Summary
When the Streamable HTTP transport POSTs a JSON-RPC request (e.g. initialize) and the server responds with 405 Method Not Allowed, the response is silently discarded. The caller then blocks in wait_for_response_with_timeout for the full request_timeout (despite the HTTP response arriving immediately) and finally raises Errors::TimeoutError instead of a meaningful transport error.
Reproduction
Point the client at any URL that returns 405 for POST (e.g. a plain web page):
client = RubyLLM::MCP.client(
name: "test",
transport_type: :streamable_http,
request_timeout: 10_000,
config: { url: "https://example.com/mcp" }
)
client.start
The server answers 405 within milliseconds, but the client waits the full 10 seconds, then:
ERROR -- RubyLLM::MCP: StreamableHTTP request timeout (ID: ...) after 10 seconds
RubyLLM::MCP::Errors::TimeoutError: Request timed out after 10 seconds
(It also then sends a notifications/cancelled POST, which gets a 405 that is ignored as well.)
Root cause
initialize carries a request ID, so a response queue is registered in @pending_requests and the POST runs on a background thread, while the calling thread polls the queue in wait_for_response_with_timeout.
The background thread receives the 405 right away, but handle_response treats it as a no-op:
|
when 405 |
|
# Method not allowed - acceptable for some endpoints |
|
nil |
Nothing is pushed onto the pending request's queue and the entry isn't deleted, so the waiting thread never wakes up until the deadline.
The "acceptable for some endpoints" comment is correct for the MCP spec's GET (server may not support the SSE stream) and DELETE (session termination) — but handle_response is shared with the POST path, where a 405 means the endpoint doesn't accept MCP requests at all and should fail fast.
Suggested fix
In the 405 branch of handle_response, when there is a pending request_id (i.e. this was a POST carrying a request), push an Errors::TransportError onto the pending queue — same pattern as the rescue blocks in send_request_in_background — instead of returning nil. The GET/DELETE paths don't register pending requests, so they keep the lenient behavior.
Observed with ruby_llm-mcp 1.0.0 (current main has the same code).
Summary
When the Streamable HTTP transport POSTs a JSON-RPC request (e.g.
initialize) and the server responds with 405 Method Not Allowed, the response is silently discarded. The caller then blocks inwait_for_response_with_timeoutfor the fullrequest_timeout(despite the HTTP response arriving immediately) and finally raisesErrors::TimeoutErrorinstead of a meaningful transport error.Reproduction
Point the client at any URL that returns 405 for POST (e.g. a plain web page):
The server answers 405 within milliseconds, but the client waits the full 10 seconds, then:
(It also then sends a
notifications/cancelledPOST, which gets a 405 that is ignored as well.)Root cause
initializecarries a request ID, so a response queue is registered in@pending_requestsand the POST runs on a background thread, while the calling thread polls the queue inwait_for_response_with_timeout.The background thread receives the 405 right away, but
handle_responsetreats it as a no-op:ruby_llm-mcp/lib/ruby_llm/mcp/native/transports/streamable_http.rb
Lines 407 to 409 in e373e42
Nothing is pushed onto the pending request's queue and the entry isn't deleted, so the waiting thread never wakes up until the deadline.
The "acceptable for some endpoints" comment is correct for the MCP spec's GET (server may not support the SSE stream) and DELETE (session termination) — but
handle_responseis shared with the POST path, where a 405 means the endpoint doesn't accept MCP requests at all and should fail fast.Suggested fix
In the 405 branch of
handle_response, when there is a pendingrequest_id(i.e. this was a POST carrying a request), push anErrors::TransportErroronto the pending queue — same pattern as therescueblocks insend_request_in_background— instead of returningnil. The GET/DELETE paths don't register pending requests, so they keep the lenient behavior.Observed with ruby_llm-mcp 1.0.0 (current
mainhas the same code).