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:
Andre.Kalberer 2026-07-27 06:29:51 -07:00 • committed by GitHub
commit 7fa438ce00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 133 additions and 0 deletions

View file

@ -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?,

View file

@ -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