diff --git a/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt b/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt index 115459c2..729e2f3b 100644 --- a/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt +++ b/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt @@ -42,6 +42,30 @@ private fun truncateIfNeeded(serialized: String): String { } } +private fun buildHttp2HeaderList( + pseudoHeaders: Map, headers: Map +): List { + val orderedPseudoHeaderNames = listOf(":scheme", ":method", ":path", ":authority") + + val fixedPseudoHeaders = LinkedHashMap().apply { + orderedPseudoHeaderNames.forEach { name -> + val value = pseudoHeaders[name.removePrefix(":")] ?: pseudoHeaders[name] + if (value != null) { + put(name, value) + } + } + + pseudoHeaders.forEach { (key, value) -> + val properKey = if (key.startsWith(":")) key else ":$key" + if (!containsKey(properKey)) { + put(properKey, value) + } + } + } + + return (fixedPseudoHeaders + headers).map { HttpHeader.httpHeader(it.key.lowercase(), it.value) } +} + /** * Normalizes HTTP request line endings from MCP clients. * @@ -136,25 +160,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { api.logging().logToOutput("MCP HTTP/2 request: $targetHostname:$targetPort") - val orderedPseudoHeaderNames = listOf(":scheme", ":method", ":path", ":authority") - - val fixedPseudoHeaders = LinkedHashMap().apply { - orderedPseudoHeaderNames.forEach { name -> - val value = pseudoHeaders[name.removePrefix(":")] ?: pseudoHeaders[name] - if (value != null) { - put(name, value) - } - } - - pseudoHeaders.forEach { (key, value) -> - val properKey = if (key.startsWith(":")) key else ":$key" - if (!containsKey(properKey)) { - put(properKey, value) - } - } - } - - val headerList = (fixedPseudoHeaders + headers).map { HttpHeader.httpHeader(it.key.lowercase(), it.value) } + val headerList = buildHttp2HeaderList(pseudoHeaders, headers) val request = HttpRequest.http2Request(toMontoyaService(), headerList, requestBody) val response = api.http().sendRequest(request, HttpMode.HTTP_2) @@ -162,12 +168,18 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { response?.toString() ?: "" } - mcpTool("Creates a new Repeater tab with the specified HTTP request and optional tab name. Make sure to use carriage returns appropriately.") { + mcpTool("Creates an HTTP/1.1 Repeater tab with the specified raw HTTP request and optional tab name. Make sure to use carriage returns appropriately. Prefer create_repeater_tab_http2 for modern web targets that speak HTTP/2.") { val fixedContent = normalizeHttpContent(content) val request = HttpRequest.httpRequest(toMontoyaService(), fixedContent) api.repeater().sendToRepeater(request, tabName) } + mcpTool("Creates an HTTP/2 Repeater tab with the specified HTTP/2 request and optional tab name. Use this by default for modern web targets. Do NOT pass headers to the body parameter.") { + val headerList = buildHttp2HeaderList(pseudoHeaders, headers) + val request = HttpRequest.http2Request(toMontoyaService(), headerList, requestBody) + api.repeater().sendToRepeater(request, tabName) + } + mcpTool("Sends an HTTP request to Intruder with the specified HTTP request and optional tab name. Make sure to use carriage returns appropriately.") { val fixedContent = normalizeHttpContent(content) val request = HttpRequest.httpRequest(toMontoyaService(), fixedContent) @@ -437,6 +449,17 @@ data class CreateRepeaterTab( override val usesHttps: Boolean ) : HttpServiceParams +@Serializable +data class CreateRepeaterTabHttp2( + val tabName: String?, + val pseudoHeaders: Map, + val headers: Map, + val requestBody: String, + override val targetHostname: String, + override val targetPort: Int, + override val usesHttps: Boolean +) : HttpServiceParams + @Serializable data class SendToIntruder( val tabName: String?, diff --git a/src/test/kotlin/net/portswigger/mcp/tools/ToolsKtTest.kt b/src/test/kotlin/net/portswigger/mcp/tools/ToolsKtTest.kt index 54d5aa36..ff06427e 100644 --- a/src/test/kotlin/net/portswigger/mcp/tools/ToolsKtTest.kt +++ b/src/test/kotlin/net/portswigger/mcp/tools/ToolsKtTest.kt @@ -351,12 +351,53 @@ class ToolsKtTest { val expectedOrder = listOf(":scheme", ":method", ":path", ":authority") for (i in 0 until minOf(expectedOrder.size, pseudoHeaderNames.size)) { - assertEquals(expectedOrder[i], pseudoHeaderNames[i], + assertEquals(expectedOrder[i], pseudoHeaderNames[i], "Pseudo headers should follow the order: scheme, method, path, authority") } } + + @Test + fun `create repeater tab http2 should build http2 request`() { + val repeater = mockk(relaxed = true) + val httpRequest = mockk() + val headersSlot = slot>() + val bodySlot = slot() + + every { HttpRequest.http2Request(any(), capture(headersSlot), capture(bodySlot)) } returns httpRequest + every { api.repeater() } returns repeater + + val pseudoHeaders = mapOf( + "method" to "POST", "path" to "/api/x", "authority" to "example.com", "scheme" to "https" + ) + val headers = mapOf("Content-Type" to "application/json") + val requestBody = "{\"k\":\"v\"}" + + runBlocking { + val result = client.callTool( + "create_repeater_tab_http2", mapOf( + "tabName" to "h2-tab", + "pseudoHeaders" to Json.encodeToJsonElement(pseudoHeaders), + "headers" to Json.encodeToJsonElement(headers), + "requestBody" to requestBody, + "targetHostname" to "example.com", + "targetPort" to 443, + "usesHttps" to true + ) + ) + + delay(100) + assertNotNull(result) + } + + verify(exactly = 1) { repeater.sendToRepeater(httpRequest, "h2-tab") } + assertEquals("{\"k\":\"v\"}", bodySlot.captured, "Request body should be passed through unchanged") + + val pseudoHeaderNames = headersSlot.captured.filter { it.name().startsWith(":") }.map { it.name() } + assertEquals(listOf(":scheme", ":method", ":path", ":authority"), pseudoHeaderNames) + assertTrue(headersSlot.captured.any { it.name() == "content-type" && it.value() == "application/json" }) + } } - + @Nested inner class UtilityToolsTests { @Test