Skip to content

Commit f1079ab

Browse files
committed
Surface MCP HTTP requests in Site Map and normalize Repeater newlines
send_http1_request and send_http2_request now register the response via api.siteMap().add() after sending. Previously these calls bypassed every visible Burp surface (Proxy History is read-only to extensions, and the existing logToOutput line only recorded host:port). Site Map is the closest writable surface for extension-issued traffic, so users can review requests issued by the MCP under Target > Site map. The add is gated on HttpRequestResponse.hasResponse() so failed attempts (e.g. HTTP/2 against an HTTP/1.1-only origin, which returns a non-null wrapper with response() == null) do not pollute Site Map with request-only entries. Burp's Logger continues to record those attempts as "communication error", which is the right place for them. create_repeater_tab now normalizes \n to \r\n before constructing the request, matching send_http1_request. Without this, callers that pass LF-only content (common when an LLM emits a multi-line string) produce garbled requests in the Repeater tab. Adds tests covering siteMap.add on the success path, the no-add case when sendRequest returns null, the no-add case when the wrapper has no response, and the CRLF normalization in create_repeater_tab.
1 parent 2b07992 commit f1079ab

2 files changed

Lines changed: 82 additions & 3 deletions

File tree

src/main/kotlin/net/portswigger/mcp/tools/Tools.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
6060
val request = HttpRequest.httpRequest(toMontoyaService(), fixedContent)
6161
val response = api.http().sendRequest(request)
6262

63+
response?.takeIf { it.hasResponse() }?.let { api.siteMap().add(it) }
64+
6365
response?.toString() ?: "<no response>"
6466
}
6567

@@ -111,11 +113,14 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
111113
val request = HttpRequest.http2Request(toMontoyaService(), headerList, requestBody)
112114
val response = api.http().sendRequest(request, HttpMode.HTTP_2)
113115

116+
response?.takeIf { it.hasResponse() }?.let { api.siteMap().add(it) }
117+
114118
response?.toString() ?: "<no response>"
115119
}
116120

117121
mcpTool<CreateRepeaterTab>("Creates a new Repeater tab with the specified HTTP request and optional tab name. Make sure to use carriage returns appropriately.") {
118-
val request = HttpRequest.httpRequest(toMontoyaService(), content)
122+
val fixedContent = content.replace("\r", "").replace("\n", "\r\n")
123+
val request = HttpRequest.httpRequest(toMontoyaService(), fixedContent)
119124
api.repeater().sendToRepeater(request, tabName)
120125
}
121126

src/test/kotlin/net/portswigger/mcp/tools/ToolsKtTest.kt

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ class ToolsKtTest {
173173
}
174174
every { api.http() } returns httpService
175175
every { httpResponse.toString() } returns "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nResponse body"
176+
every { httpResponse.hasResponse() } returns true
176177
every { httpService.sendRequest(capture(capturedRequest)) } returns httpResponse
177178

178179
runBlocking {
@@ -192,6 +193,7 @@ class ToolsKtTest {
192193
}
193194

194195
verify(exactly = 1) { httpService.sendRequest(any<HttpRequest>()) }
196+
verify(exactly = 1) { api.siteMap().add(httpResponse) }
195197
assertEquals("GET /foo HTTP/1.1\r\nHost: example.com\r\n\r\n", capturedRequest.captured.toString(), "Request body should match")
196198
}
197199

@@ -222,6 +224,8 @@ class ToolsKtTest {
222224
delay(100)
223225
result.expectTextContent("<no response>")
224226
}
227+
228+
verify(exactly = 0) { api.siteMap().add(any<burp.api.montoya.http.message.HttpRequestResponse>()) }
225229
}
226230

227231
@Test
@@ -235,6 +239,7 @@ class ToolsKtTest {
235239

236240
every { HttpRequest.http2Request(any(), capture(headersSlot), capture(bodySlot)) } returns httpRequest
237241
every { httpResponse.toString() } returns "HTTP/2 200 OK\r\nContent-Type: text/plain\r\n\r\nResponse body"
242+
every { httpResponse.hasResponse() } returns true
238243
every { api.http() } returns httpService
239244
every { httpService.sendRequest(capture(requestSlot), HttpMode.HTTP_2) } returns httpResponse
240245

@@ -265,7 +270,8 @@ class ToolsKtTest {
265270
}
266271

267272
verify(exactly = 1) { HttpRequest.http2Request(any(), any(), any<String>()) }
268-
273+
verify(exactly = 1) { api.siteMap().add(httpResponse) }
274+
269275
assertEquals("Test body", bodySlot.captured, "Request body should match")
270276

271277
val pseudoHeaderList = headersSlot.captured.filter { it.name().startsWith(":") }
@@ -307,8 +313,76 @@ class ToolsKtTest {
307313
delay(100)
308314
result.expectTextContent("<no response>")
309315
}
316+
317+
verify(exactly = 0) { api.siteMap().add(any<burp.api.montoya.http.message.HttpRequestResponse>()) }
310318
}
311-
319+
320+
@Test
321+
fun `http2 should not add to site map when response is missing`() {
322+
val httpService = mockk<Http>()
323+
val httpRequest = mockk<HttpRequest>()
324+
val httpResponse = mockk<burp.api.montoya.http.message.HttpRequestResponse>()
325+
326+
every { HttpRequest.http2Request(any(), any(), any<String>()) } returns httpRequest
327+
every { api.http() } returns httpService
328+
every { httpService.sendRequest(any(), HttpMode.HTTP_2) } returns httpResponse
329+
every { httpResponse.hasResponse() } returns false
330+
every { httpResponse.toString() } returns "HttpRequestResponse{httpRequest=..., httpResponse=null}"
331+
332+
val pseudoHeaders = mapOf("method" to "GET", "path" to "/test", "authority" to "example.com", "scheme" to "https")
333+
val headers = mapOf("User-Agent" to "Test Agent")
334+
335+
runBlocking {
336+
val result = client.callTool(
337+
"send_http2_request", mapOf(
338+
"pseudoHeaders" to Json.encodeToJsonElement(pseudoHeaders),
339+
"headers" to Json.encodeToJsonElement(headers),
340+
"requestBody" to "",
341+
"targetHostname" to "example.com",
342+
"targetPort" to 443,
343+
"usesHttps" to true
344+
)
345+
)
346+
347+
delay(100)
348+
assertNotNull(result)
349+
}
350+
351+
verify(exactly = 0) { api.siteMap().add(any<burp.api.montoya.http.message.HttpRequestResponse>()) }
352+
}
353+
354+
@Test
355+
fun `create repeater tab should normalize line endings`() {
356+
val repeater = mockk<burp.api.montoya.repeater.Repeater>(relaxed = true)
357+
val contentSlot = slot<String>()
358+
359+
every { HttpRequest.httpRequest(any(), capture(contentSlot)) } answers {
360+
val captured = secondArg<String>()
361+
mockk<HttpRequest>().also {
362+
every { it.toString() } returns captured
363+
}
364+
}
365+
every { api.repeater() } returns repeater
366+
367+
runBlocking {
368+
val result = client.callTool(
369+
"create_repeater_tab", mapOf(
370+
"tabName" to "lf-only",
371+
"content" to "GET /foo HTTP/1.1\nHost: example.com\n\n",
372+
"targetHostname" to "example.com",
373+
"targetPort" to 80,
374+
"usesHttps" to false
375+
)
376+
)
377+
378+
delay(100)
379+
assertNotNull(result)
380+
}
381+
382+
verify(exactly = 1) { repeater.sendToRepeater(any<HttpRequest>(), "lf-only") }
383+
assertEquals("GET /foo HTTP/1.1\r\nHost: example.com\r\n\r\n", contentSlot.captured, "LF should be normalized to CRLF before sending to Repeater")
384+
}
385+
312386
@Test
313387
fun `http2 pseudo headers should be ordered correctly`() {
314388
val httpService = mockk<Http>()

0 commit comments

Comments
 (0)