Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 43 additions & 20 deletions src/main/kotlin/net/portswigger/mcp/tools/Tools.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ private fun truncateIfNeeded(serialized: String): String {
}
}

private fun buildHttp2HeaderList(
pseudoHeaders: Map<String, String>, headers: Map<String, String>
): List<HttpHeader> {
val orderedPseudoHeaderNames = listOf(":scheme", ":method", ":path", ":authority")

val fixedPseudoHeaders = LinkedHashMap<String, String>().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.
*
Expand Down Expand Up @@ -136,38 +160,26 @@ 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<String, String>().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)

response?.toString() ?: "<no response>"
}

mcpTool<CreateRepeaterTab>("Creates a new Repeater tab with the specified HTTP request and optional tab name. Make sure to use carriage returns appropriately.") {
mcpTool<CreateRepeaterTab>("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<CreateRepeaterTabHttp2>("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<SendToIntruder>("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)
Expand Down Expand Up @@ -437,6 +449,17 @@ data class CreateRepeaterTab(
override val usesHttps: Boolean
) : HttpServiceParams

@Serializable
data class CreateRepeaterTabHttp2(
val tabName: String?,
val pseudoHeaders: Map<String, String>,
val headers: Map<String, String>,
val requestBody: String,
override val targetHostname: String,
override val targetPort: Int,
override val usesHttps: Boolean
) : HttpServiceParams

@Serializable
data class SendToIntruder(
val tabName: String?,
Expand Down
45 changes: 43 additions & 2 deletions src/test/kotlin/net/portswigger/mcp/tools/ToolsKtTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<burp.api.montoya.repeater.Repeater>(relaxed = true)
val httpRequest = mockk<HttpRequest>()
val headersSlot = slot<List<HttpHeader>>()
val bodySlot = slot<String>()

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
Expand Down