feat(client): send MCP standard POST headers (#894)
## Problem SEP-2243 requires Streamable HTTP clients to mirror applicable JSON-RPC request metadata into standard HTTP headers. The Kotlin client currently sends `Mcp-Protocol-Version` and session metadata but does not emit `Mcp-Method` or `Mcp-Name`. ## Solution - Add `Mcp-Method` for JSON-RPC requests and notifications. - Add `Mcp-Name` from a string `params.name`, falling back to a string `params.uri`. - Omit both headers for JSON-RPC responses. - Add focused transport tests for name-, URI-, notification-, and response-based behavior. ## Validation - `.\gradlew.bat :kotlin-sdk-client:jvmTest --tests "io.modelcontextprotocol.kotlin.sdk.client.streamable.http.StreamableHttpClientTransportTest"` - `.\gradlew.bat :kotlin-sdk-client:ktlintCheck :kotlin-sdk-client:detekt :kotlin-sdk-client:apiCheck` - `git diff --check` Closes #787 AI assistance was used while preparing this PR. I reviewed the final change, ran the validation listed above, and take responsibility for it. --------- Co-authored-by: Pavel Gorgulov <devcrocod@gmail.com>
This commit is contained in:
parent
94745e617c
commit
7fa438ce00
2 changed files with 133 additions and 0 deletions
|
|
@ -43,6 +43,10 @@ import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
@ -51,6 +55,10 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
private const val MCP_SESSION_ID_HEADER = "mcp-session-id"
|
private const val MCP_SESSION_ID_HEADER = "mcp-session-id"
|
||||||
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
|
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
|
||||||
private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID"
|
private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID"
|
||||||
|
private const val MCP_METHOD_HEADER = "Mcp-Method"
|
||||||
|
private const val MCP_NAME_HEADER = "Mcp-Name"
|
||||||
|
private const val MCP_BASE64_PREFIX = "=?base64?"
|
||||||
|
private const val MCP_BASE64_SUFFIX = "?="
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default maximum size, in characters, of a single inline SSE event assembled from a POST response.
|
* Default maximum size, in characters, of a single inline SSE event assembled from a POST response.
|
||||||
|
|
@ -167,6 +175,7 @@ public class StreamableHttpClientTransport(
|
||||||
val jsonBody = McpJson.encodeToString(message)
|
val jsonBody = McpJson.encodeToString(message)
|
||||||
val response = client.post(url) {
|
val response = client.post(url) {
|
||||||
applyCommonHeaders(this)
|
applyCommonHeaders(this)
|
||||||
|
applyStandardPostHeaders(this, message)
|
||||||
headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}")
|
headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(jsonBody)
|
setBody(jsonBody)
|
||||||
|
|
@ -390,6 +399,36 @@ public class StreamableHttpClientTransport(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyStandardPostHeaders(builder: HttpRequestBuilder, message: JSONRPCMessage) {
|
||||||
|
val (method, params) = when (message) {
|
||||||
|
is JSONRPCRequest -> message.method to message.params
|
||||||
|
is JSONRPCNotification -> message.method to message.params
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.headers {
|
||||||
|
append(MCP_METHOD_HEADER, method)
|
||||||
|
|
||||||
|
val paramsObject = params as? JsonObject ?: return@headers
|
||||||
|
val mcpName = paramsObject.stringValue("name") ?: paramsObject.stringValue("uri")
|
||||||
|
mcpName?.let { append(MCP_NAME_HEADER, it.encodeMcpHeaderValue()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonObject.stringValue(key: String): String? =
|
||||||
|
(get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private fun String.encodeMcpHeaderValue(): String {
|
||||||
|
val containsUnsafeCharacters = any { it != '\t' && it.code !in 0x20..0x7e }
|
||||||
|
val hasEdgeWhitespace = firstOrNull()?.isWhitespace() == true || lastOrNull()?.isWhitespace() == true
|
||||||
|
val matchesBase64Sentinel = startsWith(MCP_BASE64_PREFIX) && endsWith(MCP_BASE64_SUFFIX)
|
||||||
|
|
||||||
|
if (!containsUnsafeCharacters && !hasEdgeWhitespace && !matchesBase64Sentinel) return this
|
||||||
|
|
||||||
|
return "$MCP_BASE64_PREFIX${Base64.Default.encode(encodeToByteArray())}$MCP_BASE64_SUFFIX"
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun collectSse(
|
private suspend fun collectSse(
|
||||||
session: ClientSSESession,
|
session: ClientSSESession,
|
||||||
replayMessageId: RequestId?,
|
replayMessageId: RequestId?,
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,100 @@ class StreamableHttpClientTransportTest {
|
||||||
transport.close()
|
transport.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should send MCP method and name headers from name`() = runTest {
|
||||||
|
val transport = createTransport { request ->
|
||||||
|
assertEquals("tools/call", request.headers["Mcp-Method"])
|
||||||
|
assertEquals("read_file", request.headers["Mcp-Name"])
|
||||||
|
respond(content = "", status = HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.start()
|
||||||
|
transport.send(
|
||||||
|
JSONRPCRequest(
|
||||||
|
id = "test-id",
|
||||||
|
method = "tools/call",
|
||||||
|
params = buildJsonObject {
|
||||||
|
put("name", JsonPrimitive("read_file"))
|
||||||
|
put("arguments", buildJsonObject { put("path", JsonPrimitive("README.md")) })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
transport.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should send MCP name header from URI`() = runTest {
|
||||||
|
val transport = createTransport { request ->
|
||||||
|
assertEquals("resources/read", request.headers["Mcp-Method"])
|
||||||
|
assertEquals("file:///README.md", request.headers["Mcp-Name"])
|
||||||
|
respond(content = "", status = HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.start()
|
||||||
|
transport.send(
|
||||||
|
JSONRPCRequest(
|
||||||
|
id = "test-id",
|
||||||
|
method = "resources/read",
|
||||||
|
params = buildJsonObject { put("uri", JsonPrimitive("file:///README.md")) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
transport.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should encode MCP name header values when required`() = runTest {
|
||||||
|
val cases = listOf(
|
||||||
|
"Hello, 世界" to "=?base64?SGVsbG8sIOS4lueVjA==?=",
|
||||||
|
" padded " to "=?base64?IHBhZGRlZCA=?=",
|
||||||
|
"line1\nline2" to "=?base64?bGluZTEKbGluZTI=?=",
|
||||||
|
"=?base64?literal?=" to "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=",
|
||||||
|
)
|
||||||
|
|
||||||
|
cases.forEachIndexed { index, (name, expectedHeader) ->
|
||||||
|
val transport = createTransport { request ->
|
||||||
|
assertEquals(expectedHeader, request.headers["Mcp-Name"])
|
||||||
|
respond(content = "", status = HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.start()
|
||||||
|
transport.send(
|
||||||
|
JSONRPCRequest(
|
||||||
|
id = "test-id-$index",
|
||||||
|
method = "tools/call",
|
||||||
|
params = buildJsonObject { put("name", JsonPrimitive(name)) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
transport.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should send MCP method header for notifications`() = runTest {
|
||||||
|
val transport = createTransport { request ->
|
||||||
|
assertEquals("notifications/tools/list_changed", request.headers["Mcp-Method"])
|
||||||
|
assertNull(request.headers["Mcp-Name"])
|
||||||
|
respond(content = "", status = HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.start()
|
||||||
|
transport.send(JSONRPCNotification(method = "notifications/tools/list_changed"))
|
||||||
|
transport.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `should omit MCP standard headers for responses`() = runTest {
|
||||||
|
val transport = createTransport { request ->
|
||||||
|
assertNull(request.headers["Mcp-Method"])
|
||||||
|
assertNull(request.headers["Mcp-Name"])
|
||||||
|
respond(content = "", status = HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.start()
|
||||||
|
transport.send(JSONRPCResponse(id = RequestId.StringId("test-id")))
|
||||||
|
transport.close()
|
||||||
|
}
|
||||||
|
|
||||||
// Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support
|
// Engine doesn't support SSECapability: https://youtrack.jetbrains.com/issue/KTOR-8177/MockEngine-Add-SSE-support
|
||||||
@Ignore
|
@Ignore
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue