feat(server): add Route.mcpStreamableHttp and Route.mcpStatelessStreamableHttp - #964
Open
molo17inc wants to merge 1 commit into
Open
feat(server): add Route.mcpStreamableHttp and Route.mcpStatelessStreamableHttp#964molo17inc wants to merge 1 commit into
molo17inc wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add
Route.mcpStreamableHttpandRoute.mcpStatelessStreamableHttpextensions so callers can mount the Streamable HTTP transport inside an existingrouting { }block — in particular, under a Ktor auth interceptor such asauthenticate("my-auth") { … }.Motivation
The existing
Application.mcpStreamableHttp(and its stateless twin) callrouting { 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 Ktorauthenticate(...) { … }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.mcpStatelessStreamableHttpprivate helper was extracted into a newRoute.extension with the same defaults. The Application variants now delegate to the Route variants (behavior is byte-identical):The
Applicationvariants keep their existing semantics — including auto-installingContentNegotiationand theSSEplugin — 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-existingRoute.mcpextension.Two small mechanical notes:
Route.interceptandPipelineContext<*, PipelineCall>.callneed explicit imports when used inside a bareRoutereceiver (they resolve implicitly atrouting { }scope through Routing/Application extensions, but not at plainRoutescope). Those imports are added.Tests
All existing streamable-http and mcp-mount test suites still pass. New coverage in
KtorRouteExtensionsStreamableHttpTest:route("/api") { … }and confirming siblings remain reachablerouting { route("/v1") { route("/services") { … } } }and callinginitializeagainst the full nested pathRoute.mcpStatelessStreamableHttpunder a nested route replies to POST and rejects GET with 405Run locally:
Compatibility
Application.mcpStreamableHttp/Application.mcpStatelessStreamableHttp.Route.mcpStreamableHttp,Route.mcpStatelessStreamableHttp) and twointernalhelpers.