Skip to content

Conversation

@zshea
Copy link

@zshea zshea commented Aug 25, 2025

Closes #79 #183

Implement server StreamableHttpTransport (both stateful and stateless)
This is a workable solution, tested with Claude Code

Caveat:

  1. The Ktor server SSE plugin only supports the GET method. Even if I hacked the routing to support the POST method, it behaves weirdly (see additional context). In this PR, the StreamableHttpTransport always works with enableJsonResponse = true. We can remove this limitation once the upstream has fixed this issue.
  2. Because of the above issue, I can't write an integration test for this transport. The client StreamHttpTransport assumes the SSE stream while the server assumes the JSON response.
  3. I didn't update the documentation, hope this PR won't have significant changes. I'll update the documentation after this PR is merged.

Motivation and Context

It is painful to support /sse + /message endpoint on production. Hopefully we can close this issue with this PR.

How Has This Been Tested?

It has been tested in my environment with Claude Code (1.0.89) MCP with both mcpStreamableHttp and mcpStatelessStreamableHttp

Breaking Changes

No, everything is backward compatible

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  1. Ktor server SSE plugin with POST method
    It is workable with the following implementation:
route("/sse", HttpMethod.POST) {
    sse {
        call.response.status(HttpStatusCode.NotAcceptable)
        send(event = "message", data = "1")
    }
}

But the response is always 200, even if we set the status explicitly. And the connection won't be closed with a 4xx or 5xx response. It breaks the MCP spec and can't be used directly. I think we need to create issues to upstream.

  1. Thanks @SeanChinJunKai and @devcrocod 's work on Add Streamable Http Transport #87. I got some insights there and created this PR.

@zshea zshea mentioned this pull request Aug 25, 2025
9 tasks
@zshea
Copy link
Author

zshea commented Aug 25, 2025

Hi @e5l @devcrocod @SeanChinJunKai , would you mind taking a look?

@e5l e5l requested a review from devcrocod August 28, 2025 06:49
@zshea
Copy link
Author

zshea commented Sep 10, 2025

Hi @devcrocod , would you mind taking a look when available?

@kpavlov kpavlov force-pushed the zshea/streamable-http branch from 6820649 to 465f3dd Compare September 16, 2025 08:51
Copy link
Contributor

@kpavlov kpavlov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @zshea for the PR
Would it be possible to add integration tests to make sure it actually works?
Also, id is mandatory for JSONRPCRequest, JSONRPCResponse, so let's keep it non-nullable

Copy link

@dvilker dvilker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried your PR in practice in Stateless mode and brought back what I found.

block: RoutingContext.() -> Server,
) {
routing {
post("/mcp") {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get requests also need to be processed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't implement because it's not needed for json response. Once we add SSE back, we can do it then.

Copy link

@dvilker dvilker Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't implement because it's not needed for json response. Once we add SSE back, we can do it then.

https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http

The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint

Without processing (which responds with code 405), it seems the inspector was spamming errors.

I replaced post(... with route(".... Everything else has already been implemented by you.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm drafting a PR to return 405 for the stateless extension on GET requests, as per spec

The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint.

@mantisMolloy
Copy link

Hi @zshea

Thanks for this PR.

In relation to:

But the response is always 200, even if we set the status explicitly. And the connection won't be closed with a 4xx or 5xx response. It breaks the MCP spec and can't be used directly. I think we need to create issues to upstream.

Was an issue created on ktor for this?

@mantisMolloy
Copy link

One thing that's not clear to me

I can't write an integration test for this transport. The client StreamHttpTransport assumes the SSE stream while the server assumes the JSON response.

Looking at the spec the client MUST be able to accept a json response. Is this a bug in the client?

From the spec:

If the input contains any number of JSON-RPC requests, the server MUST either return Content-Type: text/event-stream, to initiate an SSE stream, or Content-Type: application/json, to return one JSON object. The client MUST support both these cases.

@mantisMolloy
Copy link

mantisMolloy commented Oct 5, 2025

I played around with this a bit.

  1. When using enableJsonResponse = true the client StreamHttpTransport works when you add a sse route for get requests using the ktor plugin. Just pass the session into the the server StreamHttpTransport and then the it will return a 406 status and this overcomes the issue where the client assumes the SSE stream. We should be able to add integration tests if the sse route is added.

  2. I'm not sure if a fix is required upstream for the ktor routing plugin. Could we implement a version of io.ktor.server.sse.ServerSSESession and initiate the event stream inside handlePostRequest when enableJsonResponse = false.

@zshea
Copy link
Author

zshea commented Oct 29, 2025

Hi @zshea

Thanks for this PR.

In relation to:

But the response is always 200, even if we set the status explicitly. And the connection won't be closed with a 4xx or 5xx response. It breaks the MCP spec and can't be used directly. I think we need to create issues to upstream.

Was an issue created on ktor for this?

I found an answer here: https://youtrack.jetbrains.com/issue/KTOR-8327/SSE-Document-how-to-receive-SSE-requests-with-POST-and-other-methods#focus=Comments-27-12594500.0-0

The workaround significantly changes the architectureof how we implement transport: need to split header response and body response.

@zshea
Copy link
Author

zshea commented Oct 29, 2025

Thank you @zshea for the PR Would it be possible to add integration tests to make sure it actually works?

TBH I've been struggling with adding tests here, would appreciate some help here, or I can dive more later. Sorry almost forgot this PR for a while...

devcrocod and others added 7 commits November 19, 2025 18:37
…to include `TransportSendOptions`. Added support for optional resumption tokens and progress callbacks in request handling.

# Conflicts:
#	kotlin-sdk-server/api/kotlin-sdk-server.api
…r` usage. Updated references from `ErrorCode` to `RPCError.ErrorCode`. Adjusted imports for consistency with `types` package structure.
…to include `TransportSendOptions`. Added support for optional resumption tokens and progress callbacks in request handling.
…, simplify `session` handling, and replace `LATEST_PROTOCOL_VERSION` with `DEFAULT_NEGOTIATED_PROTOCOL_VERSION`. Adjusted request validation and related imports.
@devcrocod devcrocod force-pushed the zshea/streamable-http branch from 128f798 to ecaeb03 Compare November 19, 2025 17:41
… and request validation, and optimized DNS rebind protection logic. Added safeguards for headers and session cleanups.
devcrocod added a commit that referenced this pull request Nov 20, 2025
Add support for `TransportSendOptions` in `Transport.send` method

## Motivation and Context
This change extends the `Transport.send` method signature to support
advanced transport features like resumption tokens and progress
callbacks. This is needed to enable:
- Long-running request resumption after reconnection
- Request-response association tracking
- Progress token handling in transport layer

needed for #235 

## How Has This Been Tested?
All transport tests passed

## Breaking Changes
Yes, it’s necessary to update the custom Transport implementations.
For users who rely on the transports provided by the sdk, there’s no
breaking change

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed
@joewlambeth
Copy link

Thanks @devcrocod for the work on resolving the merge conflicts. I'm creating integration tests for this, but I'm running into an issue where Ktor is sending 406 Not Acceptable on the Streamable Server Transport for the initialize call, despite an OK payload. There shouldn't be any issues serializing the JSONRPCResponse. This wasn't happening with OldSchema integration tests, but now it seems to be happening. I'll look into this more soon, but I'll leave some details here in case there's some obvious I'm missing.

The attempted JSON RPC payload is as follows:

JSONRPCResponse(id=NumberId(value=1), result=InitializeResult(protocolVersion=2025-06-18, capabilities=ServerCapabilities(tools=Tools(listChanged=true), resources=null, prompts=null, logging=null, completions=null, experimental=null), serverInfo=Implementation(name=test-server, version=1.0, title=null, websiteUrl=null, icons=null), instructions=null, meta=null))

However, this is what the Ktor server is sending back.

[DefaultDispatcher-worker-2 @request-handler#23] INFO io.ktor.server.Application - Status: 406 Not Acceptable, Method: POST, Path: /mcp

Here's how i'm starting the test server

            TransportKind.STREAMABLE_HTTP_STATELESS -> {
                serverEngine = embeddedServer(ServerCIO, host = host, port = port) {
                    install(CallLogging) {
                        level = Level.INFO 
                        format { call ->
                            val status = call.response.status()
                            val httpMethod = call.request.httpMethod.value
                            val path = call.request.path()
                            "Status: $status, Method: $httpMethod, Path: $path"
                        }
                    }
                    routing {
                        mcpStatelessStreamableHttp { server }
                    }
                }.start(wait = false)
            }

@devcrocod
Copy link
Contributor

Hey @joewlambeth,

I didn’t open a separate pull request and instead continued the work in the same branch
I felt it would be easier to keep track of the progress that way. I hope this didn’t cause any inconvenience for anyone.

While going through the review of this PR, I fixed a number of issues I discovered both in the streamable implementation itself and in our protocol. Next, I’m planning to polish the implementation, review the KtorServer logic, and write an integration test using the typescript client against our server to verify that the basic streamable transport flow works correctly. I think this should be enough to merge these changes.

After that, we’ll add broader test coverage for the streamable server transport, as well as conformance tests, to make sure we fully align with the specification.

Thanks a lot for the example you provided, it’s definitely helpful. We also downgraded the Ktor version recently, which could be another reason why your test stopped working. I’ll take a closer look at this when I work on the integration test with the typescipt client

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.

Adding Streaming HTTP Transport

6 participants