Skip to content

feat(server): add Route.mcpStreamableHttp and Route.mcpStatelessStreamableHttp - #964

Open
molo17inc wants to merge 1 commit into
modelcontextprotocol:mainfrom
molo17inc:feat/route-mcpstreamablehttp
Open

feat(server): add Route.mcpStreamableHttp and Route.mcpStatelessStreamableHttp#964
molo17inc wants to merge 1 commit into
modelcontextprotocol:mainfrom
molo17inc:feat/route-mcpstreamablehttp

Conversation

@molo17inc

Copy link
Copy Markdown

Summary

Add Route.mcpStreamableHttp and Route.mcpStatelessStreamableHttp extensions so callers can mount the Streamable HTTP transport inside an existing routing { } block — in particular, under a Ktor auth interceptor such as authenticate("my-auth") { … }.

Motivation

The existing Application.mcpStreamableHttp (and its stateless twin) call routing { route(path) { … } } internally, so they can only be invoked at the Application scope. There is no way to nest a Streamable HTTP endpoint inside an existing routing block, and — more importantly — there is no way to place it under a Ktor authenticate(...) { … } interceptor.

This mirrors the exact pain that motivated #237 for the SSE variant, which was fixed by adding Route.mcp. This PR does the same for Streamable HTTP.

Downstream projects that rely on authenticate(...) { … } to gate every MCP request today have to install the Streamable HTTP endpoint at the Application scope and rely on a global JWT filter, losing defense-in-depth against a misconfigured filter chain.

What this enables

routing {
    authenticate("my-auth") {
        mcpStreamableHttp(path = "/mcp") { … }
        mcpStatelessStreamableHttp(path = "/mcp-stateless") { … }
    }
}

The Ktor auth plugin now enforces the requirement on every request to the MCP endpoint.

Design

The body of each Application.mcpStreamableHttp / Application.mcpStatelessStreamableHttp private helper was extracted into a new Route. extension with the same defaults. The Application variants now delegate to the Route variants (behavior is byte-identical):

Application.mcpStreamableHttp        Application.mcpStatelessStreamableHttp
        │                                       │
        ▼ routing { … }                         ▼ routing { … }
Route.mcpStreamableHttp             Route.mcpStatelessStreamableHttp

The Application variants keep their existing semantics — including auto-installing ContentNegotiation and the SSE plugin — because they still install those plugins before delegating.

The Route. variants document that both plugins must already be installed on the enclosing application, mirroring the precondition on the pre-existing Route.mcp extension.

Two small mechanical notes:

  • Route.intercept and PipelineContext<*, PipelineCall>.call need explicit imports when used inside a bare Route receiver (they resolve implicitly at routing { } scope through Routing/Application extensions, but not at plain Route scope). Those imports are added.
  • No public API is removed. The new overloads are purely additive.

Tests

All existing streamable-http and mcp-mount test suites still pass. New coverage in KtorRouteExtensionsStreamableHttpTest:

  • Mounting under a nested route("/api") { … } and confirming siblings remain reachable
  • Mounting under multi-level routing { route("/v1") { route("/services") { … } } } and calling initialize against the full nested path
  • Route.mcpStatelessStreamableHttp under a nested route replies to POST and rejects GET with 405
  • Missing-SSE-plugin failure is still surfaced instead of silently accepting requests

Run locally:

./gradlew :kotlin-sdk-server:jvmTest

Compatibility

  • No behavior change for existing callers of Application.mcpStreamableHttp / Application.mcpStatelessStreamableHttp.
  • No public signature changes.
  • Purely additive: adds two new public overloads (Route.mcpStreamableHttp, Route.mcpStatelessStreamableHttp) and two internal helpers.

…mableHttp

The existing `Application.mcpStreamableHttp` (and its stateless twin) call
`routing { route(path) { … } }` internally, which means they can only be
invoked at the Application scope. There is no way to mount a Streamable HTTP
endpoint inside an existing `routing { }` block or, more importantly, under
a Ktor auth interceptor such as `authenticate("gs-auth") { … }`.

Downstream projects that rely on `authenticate(...) { … }` to gate every
MCP request today have to install the Streamable HTTP endpoint at the
Application scope and lean on a global JWT filter, losing defense-in-depth
against a misconfigured filter chain.

This change extracts the body of each Application-level function into a new
`Route.` extension with the same defaults and delegates the Application
variant to it (behavior is byte-identical). Users can now write:

    routing {
        authenticate("my-auth") {
            mcpStreamableHttp(path = "/mcp") { … }
            mcpStatelessStreamableHttp(path = "/mcp-stateless") { … }
        }
    }

and the Ktor auth plugin will enforce the requirement on every request.

The Application variants keep their existing semantics — including
auto-installing `ContentNegotiation` and the `SSE` plugin — because they
still install the plugins before delegating. The `Route.` variants document
that both plugins must already be installed on the enclosing application,
mirroring the precondition on the pre-existing `Route.mcp` extension.

Notes:
- `Route.intercept` and `PipelineContext<*, PipelineCall>.call` require
  explicit imports when used inside a bare `Route` receiver (they are
  resolved implicitly at `routing { }` scope through Routing/Application
  extensions but not at plain Route scope), so those imports are added.
- All four existing streamable-http/mcp-mount test suites still pass.
- New test coverage in `KtorRouteExtensionsStreamableHttpTest`:
  * mounting under a nested `route("/api") { … }` and confirming siblings
    remain reachable
  * mounting under multi-level `routing { route("/v1") { route("/services")
    { … } } }` and calling `initialize` against the full path
  * `Route.mcpStatelessStreamableHttp` under a nested route replies to POST
    and rejects GET with 405
  * missing-SSE-plugin failure is still surfaced instead of silently
    accepting requests

Signed-off-by: molo17 SRL <info@molo17.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant