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

View file

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