Skip to content

Fix StreamableHTTP timeouts for inline SSE POST streams (Notion)#124

Merged
patvice merged 2 commits into
patvice:mainfrom
plehoux:fix/streamable-http-inline-sse
Feb 23, 2026
Merged

Fix StreamableHTTP timeouts for inline SSE POST streams (Notion)#124
patvice merged 2 commits into
patvice:mainfrom
plehoux:fix/streamable-http-inline-sse

Conversation

@plehoux
Copy link
Copy Markdown
Contributor

@plehoux plehoux commented Feb 22, 2026

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-stream and keeps the stream open after sending the result (per the Streamable HTTP spec). The previous synchronous post() blocked until operation_timeout, so wait_for_response_with_timeout never ran and queued results timed out.

This PR routes wait_for_response: true requests 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)

require "ruby_llm/mcp"

client = RubyLLM::MCP::Client.new(
  name: "notion",
  transport_type: :streamable_http,
  request_timeout: 8000,
  config: { url: "https://mcp.notion.com/mcp" }
)

client.start
client.tools # Times out before fix
client.stop

Note: reproducing against Notion requires a valid OAuth token/session; unauthenticated requests return 401 and won’t hit the timeout.

Environment

  • Adapter: :ruby_llm
  • Transport: :streamable_http
  • MCP server + version: Notion MCP (https://mcp.notion.com/mcp)

Logs / output (optional)

# Example:
# RubyLLM::MCP::Errors::TimeoutError: Request timed out after 8 seconds

Additional context

Notion enforces inline SSE on POSTs. Sending only Accept: application/json returns 406, so the client must handle a POST that returns text/event-stream and 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.

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.
@patvice
Copy link
Copy Markdown
Owner

patvice commented Feb 22, 2026

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.

@plehoux
Copy link
Copy Markdown
Contributor Author

plehoux commented Feb 22, 2026

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

Client:

# /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.close

Run:

  1. ruby /tmp/inline_sse_server.rb
  2. ruby /tmp/inline_sse_client.rb

On main, this times out. With this PR, it returns immediately with tools: [].

@patvice
Copy link
Copy Markdown
Owner

patvice commented Feb 23, 2026

On main, this times out. With this PR, it returns immediately with tools: [].

@plehoux This part confuses me. Would we want expect to get a tool response from Notion MCP here?

Edit: this is for the example! So in the Notion MCP we would get a tool response if I am understanding this correctly

@patvice
Copy link
Copy Markdown
Owner

patvice commented Feb 23, 2026

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

@patvice patvice merged commit 3ad2db6 into patvice:main Feb 23, 2026
13 of 14 checks passed
@plehoux
Copy link
Copy Markdown
Contributor Author

plehoux commented Feb 23, 2026

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.

@patvice
Copy link
Copy Markdown
Owner

patvice commented Feb 23, 2026

@plehoux this is a very cool product! Good job! -- and if there is anything you ever need feel free to reach out

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.

2 participants