Fix StreamableHTTP timeouts for inline SSE POST streams (Notion)#124
Conversation
Notion’s MCP server responds to every POST with text/event-stream and keeps the stream open after sending the result. The previous synchronous post() blocked until operation_timeout, so wait_for_response_with_timeout never ran and queued results timed out. Route wait_for_response: true requests through a background thread + queue so the main thread can poll results while HTTPX processes the stream. Keep synchronous behavior for fire-and-forget requests.
|
Hey @plehoux! Thanks for putting up this PR for this change. Will need a second to review + verify the issue. If you have a test script you could share in a comment or description that would be helpful. On the service, you have comments in this change that are probably not need + reference notion directly. Though could probably be removed. |
|
Here's a local repro that doesn’t require Notion auth token... It simulates an inline SSE response on POST that stays open. Server: # /tmp/inline_sse_server.rb
require "json"
require "webrick"
server = WEBrick::HTTPServer.new(
Port: 9292,
AccessLog: [],
Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO)
)
trap("INT") { server.shutdown }
server.mount_proc "/mcp" do |req, res|
res.status = 200
res["Content-Type"] = "text/event-stream"
res["Cache-Control"] = "no-cache"
res["Connection"] = "keep-alive"
res.chunked = true
payload = JSON.parse(req.body.to_s) rescue {}
id = payload["id"] || 1
response = { "jsonrpc" => "2.0", "id" => id, "result" => { "tools" => [] } }
res.body = proc do |out|
out.write("data: #{response.to_json}\n\n")
out.flush if out.respond_to?(:flush)
sleep 3600
end
end
server.startClient: # /tmp/inline_sse_client.rb
$LOAD_PATH.unshift("<path-to-your-local-ruby_llm-mcp>/lib")
require "ruby_llm/mcp"
require "ruby_llm/mcp/adapters/mcp_transports/coordinator_stub"
coordinator = RubyLLM::MCP::Adapters::MCPTransports::CoordinatorStub.new(
protocol_version: RubyLLM::MCP.config.protocol_version
)
transport = RubyLLM::MCP::Native::Transports::StreamableHTTP.new(
url: "http://localhost:9292/mcp",
request_timeout: 2000,
coordinator: coordinator,
options: { headers: {} }
)
coordinator.transport = transport
result = transport.request(
{ "jsonrpc" => "2.0", "method" => "tools/list", "id" => 1 },
wait_for_response: true
)
p result
transport.closeRun:
On main, this times out. With this PR, it returns immediately with |
Edit: this is for the example! So in the Notion MCP we would get a tool response if I am understanding this correctly |
|
@plehoux I was able to create a script that has Notion MCP working correctly with your changes. Going to merge this, thanks for you effort on getting this patched! |
|
Fast. 🙏 Thanks to you! We are deploying our agentic assistant in Missive later today. And in one/two weeks we plan to release MCP support, thanks to your work. Tell me if I can be of any help. We do have a lot of users (5k businesses) so you might hear from me with couple of PRs in the next few weeks. |
|
@plehoux this is a very cool product! Good job! -- and if there is anything you ever need feel free to reach out |
Summary
Connecting to Notion’s MCP server fails with a TimeoutError after ~8 seconds on every request. Notion responds to every POST on /mcp with
Content-Type: text/event-streamand keeps the stream open after sending the result (per the Streamable HTTP spec). The previous synchronouspost()blocked until operation_timeout, sowait_for_response_with_timeoutnever ran and queued results timed out.This PR routes
wait_for_response: truerequests through a background thread + queue so the main thread can poll results while HTTPX processes the stream. The synchronous path is preserved for fire-and-forget notifications.Expected behavior
Requests to MCP servers that respond with inline SSE streams (e.g., Notion) should return the result without timing out.
Actual behavior
Every POST to Notion timed out after the operation timeout because the main thread was blocked inside post() and never reached wait_for_response_with_timeout.
Minimal reproducible example (include MCP example)
Note: reproducing against Notion requires a valid OAuth token/session; unauthenticated requests return 401 and won’t hit the timeout.
Environment
:ruby_llm:streamable_httphttps://mcp.notion.com/mcp)Logs / output (optional)
Additional context
Notion enforces inline SSE on POSTs. Sending only
Accept: application/jsonreturns 406, so the client must handle a POST that returnstext/event-streamand stays open.This change is scoped to inline SSE responses on POSTs; the separate SSE transport remains unchanged.
Note: I used AI to help debug/fix; all changes were reviewed and tested by me. Don't hesitate pointing me in the right direction if I got something totally wrong.