Skip to content

fix(transport): preserve protocol error replies across cancellation - #1370

Open
rksharma-owg wants to merge 2 commits into
modelcontextprotocol:mainfrom
rksharma-owg:codex/fix-rmcp-protocol-reply-cancellation
Open

rksharma-owg wants to merge 2 commits into
modelcontextprotocol:mainfrom
rksharma-owg:codex/fix-rmcp-protocol-reply-cancellation

Conversation

@rksharma-owg

@rksharma-owg rksharma-owg commented Oct 15, 2026 •

Copy link
Copy Markdown

Preserve AsyncRwTransport's Invalid Request reply when receive() is cancelled while waiting for the writer or flushing a partial response.

Motivation and Context

Closes #1353.

The service loop polls receive() inside tokio::select!. After consuming a well-formed JSON line with an invalid JSON-RPC shape, receive() awaited the writer lock and send() without retaining reply progress. Cancellation could therefore lose the reply or leave a truncated JSON frame.

Keep queue/flush state on the transport, enqueue the error once with feed(), and resume flushing before reading the next message. The change is confined to async_rw.rs and adds no dependencies or background tasks.

How Has This Been Tested?

Two deterministic regressions use real Tokio duplex streams: one holds the writer lock; the other limits output capacity to one byte. Each cancels receive(), resumes it, and checks one complete -32600 reply plus delivery of the following valid request. Both fail before the fix and pass after it.

Updated against main cd88056ffdc97756fbd98e5a12bbeb41758f0ce8. Both cancellation regressions and the upstream receive_rejects_request_with_unusable_id test are retained unchanged; the redundant receive-loop comment was removed as requested. Shutdown flushing remains outside this patch.

Validation for e41a21bf6f0dc0c9431daaca765f1bf2a9c14e09:

  • The two cancellation regressions fail against current main's implementation and pass with this patch. All 13 focused transport tests pass.
  • Local macOS arm64 / Rust 1.98: all-feature, non-local feature, and no-default-feature library tests pass. Nightly formatting passes.
  • Local strict Clippy reports 16 diagnostics in unchanged auth/service/HTTP code with Rust 1.98 and CI-matched 1.99.1; the same diagnostics reproduce on untouched main. No lint rules or unrelated code were changed. The required Linux Clippy checks passed in the fork matrix below.
  • Passed full fork preflight: all 19 matrix jobs validate the exact source and target, including feature linting, tests, coverage, MSRV, SemVer/public API, examples/WASI, docs, audit, conformance and CodeQL. Existing ignored tests and documented extension expected failures remain unchanged.
  • Nonpublishing fork Scorecard passed against the same source. Its action requires default-branch execution, so the separate fork-only workflow runs there, checks out and verifies the contribution SHA, and keeps publication disabled. Personal validation configuration is absent from the contribution diff.

Breaking Changes

None identified. Public APIs and the wire format are unchanged; syntactically invalid JSON is still ignored as before.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed (N/A: restores the existing protocol-error behavior)

Additional context

The regressions also reject duplicate replies. Existing input-length limits and close/write-error behavior are preserved.

@rksharma-owg
rksharma-owg requested a review from a team as a code owner October 15, 2026 22:03
@github-actions github-actions Bot added T-core Core library changes T-transport Transport layer changes labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@connor4312 (assignment failed) is on triage rotation this week and is the reviewer for this one.

Labels come from the PR labeler. Please review the change.

Automated triage. Labels are a first pass and the assigned maintainer has the final say.

connor4312
connor4312 previously approved these changes Oct 15, 2026

@connor4312 connor4312 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connor's agent driving:

Thanks, this looks good! The new tests fail on main and pass with this change.

One small thing for later (not blocking): close() drops the writer without flushing it. So if the server shuts down while this error reply is only partly written, the client gets a cut-off line. I checked this by stopping receive() partway through the reply with a 1-byte duplex and then calling close(): the client only got {. In the service loop, this happens when serve_loop_ct.cancelled() wins the select! and the drain step has no other messages to send.

This problem was already there before this PR. A best-effort flush in close() would fix it, ideally with a time limit so a client that isn't reading can't block shutdown. That could be a follow-up issue.

DaleSeo
DaleSeo previously approved these changes Oct 16, 2026

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, @rksharma-owg. I only have a non-blocking comment.

Comment thread crates/rmcp/src/transport/async_rw.rs Outdated
Comment on lines +254 to +255
// Keep reply progress across cancellation at either the writer lock or
// flush. Once queued, resuming must flush rather than enqueue a duplicate.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: With the field and enum documented, this comment repeats what they say and can go.

Suggested change
// Keep reply progress across cancellation at either the writer lock or
// flush. Once queued, resuming must flush rather than enqueue a duplicate.

@gocamille

Copy link
Copy Markdown
Contributor

main picked up a new test at the end of async_rw.rs in #1346 (017e1c3), so this now conflicts in the test module only. Would you mind rebasing and keeping both? Dale's nit at :255 could go in the same push. #1395 has a fix waiting on this one, so I'd like to land it before 3.8.0. Or if it's easier, I'm happy to do the rebase myself.

@rksharma-owg
rksharma-owg dismissed stale reviews from DaleSeo and connor4312 via e41a21b October 27, 2026 02:49

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stdio transport: -32600 reply for an invalid JSON-RPC frame can be lost (AsyncRwTransport::receive is not cancel-safe)

4 participants