From c017bac04e823ffe50163f9f63eb900102dfc9fb Mon Sep 17 00:00:00 2001 From: radovan Date: Fri, 10 Apr 2026 17:00:40 +0200 Subject: [PATCH] Add pagination support to HTTP request tools --- .../net/portswigger/mcp/tools/McpTool.kt | 46 ++++++++++++++++++- .../kotlin/net/portswigger/mcp/tools/Tools.kt | 24 +++++----- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/net/portswigger/mcp/tools/McpTool.kt b/src/main/kotlin/net/portswigger/mcp/tools/McpTool.kt index 3fe49c2..705a895 100644 --- a/src/main/kotlin/net/portswigger/mcp/tools/McpTool.kt +++ b/src/main/kotlin/net/portswigger/mcp/tools/McpTool.kt @@ -10,6 +10,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import net.portswigger.mcp.schema.asInputSchema import kotlin.experimental.ExperimentalTypeInference +import kotlin.math.ceil @OptIn(InternalSerializationApi::class) inline fun Server.mcpTool( @@ -108,6 +109,48 @@ inline fun Server.mcpPaginatedTool( }) } +/** + * Variant of mcpPaginatedTool for tools that return a single (potentially large) string. + * + * Here, [offset] is a **byte offset** into the response and [count] is the **maximum number + * of bytes** to return in this page. The caller (Claude) should start with offset=0 and keep + * calling with offset += count until it receives a page whose length is less than count, or + * it receives the "Reached end of response" message. + * + * A safe default page size is 800 000 bytes, which stays well under the 1 MB MCP tool-result + * limit even after JSON encoding overhead. + */ +inline fun Server.mcpPaginatedBytesTool( + description: String, + crossinline execute: I.() -> String +) { + mcpTool(description, execute = { + val full = execute(this) + val bytes = full.toByteArray(Charsets.UTF_8) + val totalBytes = bytes.size + + if (offset >= totalBytes) { + val totalPages = ceil(totalBytes.toDouble() / count).toInt().coerceAtLeast(1) + "Reached end of response (total bytes: $totalBytes, total pages at this page size: $totalPages)" + } else { + val end = (offset + count).coerceAtMost(totalBytes) + val chunk = bytes.sliceArray(offset until end).toString(Charsets.UTF_8) + val remaining = totalBytes - end + val totalPages = ceil(totalBytes.toDouble() / count).toInt() + val currentPage = ceil(end.toDouble() / count).toInt() + + buildString { + appendLine("=== Page $currentPage of $totalPages | bytes $offset–$end of $totalBytes | ${if (remaining > 0) "$remaining bytes remaining" else "last page"} ===") + if (remaining > 0) { + appendLine("Call again with offset=$end to get the next page.") + } + appendLine() + append(chunk) + } + } + }) +} + @OptIn(ExperimentalTypeInference::class) @OverloadResolutionByLambdaReturnType @JvmName("mcpNamedToolString") @@ -156,5 +199,4 @@ fun String.toLowerSnakeCase(): String { interface Paginated { val count: Int val offset: Int -} - +} \ No newline at end of file diff --git a/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt b/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt index 5443eb6..304e47d 100644 --- a/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt +++ b/src/main/kotlin/net/portswigger/mcp/tools/Tools.kt @@ -44,13 +44,13 @@ private fun truncateIfNeeded(serialized: String): String { fun Server.registerTools(api: MontoyaApi, config: McpConfig) { - mcpTool("Issues an HTTP/1.1 request and returns the response.") { + mcpPaginatedBytesTool("Issues an HTTP/1.1 request and returns the response. Response is paginated") { val allowed = runBlocking { HttpRequestSecurity.checkHttpRequestPermission(targetHostname, targetPort, config, content, api) } if (!allowed) { api.logging().logToOutput("MCP HTTP request denied: $targetHostname:$targetPort") - return@mcpTool "Send HTTP request denied by Burp Suite" + return@mcpPaginatedBytesTool "Send HTTP request denied by Burp Suite" } api.logging().logToOutput("MCP HTTP/1.1 request: $targetHostname:$targetPort") @@ -63,7 +63,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { response?.toString() ?: "" } - mcpTool("Issues an HTTP/2 request and returns the response. Do NOT pass headers to the body parameter.") { + mcpPaginatedBytesTool("Issues an HTTP/2 request and returns the response. Do NOT pass headers to the body parameter. Response is paginated") { val http2RequestDisplay = buildString { pseudoHeaders.forEach { (key, value) -> val headerName = if (key.startsWith(":")) key else ":$key" @@ -83,7 +83,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { } if (!allowed) { api.logging().logToOutput("MCP HTTP request denied: $targetHostname:$targetPort") - return@mcpTool "Send HTTP request denied by Burp Suite" + return@mcpPaginatedBytesTool "Send HTTP request denied by Burp Suite" } api.logging().logToOutput("MCP HTTP/2 request: $targetHostname:$targetPort") @@ -193,8 +193,8 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { mcpTool( "Generates a Burp Collaborator payload URL for out-of-band (OOB) testing. " + - "Inject this payload into requests to detect server-side interactions (DNS lookups, HTTP requests, SMTP). " + - "Use get_collaborator_interactions with the returned payloadId to check for interactions." + "Inject this payload into requests to detect server-side interactions (DNS lookups, HTTP requests, SMTP). " + + "Use get_collaborator_interactions with the returned payloadId to check for interactions." ) { api.logging().logToOutput("MCP generating Collaborator payload${customData?.let { " with custom data" } ?: ""}") @@ -210,8 +210,8 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) { mcpTool( "Polls Burp Collaborator for out-of-band interactions (DNS, HTTP, SMTP). " + - "Optionally filter by payloadId from generate_collaborator_payload. " + - "Returns interaction details including type, timestamp, client IP, and protocol-specific data." + "Optionally filter by payloadId from generate_collaborator_payload. " + + "Returns interaction details including type, timestamp, client IP, and protocol-specific data." ) { api.logging().logToOutput("MCP polling Collaborator interactions${payloadId?.let { " for payload: $it" } ?: ""}") @@ -341,8 +341,8 @@ data class SendHttp1Request( val content: String, override val targetHostname: String, override val targetPort: Int, - override val usesHttps: Boolean -) : HttpServiceParams + override val usesHttps: Boolean, override val count: Int, override val offset: Int +) : HttpServiceParams, Paginated @Serializable data class SendHttp2Request( @@ -351,8 +351,8 @@ data class SendHttp2Request( val requestBody: String, override val targetHostname: String, override val targetPort: Int, - override val usesHttps: Boolean -) : HttpServiceParams + override val usesHttps: Boolean, override val count: Int, override val offset: Int +) : HttpServiceParams, Paginated @Serializable data class CreateRepeaterTab(