Skip to content

Add stateless feature to Streamable HTTP protocol #101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

bigH
Copy link

@bigH bigH commented Jul 31, 2025

See: #99

Currently, this implementation of MCP requires that sessions are held in memory for Server-Sent Events (SSEs). The session data maps an ID to a live IO stream. This doesn't work for Chime for a few reasons:

  • We run >1 replica, so in-memory storage of session state is not going to work
  • When contacting a backend with no knowledge of a given session, the server responds with an error, breaking downstream agent functionality
  • Chime's default configuration doesn't allow for long-lived connections and connections are terminated forcibly after a timeout

So, here we add tests and validate that stateless configuration behaves according to spec. In situations where the spec is unclear about something, we defer to the Python SDK.

@koic
Copy link
Member

koic commented Aug 1, 2025

Does handle_post method need to handle the case for stateless?

@bigH
Copy link
Author

bigH commented Aug 1, 2025

Does handle_post method need to handle the case for stateless?

My understanding is that it does not. handle_post calls handle_regular_request, which only uses SSE if a stream exists.

I've tested this against my own service and it works properly. What would it take to get this merged?

@koic
Copy link
Member

koic commented Aug 2, 2025

Ah, I see. Thank you for the explanation. Can you squash your commits into one?

@bigH
Copy link
Author

bigH commented Aug 5, 2025

Happy to.

@bigH bigH force-pushed the stateless branch 2 times, most recently from a2c47d2 to ca3537e Compare August 5, 2025 17:32
@bigH
Copy link
Author

bigH commented Aug 5, 2025

Rebased on to latest main and squashed my commits.

koic
koic previously approved these changes Aug 6, 2025
- Create failing tests for `stateless` mode
- Implement `stateless` mode by turning of SSE in Streamable HTTP,
  making the interaction a standard HTTP Req/Resp
@bigH
Copy link
Author

bigH commented Aug 6, 2025

Bump @koic. I think this is good to review/merge.

bigH and others added 2 commits August 12, 2025 22:27
Co-authored-by: Koichi ITO <koic.ito@gmail.com>
Co-authored-by: Koichi ITO <koic.ito@gmail.com>

response = stateless_transport.handle_request(request)
assert_equal 202, response[0]
assert_equal({ "Content-Type" => "application/json" }, response[1])
Copy link
Member

Choose a reason for hiding this comment

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

It seems that this assertion is failing due to the changes in #105. Can you rebase with the latest main branch and update this assertion as follows?

Suggested change
assert_equal({ "Content-Type" => "application/json" }, response[1])
assert_empty(response[1])

@koic
Copy link
Member

koic commented Aug 20, 2025

@bigH This looks good overall to me. Can you rebase on the latest main branch, make sure the tests pass, and squash the commits into a single one?

@Ginja
Copy link
Contributor

Ginja commented Aug 21, 2025

I tested this change on an internal MCP server and it looks good 👍

@Ginja
Copy link
Contributor

Ginja commented Aug 21, 2025

On further testing I may have found a bug with this feature, but only when specifically using socketry/falcon (vs Puma).

{
    "time": "2025-08-21T20:28:04+00:00",
    "severity": "error",
    "pid": 20,
    "oid": 1176,
    "fiber_id": 1192,
    "subject": "Protocol::Rack::Adapter::Rack31",
    "annotation": "Reading HTTP/1.1 requests for Async::HTTP::Protocol::HTTP1::Server.",
    "message": "undefined method 'bytesize' for nil",
    "event": {
        "type": "failure",
        "root": "/app",
        "class": "NoMethodError",
        "message": "undefined method 'bytesize' for nil",
        "backtrace": [
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-rack-0.16.0/lib/protocol/rack/body/enumerable.rb:27:in 'Array#sum'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-rack-0.16.0/lib/protocol/rack/body/enumerable.rb:27:in 'Protocol::Rack::Body::Enumerable.wrap'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-rack-0.16.0/lib/protocol/rack/body.rb:64:in 'Protocol::Rack::Body.wrap'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-rack-0.16.0/lib/protocol/rack/response.rb:53:in 'Protocol::Rack::Response.wrap'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-rack-0.16.0/lib/protocol/rack/adapter/generic.rb:160:in 'Protocol::Rack::Adapter::Generic#call'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-http-0.52.0/lib/protocol/http/middleware.rb:53:in 'Protocol::HTTP::Middleware#call'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-http-0.52.0/lib/protocol/http/content_encoding.rb:40:in 'Protocol::HTTP::ContentEncoding#call'",
            "/artifacts/bundle/ruby/3.4.0/gems/protocol-http-0.52.0/lib/protocol/http/middleware.rb:53:in 'Protocol::HTTP::Middleware#call'",
            "/artifacts/bundle/ruby/3.4.0/gems/falcon-0.52.3/lib/falcon/server.rb:66:in 'Falcon::Server#call'",
            "/artifacts/bundle/ruby/3.4.0/gems/async-http-0.91.0/lib/async/http/server.rb:57:in 'block in Async::HTTP::Server#accept'",
            "/artifacts/bundle/ruby/3.4.0/gems/async-http-0.91.0/lib/async/http/protocol/http1/server.rb:72:in 'Async::HTTP::Protocol::HTTP1::Server#each'",
            "/artifacts/bundle/ruby/3.4.0/gems/async-http-0.91.0/lib/async/http/server.rb:49:in 'Async::HTTP::Server#accept'",
            "/artifacts/bundle/ruby/3.4.0/gems/falcon-0.52.3/lib/falcon/server.rb:57:in 'Falcon::Server#accept'",
            "/artifacts/bundle/ruby/3.4.0/gems/io-endpoint-0.15.2/lib/io/endpoint/wrapper.rb:216:in 'block (2 levels) in IO::Endpoint::Wrapper#accept'",
            "/artifacts/bundle/ruby/3.4.0/gems/async-2.27.4/lib/async/task.rb:183:in 'block in Async::Task#run'",
            "/artifacts/bundle/ruby/3.4.0/gems/async-2.27.4/lib/async/task.rb:427:in 'block in Async::Task#schedule'"
        ]
    }
}

Digging into it, it looks like nil is not a valid response with Falcon. Adding the following to streamable_http_transport.rb fixes the issue. Something we may want to consider for those using Falcon.

        def handle_regular_request(body_string, session_id)
          unless @stateless
            # If session ID is provided, but not in the sessions hash, return an error
            if session_id && !@sessions.key?(session_id)
              return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
            end
          end

          # Ensure we always have a valid response body - convert nil to empty string
          response = @server.handle_json(body_string) || ""

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.

3 participants