Skip to content

fix(mcp): stdio id matching, real notifications, drained stderr, call timeout and cancel - #234

Merged
yuanhao merged 6 commits into
mainfrom
fix/mcp-stdio
Oct 5, 2026
Merged

yuanhao merged 6 commits into
mainfrom
fix/mcp-stdio

Conversation

@yuanhao

@yuanhao yuanhao commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #224.

Stdio transport

  • Bug: send read exactly one line as the response. A logging or progress notification, or a ping, sent ahead of the answer became "the response": the call failed, and every later call was off by one.
  • Fix: send now reads until the response carrying its own id. In between it:
    • skips server notifications;
    • answers server requests: ping → {}, anything else (sampling, roots) → -32601, so the server doesn't wait;
    • drops responses for other ids, such as a call that timed out earlier;
    • skips non-JSON lines, such as half a line left by a cancelled call.
  • stderr is drained into debug! logs (target yoagent::mcp::stderr). It was piped and never read, so a server that logged more than a pipe holds blocked mid-call. Found while fixing this; it wasn't in the issue.
  • The child process is kill_on_drop, so dropping the client ends the server.

Notifications

  • Bug: notifications/initialized was sent as a request with an id and awaited. A spec-compliant server never answers it, so connecting over stdio could hang.
  • Fix: a new JsonRpcNotification and McpTransport::notify.
    • stdio writes the notification and returns.
    • HTTP POSTs it and accepts the 202.
    • The trait's default notify keeps the old request-and-wait behaviour, so a custom transport neither breaks nor changes.

Tool calls

  • Cancel and timeout: McpToolAdapter::execute now races the run's cancel token and a call timeout. The default is DEFAULT_CALL_TIMEOUT (300 s), changed with with_call_timeout(Option<Duration>). The timeout includes waiting for the client lock. Before, a hung server hung the agent, and every tool from that server waited behind the lock.
  • Unmodelled content: McpToolCallResult.content is parsed leniently. A resource, resource_link or audio block becomes McpContent::Text holding its JSON, instead of failing the whole call. A new enum variant would have been a breaking change.

All public changes are additive.

Tests

tests/mcp_stdio_test.rs (unix) runs McpClient::connect_stdio against a bash-scripted server:

  • During initialize: a notification and a ping arrive before the answer, and the script checks the client's ping reply.
  • initialized: must arrive without an id.
  • tools/list: a stale response for another id comes before the real one.
  • The call: a 200 KB stderr flood comes first, then the answer with a text block and a resource block.
  • Afterwards: the server never answers again, which tests the timeout and the cancel.

With id matching broken, the test fails, which I checked by breaking it. There are also unit tests for lenient content and the notification shape.

Local checks: fmt, clippy (all features, no default features, wasm32), docs, and cargo test --all-features (55 binaries).

🤖 Generated with Claude Code

https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG

yuanhao and others added 6 commits October 5, 2026 21:12
… timeout and cancel (#224)

- StdioTransport::send reads until the response with the request's id. It
  skips server notifications, answers server requests (`ping` succeeds,
  others get -32601), and drops stale ids and non-JSON lines.
- New JsonRpcNotification and McpTransport::notify. The default keeps the
  old request-and-wait behaviour for custom transports; stdio and HTTP
  write the notification and return. notifications/initialized uses it,
  so the handshake no longer waits on a reply a compliant server never
  sends.
- The server's stderr is drained into debug! logs, so it can no longer
  block on a full pipe. The child process is kill_on_drop.
- McpToolAdapter races ctx.cancel and a call timeout (300 s default,
  with_call_timeout).
- Unmodelled content blocks (resource, audio, …) become JSON text instead
  of failing the call.

Tested against a bash-scripted server (tests/mcp_stdio_test.rs). The test
fails if id matching is broken.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
- The stderr drain reads lossily in bounded chunks. It used to stop at
  the first non-UTF-8 byte, after which the server blocked again or got
  EPIPE. The last 4 KiB are kept, so a server that exits is reported as
  Transport("Connection closed: … exited with …; last stderr: …") instead
  of a bare ConnectionClosed (it waits up to 500 ms for the drain).
- Non-JSON stdout lines are warn!ed and counted into the close error.
  Declined server requests are warn!ed too. Response ids are read in
  numeric or string form, and a null-id error is warn!ed when it is
  attributed to the current call.
- A write cut off by a cancelled call is flagged, and the next message
  starts on a fresh line.
- The call timeout moved to the client: 300 s for stdio, none for HTTP
  (its idle timeout already ends a stalled call). from_client copies it,
  and the clock starts after the client lock is held. The message says
  the server may still carry the call out.
- The handshake is bounded at 60 s. A failed initialized notification
  fails the connect, and the HTTP notify error quotes the body.
- Unmodelled content: a text resource becomes its own text; data/blob
  over 1 KiB is replaced by its size; warn! once per type.
- close() logs a kill error instead of dropping it, and its comment is
  fixed.
- Tests:
  - stderr with a non-UTF-8 byte then a 200 KB flood;
  - a banner line;
  - a declined sampling request;
  - a late answer followed by a successful call;
  - kill on drop (with a server that ignores EOF);
  - a dying server's stderr in the error;
  - HTTP initialized sent once with no id, and a rejected one failing
    the connect;
  - large payloads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
…fter exit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
…poll panic; fix the guide snippet

- The trait's default notify stays best-effort (logged, Ok), as the old
  path was; only the built-in transports' errors fail the handshake.
- a47ffc2's take() did fix a real panic (a finished JoinHandle polled
  again). Its test missed it, because an exited server fails at the
  write first. A server that closes its output but keeps reading
  reaches the close error each time, and that test panics without the
  fix.
- The guide's custom-timeout snippet keeps the agent's other tools
  (with_tools replaces them) and imports Arc/Mutex.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
Stdout can close a moment before the process is reaped, so try_wait()
raced: CI saw "closed its output" for a server that had exited.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
…ies)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG
@yuanhao
yuanhao merged commit 5d79bdf into main Oct 5, 2026
14 checks passed
@yuanhao
yuanhao deleted the fix/mcp-stdio branch October 5, 2026 21:38
@yuanhao yuanhao mentioned this pull request Oct 5, 2026
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.

MCP stdio: no response-id matching, initialized sent as a request, no timeout/cancel

1 participant