modelcontextprotocol / modelcontextprotocol/kotlin-sdk
Ktor SSE / StreamableHttp extensions build their transports internally, with no hook to wrap them
Nobody has claimed this yet.
- Dominant language
- Kotlin
- Stars
- 1.5k
- Forks
- 248
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 23
Description
Affected version / component: io.modelcontextprotocol:kotlin-sdk 0.14.0 — server/KtorServer.kt
The Application.mcp { } (SSE) and Application.mcpStreamableHttp { } extensions construct the transport and create the session entirely inside the extension. The user block only returns a Server, so there is no point at which caller code can wrap, decorate, or intercept the Transport before it is used. That rules out any cross-cutting transport behavior — logging, metrics, back-pressure, custom framing — on HTTP sessions, even though it is straightforward for transports you build yourself.
The SSE endpoint is representative:
private suspend fun ServerSSESession.mcpSseEndpoint(..., block: ServerSSESession.() -> Server) {
val transport = mcpSseTransport(postEndpoint, transportManager, maxRequestBodySize) // SseServerTransport(...)
val server = block()
server.onClose { ... }
server.createSession(transport)
awaitCancellation()
}
mcpStreamableHttp has the same shape, with StreamableHttpServerTransport(configuration) followed by server.createSession(transport). Because the block returns a Server, the caller never sees the Transport and cannot substitute a delegating wrapper. mcpStreamableHttp { } does accept a serverFactory parameter, but it only supplies the Server; it does not expose or allow wrapping the transport, so it does not close this gap.
(Surfacing the JSON-RPC request id to handlers, which might otherwise motivate a transport hook, is being addressed separately upstream, so it is out of scope here.)
Suggested fix
Expose a transport factory/interceptor hook — let the mcp / mcpStreamableHttp setup wrap each Transport before createSession, or route these sessions through a user-controllable connect() path so a subclassed server can intercept them.
Workaround
The HTTP/SSE transport classes (SseServerTransport, StreamableHttpServerTransport) and their handleRequest/handlePostMessage methods are public, so you can stand up your own Ktor routes, construct the transport, wrap it, and call Server.createSession(wrappedTransport) directly. The cost is real: the extension wiring you have to replicate — TransportManager, the session-id lifecycle (setOnSessionInitialized/setOnSessionClosed), DNS-rebinding protection, and content negotiation — is private, roughly a hundred lines that then have to track SDK internals across upgrades.
Impact
A transport hook would bring logging, metrics, back-pressure, and custom framing to HTTP/SSE without reimplementing the extension. Today, parity between stdio and HTTP transports means duplicating that internal wiring.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in server/KtorServer.kt with the Application.mcp and Application.mcpStreamableHttp extensions, then inspect SseServerTransport, StreamableHttpServerTransport, and Server.createSession. Determine how a transport factory or interceptor can be exposed while preserving TransportManager, session lifecycle callbacks, DNS-rebinding protection, and content negotiation. Done means callers can wrap each HTTP/SSE transport without recreating the extension wiring.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin
- Domain
- api, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100