Skip to content
Open
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
46 changes: 44 additions & 2 deletions src/main/kotlin/net/portswigger/mcp/tools/McpTool.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reified I : Any> Server.mcpTool(
Expand Down Expand Up @@ -108,6 +109,48 @@ inline fun <reified I : Paginated> 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 <reified I : Paginated> Server.mcpPaginatedBytesTool(
description: String,
crossinline execute: I.() -> String
) {
mcpTool<I>(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")
Expand Down Expand Up @@ -156,5 +199,4 @@ fun String.toLowerSnakeCase(): String {
interface Paginated {
val count: Int
val offset: Int
}

}
24 changes: 12 additions & 12 deletions src/main/kotlin/net/portswigger/mcp/tools/Tools.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ private fun truncateIfNeeded(serialized: String): String {

fun Server.registerTools(api: MontoyaApi, config: McpConfig) {

mcpTool<SendHttp1Request>("Issues an HTTP/1.1 request and returns the response.") {
mcpPaginatedBytesTool<SendHttp1Request>("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")
Expand All @@ -63,7 +63,7 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {
response?.toString() ?: "<no response>"
}

mcpTool<SendHttp2Request>("Issues an HTTP/2 request and returns the response. Do NOT pass headers to the body parameter.") {
mcpPaginatedBytesTool<SendHttp2Request>("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"
Expand All @@ -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")
Expand Down Expand Up @@ -193,8 +193,8 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {

mcpTool<GenerateCollaboratorPayload>(
"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" } ?: ""}")

Expand All @@ -210,8 +210,8 @@ fun Server.registerTools(api: MontoyaApi, config: McpConfig) {

mcpTool<GetCollaboratorInteractions>(
"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" } ?: ""}")

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down