Skip to content

[Bug]: StreamableHTTP: 405 response to a POST request is silently ignored, request hangs until timeout #144

Description

@ebeigarts

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions