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.isActive
|
||||
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.time.Duration
|
||||
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_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
|
||||
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.
|
||||
|
|
@ -167,6 +175,7 @@ public class StreamableHttpClientTransport(
|
|||
val jsonBody = McpJson.encodeToString(message)
|
||||
val response = client.post(url) {
|
||||
applyCommonHeaders(this)
|
||||
applyStandardPostHeaders(this, message)
|
||||
headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}")
|
||||
contentType(ContentType.Application.Json)
|
||||
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(
|
||||
session: ClientSSESession,
|
||||
replayMessageId: RequestId?,
|
||||
|
|
|
|||
|
|
@ -246,6 +246,100 @@ class StreamableHttpClientTransportTest {
|
|||
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
|
||||
@Ignore
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Reference in a new issue