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

@Serializable
public class JSONRPCResponse(
public val id: RequestId,
public val id: RequestId?,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
public val id: RequestId?,
public val id: RequestId? = null,

Copy link
Contributor

Choose a reason for hiding this comment

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

According to the spec, the id is required field

}
}

@KtorDsl
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing KDoc

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.

this.response.status(status)
this.respond(
JSONRPCResponse(
id = null,
Copy link

Choose a reason for hiding this comment

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

Yom can use RequestId.StringId("server-error") as in the Python SDK


val hasRequest = messages.any { it is JSONRPCRequest }
if (!hasRequest) {
call.respondNullable(status = HttpStatusCode.Accepted, message = null)
Copy link

Choose a reason for hiding this comment

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

Perhaps because of the Accept request header, this line returns 406 Not Acceptable instead of 202. MCP Inspector throws an error in log, and the Python client crashes because of this. For me, call.respondBytes(status = HttpStatusCode.Accepted, bytes = ByteArray(0)) helped.

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.

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

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
4 participants