Skip to content

fix(model): keep _meta-only results as tool results when decoding custom results - #6

Closed
preston-footprint wants to merge 96 commits into
dinglebear-ai:fix/custom-result-meta-classification-upstreamfrom
preston-footprint:fix/custom-result-meta-keep-tool-result
Closed

preston-footprint wants to merge 96 commits into
dinglebear-ai:fix/custom-result-meta-classification-upstreamfrom
preston-footprint:fix/custom-result-meta-keep-tool-result

Conversation

@preston-footprint

@preston-footprint preston-footprint commented Oct 20, 2026 •

Copy link
Copy Markdown

This keeps the fix for custom results that carry _meta, but brings back the legacy _meta-only case that server_result_should_accept_legacy_call_tool_result_when_only_meta_present (modelcontextprotocol#1103) covers. ServerResult tries CallToolResult before CustomResult, and with _meta dropped from the field check entirely, a bare {"_meta": …} no longer decodes as a tool result.

Here _meta counts as a tool-result field only when it's the only field present. So the legacy {"_meta": …} still decodes as CallToolResult, while _meta next to resultType or other unknown fields falls through, to TaskAckResult (a tasks/update or tasks/cancel ack carrying _meta) or to CustomResult. Your two tests stay as they are, and I added tests for custom results and task acks with _meta, tool results with _meta, and other ServerResult variants keeping their variant when _meta is added. I also merged current main into the branch, as a merge rather than a rebase, so your history is untouched.

For modelcontextprotocol#1197.

AI disclosure

This PR and its tests were written primarily by Claude Code, under my direction and review.

🤖 Generated with Claude Code

nickcoai and others added 30 commits August 24, 2026 22:43
…1186)

* fix: allow concurrent streamable http requests

* fix: keep streamable http recovery responsive

Keep cancellation and replies available while old session POSTs finish.
Bound the wait for old POSTs and the replacement initialization handshake.
Do not retry interrupted POSTs because the server may have processed them.

Add regressions for recovery, queued cancellation, control timeouts, and
server replies needed by active requests.

* fix: preserve response and cancellation ordering

* refactor: clarify streamable http control flow

* fix: match stream responses against pending request ids

Match responses against all pending requests before removing a stream
registration. Keep distinct numeric and string ids separate while preserving
the existing fallback for servers that stringify numeric ids.

Add a mixed-id regression and keep a separate registration owner alive in
the abandoned-cancellation test.

* feat: make streamable http control timeouts configurable
…odelcontextprotocol#1211)

* fix(transport): fall back after HTTP discover rejection

* fix(transport): limit legacy fallback to sessionless probes

* style: apply nightly rustfmt import ordering
* feat: add request-state key rotation

* docs: streamline request-state codec documentation

* fix: harden request-state fallback verification

* refactor: refine request-state keyring API

* docs: make request-state rotation guidance self-contained

* docs: streamline request-state keyring rustdocs

* test: streamline request-state keyring coverage

* docs: restore request-state key rotation doctest

* chore: remove manual changelog entry
…lcontextprotocol#1230)

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.13 to 2.86.7.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](taiki-e/install-action@82cd3e7...b6ff580)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.86.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…elcontextprotocol#1232)

* feat(auth): coordinate refreshes through credential stores

Add an optional owned guard covering credential reload, refresh, and save.
Keep credential-store failures distinct from reauthorization, including
reactive refresh after an HTTP 401.

Existing stores retain their default uncoordinated behavior. Cover guard
ordering, concurrent rotation, storage failures, and client identity checks.

* docs(auth): clarify refresh guard coordination
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ontextprotocol#1229)

Updates the requirements on [process-wrap](https://github.com/watchexec/process-wrap) to permit the latest version.
- [Changelog](https://github.com/watchexec/process-wrap/blob/main/CHANGELOG.md)
- [Commits](watchexec/process-wrap@v9.0.0...v10.0.0)

---
updated-dependencies:
- dependency-name: process-wrap
  dependency-version: 10.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…xtprotocol#1238)

Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 7.6.0 to 10.0.1.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](astral-sh/setup-uv@37802ad...20cfd1b)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ontextprotocol#1234)

* feat(auth): request ID-JAGs with enterprise refresh tokens

* feat(auth): exchange ID-JAGs for MCP access tokens

Redeem ID-JAGs at the approved resource authorization server and return
its bearer token, lifetime, and effective granted scopes. Preserve scope
narrowing and redacted diagnostics, and reuse the default HTTP adapter.

Support independently configured client authentication at both servers.
Document the exchange profile and test redirects and staged failures.

Partially addresses modelcontextprotocol#531.

* test(conformance): cover EMA refresh-token exchange
…col#1236)

* fix(auth): unify refresh checks and error handling

Claude-Session: https://claude.ai/code/session_011pHFfoTygeG84mCDXzCcmw

* test(auth): add live authorization-server checks

Claude-Session: https://claude.ai/code/session_011pHFfoTygeG84mCDXzCcmw
)

* fix: resolve clippy warnings across workspace

* ci: also run clippy on rmcp with all features except local

--all-features enables the local feature, which cfg-gates out
tower.rs and most of the test suite, so the existing clippy step
never actually lints them.
…contextprotocol#1250)

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.86.7 to 2.87.6.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](taiki-e/install-action@b6ff580...7b8d471)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.87.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1a51d4b5a03bb97576af186c813af67e9137ba7c to d43b6c087ac471e2ea7b8af622ff15f05c0c365b.
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](crate-ci/typos@1a51d4b...d43b6c0)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-version: d43b6c087ac471e2ea7b8af622ff15f05c0c365b
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…nic (modelcontextprotocol#1231)

* fix(sse): saturate exponential reconnect backoff

ExponentialBackoff::retry computed the reconnect multiplier with
2u32.pow(current_times). With max_times unset, current_times can reach
the bit width, panicking in debug builds and wrapping to a zero delay in
release builds for long-lived SSE clients. Use saturating_pow and
Duration::saturating_mul so the delay stays monotonic and panic-free.

* fix(sse): cap exponential reconnect backoff at a bounded max delay

Saturating the multiplier alone can still yield decades-long sleeps once
current_times reaches the bit width, pinning the stream in
tokio::time::sleep without reconnecting or terminating. Add an optional
max_delay (default 30s) that clamps the computed delay, keeping the
backoff monotonic and panic-free while guaranteeing the client retries.

* fix(sse): default max_delay to None to preserve unbounded backoff

Per maintainer feedback, leave ExponentialBackoff::default() unbounded so
the fix stays a pure overflow bug-fix. The saturating multiplier removes
the debug panic / release wrap from modelcontextprotocol#1198, while max_delay stays opt-in
for callers that want a bounded reconnect delay.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…1192)

Return HTTP 403 for malformed and non-UTF-8 Origin headers when validation is enabled.
Add enforce_origin_validation() for strict empty-allowlist validation while preserving the
legacy default and non-empty allowlist behavior. Configure the conformance server to opt in
explicitly.

Signed-off-by: lucarlig <luca.carlig@ibm.com>
…HTTP 400 (modelcontextprotocol#1259)

`jsonrpc_http_status` maps several protocol errors (unsupported protocol
version, missing required client capability, invalid params) to HTTP 400
on the modern per-request Streamable HTTP path, but fell through to the
default HTTP 200 for `ErrorCode::HEADER_MISMATCH`. When a `ServerHandler`
returns `Err(ErrorData::header_mismatch(...))`, RMCP serialized the
correct JSON-RPC -32020 error but sent it with HTTP 200.

The legacy/transport-generated header validation path (SEP-2243
Mcp-Method/Mcp-Name/Mcp-Param-* mismatches) was unaffected, since
`header_mismatch_jsonrpc_response` constructs an HTTP 400 response
directly rather than going through `jsonrpc_http_status`. Only
handler-generated HeaderMismatch errors on the modern path hit the bug.

Add `ErrorCode::HEADER_MISMATCH` to the existing `BAD_REQUEST` match arm,
matching the Streamable HTTP server-validation rules in the 2026-07-28
spec. Added unit tests covering the mapper directly (header-mismatch to
400, method-not-found to 404, an unmapped code defaulting to 200).

Closes modelcontextprotocol#1225

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ontextprotocol#1208)

* fix: do not treat malformed JSON 200 as Accepted for requests

Streamable HTTP mapped any JSON body that failed to parse as
ServerJsonRpcMessage to Accepted, which is correct for notification
POSTs. For a request the worker then waits on SSE forever, so
call_tool hangs when the server returns 200 application/json with
an empty or non-JSON-RPC body.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix: include a short body preview in JSON-RPC parse errors

Request POST parse failures now include a 256-byte prefix of the
unusable body so empty, HTML, and `{}` are distinguishable from the
serde diagnostic. HTTP reads bytes() then from_slice, matching Unix.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
gocamille and others added 14 commits October 15, 2026 11:09
…delcontextprotocol#1361)

* fix(streamable-http-client): handle error responses to initialize

expect_initialized only stopped draining an SSE response on a result, so
an error event for initialize was skipped. serve() then reported
ConnectionClosed, or hung if the server kept the stream open.

perform_reinitialization also ignored an error reply and went on to send
notifications/initialized and retry the expired request without a
session. It now returns the error, which fails the pending requests.

Fixes modelcontextprotocol#1360

* fix(streamable-http-client): keep ErrorData on re-initialization failure

* chore: drop extra blank line in Cargo.toml
…otocol#1352)

* feat(auth): add initial authorization scope selection

* refactor(auth): share initial scope discovery helpers

* test(auth): trim redundant initial scope assertions

* fix(auth): address initial scope selection review

* test(auth): cover reactive initial scope selection

* refactor(auth): use one metadata discovery entry point
…s/read (modelcontextprotocol#1344)

* feat(client): cancel an abandoned tools/call, prompts/get or resources/read

A caller that drops `call_tool` (a `tokio::time::timeout`, a lost `select!`
branch) abandoned the request locally while the server kept working on it.
Send a best-effort `notifications/cancelled` for the request in flight when
that happens.

A drop guard, modelled on `PendingSubscriptionRequest`, is armed while the
response is awaited and disarmed as soon as it arrives, so dropping the
future while the client handler fulfils MRTR input requests cancels nothing.

Closes modelcontextprotocol#1338

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): cancel any abandoned client request, per review

Every request the client peer sends now goes through the drop guard, not
only tools/call, prompts/get and resources/read. `initialize` is not
sent through `Peer`, and the helper is client-only: a client must never
cancel `initialize`, and from 2026-07-28 a server may send
`notifications/cancelled` only to end a subscriptions/listen stream.

The guard no longer unregisters a subscription that these requests never
register, and the test helper fails when the call never reaches the
point it is to be abandoned at instead of dropping the call anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): keep server/discover off cancel-on-drop; borrow the peer in the guard

Per review: server/discover is part of the handshake like initialize, so
it goes back to plain send_request. AbandonedRequestGuard borrows the
peer instead of cloning it, and the helper's doc keeps only the
constraint a future editor must not break.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rotocol#1359)

* fix(transport): accept nested x-mcp-header annotations

* docs: document nested x-mcp-header and schemars inline

* fix(transport): walk schema maps by value and pin path pop
…delcontextprotocol#1366)

* fix(client): answer a server ping during the initialize handshake

expect_response dropped a server ping while waiting for the initialize
response, so the ping never got a reply. Answer it with an empty result
during the initialize wait, the same way the server answers an early
client ping. The server/discover wait still leaves it unanswered.

Refs modelcontextprotocol#745
Fixes modelcontextprotocol#1362

* test(client): drop duplicated discover ping comment

* refactor(client): replace answer_ping bool with PreInitPing enum

* test(client): assert notifications/initialized follows the ping response

* refactor(client): match on PreInitPing instead of comparing it

The compiler now flags this spot if PreInitPing gains a variant, and
the PartialEq and Eq derives are no longer needed.
…odelcontextprotocol#1346)

* fix(model): read a request id written in float form as that integer

A JSON-RPC id of 1.0 or 1e2 is an integer written the way some encoders
write every number. It used to be rejected, so the request got no
response. NumberOrString now accepts a float that an f64 represents
exactly as an integer (up to 2^53) and reads it as that integer.

modelcontextprotocol#1287 already makes a message with an id and a method parse only as a
request, so the earlier change to JsonRpcNotification is dropped; the
tests for an unusable id stay as regression tests.

Refs modelcontextprotocol#1345

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(model): reject 2^53 as a float-form id; it cannot say which integer was sent

2^53 + 1 written in float form parses to the same f64 as 2^53, so
accepting 2^53 silently turned 9007199254740993.0 into 9007199254740992.
The exact-integer range is now strictly below 2^53. Added that id to
request_with_unusable_id_is_rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…T with 400 (modelcontextprotocol#1377)

In legacy session mode a POST without an Mcp-Session-Id header whose
message is not initialize got 422 Unprocessable Entity. The Streamable
HTTP transport's Session Management section says servers that require a
session ID SHOULD respond to requests without one (other than
initialization) with 400 Bad Request, which GET and DELETE already do.

Return the same 400 "Bad Request: Session ID is required" response from
both session-less POST branches, through one helper shared with GET and
DELETE.

Signed-off-by: Jens Holdgaard Pedersen <Jens@holdgaard.org>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…treamable HTTP client (modelcontextprotocol#1367)

* fix(transport): follow same-origin 307/308 redirects in the default Streamable HTTP client

The default reqwest client used `Policy::none()`, so any redirect from an
MCP server failed with `UnexpectedServerResponse("HTTP 307: ...")`.

It now follows 307 and 308 when the target has the same origin as the
original request, up to 10 hops. Those keep the method and body, so the
JSON-RPC message and the session, auth and custom headers arrive intact.
301, 302 and 303 are still returned to the caller, because following them
turns the POST into a bodyless GET. Cross-origin redirects are still
returned too, which keeps the header-leak guard.

Refs modelcontextprotocol#936
Reported-by: Mo (@trickyfalcon)

* docs(transport): describe redirect handling in default_http_client

Document on `from_uri` and `from_config` which redirects the default
client follows, that a 308 does not change the stored URI, and that a
client passed to `with_client` keeps its own redirect policy.

* docs(transport): move redirect rationale to same_origin_redirect_policy

The rationale explains the policy, so it now sits on
`same_origin_redirect_policy`, and the `default_http_client` doc points
there instead of repeating the `from_uri` section.

* test(transport): drop unused Default derive on CapturedRequest
…ontextprotocol#1349)

* fix(streamable-http-server): look up tool schemas per request

* test: cover stale tool schemas in header tests
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…tom results

ServerResult is an untagged enum that tries CallToolResult before
TaskAckResult and CustomResult. Dropping _meta from CallToolResult's
known-field check fixes custom results that carry _meta, but it breaks
the legacy {"_meta": ...} tool result covered by
server_result_should_accept_legacy_call_tool_result_when_only_meta_present
(modelcontextprotocol#1103).

Count _meta as a tool-result field only when it is the only field
present. A bare _meta still decodes as CallToolResult, while _meta
alongside resultType or other unknown fields falls through to
TaskAckResult or CustomResult.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@preston-footprint
preston-footprint force-pushed the fix/custom-result-meta-keep-tool-result branch from 8046a0e to 39c0838 Compare October 20, 2026 23:45
@jmagar jmagar closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.