From 61ef640dec4672b762b37a834717f8b32fa0538f Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 19 Jul 2026 12:29:05 -0400 Subject: [PATCH 1/3] Align MCP 2026 result identity metadata --- .github/workflows/interop_2026_07_28.yml | 26 +- CHANGELOG.md | 12 + doc/interoperability.md | 55 ++- doc/mcp-2026-07-28-release-runbook.md | 13 +- doc/mcp-2026-07-28.md | 29 +- doc/spec-coverage-2026-07-28.md | 40 +- lib/src/client/client.dart | 6 +- lib/src/server/server.dart | 19 +- lib/src/shared/protocol.dart | 17 +- lib/src/types/initialization.dart | 54 ++- lib/src/types/json_rpc.dart | 41 +- .../lib/src/conformance_runner.dart | 105 ++--- .../lib/src/inspect_client_command.dart | 35 +- .../lib/src/inspect_server_command.dart | 6 + .../fixtures/stateless_inventory_server.dart | 18 +- .../test/src/inspect_client_command_test.dart | 109 +++++- .../test/src/inspect_server_command_test.dart | 71 ++++ .../2026_07_28_expected_failures.txt | 11 +- test/conformance/README.md | 8 +- test/interop/python_2026_07_28/README.md | 24 +- test/interop/ts_2026_07_28/README.md | 50 ++- test/interop/ts_2026_07_28/src/client.mjs | 14 +- test/mcp_2026_07_28_test.dart | 366 +++++++++++++++++- .../run_python_2026_07_28_interop_test.dart | 28 ++ test/tool/run_ts_2026_07_28_interop_test.dart | 27 ++ test/types_test.dart | 16 +- .../run_python_2026_07_28_interop.dart | 101 ++++- tool/testing/run_ts_2026_07_28_interop.dart | 203 +++++++++- 28 files changed, 1309 insertions(+), 195 deletions(-) create mode 100644 test/tool/run_python_2026_07_28_interop_test.dart create mode 100644 test/tool/run_ts_2026_07_28_interop_test.dart diff --git a/.github/workflows/interop_2026_07_28.yml b/.github/workflows/interop_2026_07_28.yml index 9295337c..ec8f9e5e 100644 --- a/.github/workflows/interop_2026_07_28.yml +++ b/.github/workflows/interop_2026_07_28.yml @@ -62,8 +62,16 @@ jobs: working-directory: test/interop/ts_2026_07_28 run: npm ci - - name: Run TypeScript 2026-07-28 interop fixture - run: dart run tool/testing/run_ts_2026_07_28_interop.dart + - name: Run Dart client against published TypeScript beta server + run: >- + dart run tool/testing/run_ts_2026_07_28_interop.dart + --direction=dart-to-ts + + - name: Verify known published TypeScript beta client gap + run: >- + dart run tool/testing/run_ts_2026_07_28_interop.dart + --direction=ts-to-dart + --expect-published-ts-client-gap python-2026-07-28-interop: runs-on: ubuntu-latest @@ -90,10 +98,20 @@ jobs: python -m pip install -r test/interop/python_2026_07_28/requirements.txt - - name: Run Python 2026-07-28 interop fixture + - name: Run Dart client against published Python beta server + env: + MCP_PYTHON: python + run: >- + dart run tool/testing/run_python_2026_07_28_interop.dart + --direction=dart-to-python + + - name: Verify known published Python beta client gap env: MCP_PYTHON: python - run: dart run tool/testing/run_python_2026_07_28_interop.dart + run: >- + dart run tool/testing/run_python_2026_07_28_interop.dart + --direction=python-to-dart + --expect-published-python-client-gap browser-2026-07-28-interop: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a8538d9..2b8c7ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### Changed + +- Aligned MCP `2026-07-28` identity with spec PR #3002: request + `clientInfo` is optional, servers stamp their identity by default in successful + stateless result `_meta["io.modelcontextprotocol/serverInfo"]` while preserving + handler-authored metadata, and + `server/discover` no longer emits body `serverInfo`. `DiscoverResult.serverInfo` + is now nullable, so callers must handle anonymous discovery. MCP `2025-11-25` + initialization remains unchanged. + ## 2.3.0-dev.2 This preview makes MCP `2026-07-28` the preferred protocol while preserving diff --git a/doc/interoperability.md b/doc/interoperability.md index 47d3eaa3..fba7ac27 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -26,11 +26,11 @@ For MCP 2026-07-28 coverage, see the | Dart client -> TypeScript SDK server | Streamable HTTP | MCP 2025-11-25 | [`test/interop/dart_client_with_ts_server_test.dart`](../test/interop/dart_client_with_ts_server_test.dart), [`test/interop/ts/`](../test/interop/ts/) | Verified | Covers tool calls and stale preconfigured session-id recovery. | | TypeScript SDK client -> Dart server | stdio | MCP 2025-11-25 | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Runs the compiled TypeScript client fixture against a Dart server process and checks that an official TS client can list tools immediately after the lifecycle handshake. | | TypeScript SDK client -> Dart server | Streamable HTTP | MCP 2025-11-25 | [`test/interop/ts_client_with_dart_server_test.dart`](../test/interop/ts_client_with_dart_server_test.dart), [`test/interop/test_dart_server.dart`](../test/interop/test_dart_server.dart) | Verified | Includes official TS Streamable HTTP client lifecycle coverage, pre-`initialized` operation rejection, GET SSE streams, and `Last-Event-ID` replay behavior. | -| TypeScript SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/ts_2026_07_28/`](../test/interop/ts_2026_07_28/), [`tool/testing/run_ts_2026_07_28_interop.dart`](../tool/testing/run_ts_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses published `@modelcontextprotocol/client@2.0.0-beta.4` and `@modelcontextprotocol/server@2.0.0-beta.4` packages. Covers modern negotiation, cache metadata, `tools/list`, `tools/call`, routing-header validation and retry, removed core RPC rejection, progress, `subscriptions/listen`, `AbortController` response-stream cancellation observed by the Dart server, and follow-up recovery. | -| Dart MCP 2026-07-28 client -> TypeScript SDK beta server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/ts_2026_07_28/src/server.mjs`](../test/interop/ts_2026_07_28/src/server.mjs), [`tool/testing/run_ts_2026_07_28_interop.dart`](../tool/testing/run_ts_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses the published TypeScript SDK beta server through its `createMcpHandler` entry and covers `server/discover` negotiation, `tools/list`, `tools/call`, one-time `HeaderMismatch` metadata refresh and retry, an MCP 2026-07-28 `input_required` elicitation retry, request-stream cancellation observed through the server's Web Request `AbortSignal`, and post-cancellation recovery. | +| TypeScript SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/ts_2026_07_28/`](../test/interop/ts_2026_07_28/), [`tool/testing/run_ts_2026_07_28_interop.dart`](../tool/testing/run_ts_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Known published-beta gap | Published `@modelcontextprotocol/client@2.0.0-beta.4` predates spec PR #3002 and rejects discovery without obsolete body `serverInfo`. CI asserts the exact negotiation failure as temporary expected drift; a TypeScript SDK PR #2513 preview validates the final result metadata identity shape. | +| Dart MCP 2026-07-28 client -> TypeScript SDK beta server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/ts_2026_07_28/src/server.mjs`](../test/interop/ts_2026_07_28/src/server.mjs), [`tool/testing/run_ts_2026_07_28_interop.dart`](../tool/testing/run_ts_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses the published TypeScript SDK beta server through its `createMcpHandler` entry and a temporary read-only fallback for legacy body `serverInfo`; covers `server/discover`, `tools/list`, `tools/call`, one-time `HeaderMismatch` metadata refresh and retry, MCP 2026-07-28 `input_required`, request-stream cancellation, and post-cancellation recovery. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio; the stable recipe remains separate from the MCP 2026-07-28 beta fixture. | -| Python SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/`](../test/interop/python_2026_07_28/), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses official Python SDK `mcp==2.0.0b1` and covers automatic `server/discover` negotiation, `tools/list`, and `tools/call` against the Dart conformance server. | -| Dart MCP 2026-07-28 client -> Python SDK beta server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/server.py`](../test/interop/python_2026_07_28/server.py), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses the official Python SDK beta MCP server and covers discovery, protocol selection, `tools/list`, and `tools/call`. | +| Python SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/`](../test/interop/python_2026_07_28/), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Known published-beta gap | Published `mcp==2.0.0b1` predates spec PR #3002 and falls back to MCP 2025-11-25 when canonical discovery omits obsolete body `serverInfo`. CI asserts that exact fallback as temporary expected drift. | +| Dart MCP 2026-07-28 client -> Python SDK beta server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/server.py`](../test/interop/python_2026_07_28/server.py), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses the official Python SDK beta server through the temporary read-only fallback for legacy body `serverInfo`; covers discovery, protocol selection, `tools/list`, and `tools/call`. | | Dart browser client -> Dart server | Streamable HTTP | MCP 2025-11-25 and MCP 2026-07-28 | [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart), [`tool/testing/run_browser_2026_07_28_interop.dart`](../tool/testing/run_browser_2026_07_28_interop.dart) | Verified | A real Chrome client completes 12 tool-list requests and 12 tool calls in each profile over cross-origin Streamable HTTP. It also proves MCP 2026-07-28 request-stream cancellation and recovery. The MCP 2025-11-25 case waits for response-stream reconnect timers and guards against browser connection-slot exhaustion. | | Flutter Web example -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`example/flutter_http_client/test/browser_e2e_test.dart`](../example/flutter_http_client/test/browser_e2e_test.dart), [`tool/testing/run_flutter_web_example_e2e.dart`](../tool/testing/run_flutter_web_example_e2e.dart), [`test_core.yml`](../.github/workflows/test_core.yml) | Verified | The example's real service layer runs in Chrome and completes connection, 12 tool-list requests, 12 tool calls, expected RPC-error recovery, reconnect, a post-reconnect request, and disconnect. Deterministic widget tests cover the UI separately. Flutter Web cannot spawn stdio servers. | | MCP Apps host/client metadata | stdio or Streamable HTTP | MCP 2026-07-28 plus `io.modelcontextprotocol/ui` extension | [`doc/mcp-apps.md`](mcp-apps.md), [`example/mcp_apps_helpers_server.dart`](../example/mcp_apps_helpers_server.dart), [`test/types/mcp_ui_test.dart`](../test/types/mcp_ui_test.dart), [`test/server/mcp_ui_test.dart`](../test/server/mcp_ui_test.dart) | Verified | Verified coverage is limited to SDK metadata helpers, serialization, and checked-in examples; host rendering behavior varies by host, so verify UI metadata against your target host. | @@ -60,35 +60,50 @@ packages: cd test/interop/ts_2026_07_28 npm ci cd ../../.. -dart run tool/testing/run_ts_2026_07_28_interop.dart +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=dart-to-ts ``` -This starts the Dart MCP 2026-07-28 conformance server, runs the pinned TypeScript -SDK beta client against it, then runs the reverse Dart MCP 2026-07-28 client smoke check -against the TypeScript SDK beta server. +That direction verifies the compatible Dart client -> published TypeScript beta +server path. The published beta.4 client still expects obsolete body +`serverInfo`; CI keeps that reverse direction visible without weakening Dart's +wire output by asserting the specific pre-#3002 negotiation failure: -`@modelcontextprotocol/client@2.0.0-beta.4` and -`@modelcontextprotocol/server@2.0.0-beta.4` expose the required modern path and -pass in both directions. The reverse path includes an `input_required` -elicitation retry, bidirectional request-stream cancellation observed by each -server, and successful post-cancellation tool calls in addition to discovery -and normal tool calls. +```bash +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=ts-to-dart \ + --expect-published-ts-client-gap +``` + +An unexpected pass or a different failure is an error, so the workaround cannot +silently become stale. To validate a TypeScript SDK #2513 preview installation, +run the same `ts-to-dart` direction without the expected-gap flag. + +The Dart-client path includes an `input_required` elicitation retry, +request-stream cancellation observed by the TypeScript server, and successful +post-cancellation tool calls in addition to discovery and normal tool calls. The `Run MCP 2026-07-28 Interop` workflow covers relevant PRs, manual dispatch, and a daily schedule on `main`. -The official Python SDK beta fixture runs independently in both directions: +The official Python SDK beta fixture runs each direction independently: ```bash python3 -m venv .dart_tool/python-2026-interop .dart_tool/python-2026-interop/bin/python -m pip install \ -r test/interop/python_2026_07_28/requirements.txt MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ - dart run tool/testing/run_python_2026_07_28_interop.dart + dart run tool/testing/run_python_2026_07_28_interop.dart \ + --direction=dart-to-python +MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ + dart run tool/testing/run_python_2026_07_28_interop.dart \ + --direction=python-to-dart \ + --expect-published-python-client-gap ``` -It verifies automatic MCP 2026-07-28 discovery plus tool listing and execution for both -Python client -> Dart server and Dart client -> Python server. +The Dart client -> Python beta server path remains required. The reverse path +asserts the published beta's exact pre-#3002 fallback; an unexpected pass or a +different failure is an error so the exception cannot silently become stale. The browser fixture runs the web implementation in Chrome against the same Dart conformance server: @@ -146,4 +161,6 @@ When adding a new interoperability claim: - A broader compatibility table once additional SDKs expose stable MCP 2025-11-25 fixtures. - Request-scoped cancellation against Python SDK beta and additional peer - implementations; both TypeScript SDK beta directions are verified. + implementations. The published TypeScript beta.4 server direction is + verified, its client direction remains the documented pre-#3002 gap, and the + TypeScript SDK #2513 preview verifies the final reverse path. diff --git a/doc/mcp-2026-07-28-release-runbook.md b/doc/mcp-2026-07-28-release-runbook.md index 0d010788..f59a3322 100644 --- a/doc/mcp-2026-07-28-release-runbook.md +++ b/doc/mcp-2026-07-28-release-runbook.md @@ -98,12 +98,21 @@ dart run test/conformance/run_2026_07_28_server_conformance.dart \ dart run test/conformance/run_2026_07_28_client_conformance.dart \ --timeout-seconds 90 cd test/interop/ts_2026_07_28 && npm ci && cd ../../.. -dart run tool/testing/run_ts_2026_07_28_interop.dart +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=dart-to-ts +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=ts-to-dart \ + --expect-published-ts-client-gap python3 -m venv .dart_tool/python-2026-interop .dart_tool/python-2026-interop/bin/python -m pip install \ -r test/interop/python_2026_07_28/requirements.txt MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ - dart run tool/testing/run_python_2026_07_28_interop.dart + dart run tool/testing/run_python_2026_07_28_interop.dart \ + --direction=dart-to-python +MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ + dart run tool/testing/run_python_2026_07_28_interop.dart \ + --direction=python-to-dart \ + --expect-published-python-client-gap dart run tool/testing/run_browser_2026_07_28_interop.dart dart run tool/testing/run_flutter_web_example_e2e.dart dart pub publish --dry-run diff --git a/doc/mcp-2026-07-28.md b/doc/mcp-2026-07-28.md index 59291d0e..7ac4cb8c 100644 --- a/doc/mcp-2026-07-28.md +++ b/doc/mcp-2026-07-28.md @@ -63,6 +63,26 @@ Use `McpServerOptions(protocol: McpProtocol.legacy)` to advertise only legacy MCP versions, or `McpServerOptions(protocol: McpProtocol.require2026)` to reject legacy initialization. +## Stateless identity metadata + +MCP `2026-07-28` carries peer identity in stateless metadata: + +- Clients normally include `_meta["io.modelcontextprotocol/clientInfo"]` on + every request, but the field is optional. Servers accept it when omitted and + reject a value that is present but is not a valid `Implementation` object. +- `McpServer` stamps its configured identity in + `_meta["io.modelcontextprotocol/serverInfo"]` on successful stateless results + by default; a handler-authored value at that key wins. `server/discover` no + longer emits a body `serverInfo` field. +- `DiscoverResult.serverInfo` is nullable and reads result metadata for + convenience. Missing or malformed optional identity is anonymous; + applications must not use this self-reported display/debug field for security + or protocol behavior. + +The parser temporarily accepts the pre-spec top-level discovery identity sent by +published TypeScript and Python SDK beta releases. Dart never emits that legacy +shape; the fallback can be removed after corrected peer releases are pinned. + ## Profile summary | Profile | Default? | Client behavior | Server behavior | @@ -162,10 +182,11 @@ matching dev.2 SDK dependency. while `dev/2026-07-28-rc` remains a read-only archive for links embedded in the earlier dev.0 and dev.1 packages. -CI runs official MCP 2025-11-25 and MCP 2026-07-28 conformance, pinned -spec-example audits, bidirectional TypeScript and Python interop, real-browser -transport tests, a Flutter Web service integration in Chrome, and widget -tests. See +CI runs official MCP 2025-11-25 and MCP 2026-07-28 conformance plus pinned +spec-example audits, published TypeScript and Python server interop, exact +published-beta client expected-gap checks for both SDKs, real-browser transport +tests, a Flutter Web service integration in Chrome, and widget tests. +TypeScript SDK #2513 preview interop is validated separately before release. See the [interoperability guide](interoperability.md#running-interop-checks-locally) for local commands and the [day-0 release runbook](mcp-2026-07-28-release-runbook.md) for the final tag, diff --git a/doc/spec-coverage-2026-07-28.md b/doc/spec-coverage-2026-07-28.md index 23d0bc5e..65cae522 100644 --- a/doc/spec-coverage-2026-07-28.md +++ b/doc/spec-coverage-2026-07-28.md @@ -64,7 +64,11 @@ Run the TypeScript SDK beta interop gate from the repository root: cd test/interop/ts_2026_07_28 npm ci cd ../../.. -dart run tool/testing/run_ts_2026_07_28_interop.dart +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=dart-to-ts +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=ts-to-dart \ + --expect-published-ts-client-gap ``` Run the independent JSON Schema Draft 2020-12 and Draft 7 compatibility gates @@ -103,17 +107,18 @@ schedule. | Spec area | Official source | Requirement tracked here | Local coverage | Cross-SDK coverage | Official conformance | Status | | --- | --- | --- | --- | --- | --- | --- | | Default stable profile with legacy opt-out | [Versioning and compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) | The dev.2 preview defaults to `McpProtocol.stable`, while callers can explicitly select `McpProtocol.legacy` or `McpProtocol.require2026`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`doc/mcp-2026-07-28.md`](mcp-2026-07-28.md) | TypeScript SDK beta interop covers explicit MCP 2026-07-28 negotiation; local tests cover the default-option path. | MCP 2025-11-25 and MCP 2026-07-28 conformance both run in CI. | Verified | -| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart), [`test/conformance/mcp_2026_07_28_client.dart`](../test/conformance/mcp_2026_07_28_client.dart) | The TypeScript and Python SDK beta fixtures validate discovery and protocol selection in both directions. | `protocol-version`, `server/discover`, and client negotiation scenarios in alpha.9. | Verified | -| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every MCP 2026-07-28 request carries protocol version, client identity, and client capabilities in `_meta`; servers do not infer protocol state from a prior request. Non-MCP metadata remains opaque and is preserved. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | TypeScript SDK beta client fixture exercises normal request paths with MCP 2026-07-28 metadata. | `stateless` and `stateless-http` scenarios in alpha.9. | Verified | +| Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart), [`test/conformance/mcp_2026_07_28_client.dart`](../test/conformance/mcp_2026_07_28_client.dart) | Dart clients retain temporary legacy-body read compatibility with published TypeScript and Python beta servers. Their published beta clients remain known pre-#3002 gaps against a spec-correct Dart server; TypeScript SDK #2513 preview provides forward validation. | Published alpha.9 predates #3002; conformance PR #403 semantics are verified locally against the merged PR source. | Verified locally; published peer/referee gaps | +| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every MCP 2026-07-28 request carries protocol version and client capabilities in `_meta`; client identity is optional, while a present malformed identity is rejected. Servers do not infer protocol state from a prior request. Non-MCP metadata remains opaque and is preserved. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | Published TypeScript SDK beta clients exercise identified requests; local tests and merged conformance PR #403 source cover anonymous requests. | Published alpha.9 incorrectly requires `clientInfo`; `server-stateless` is expected-failed until a release includes conformance PR #403. | Verified locally; published referee gap | +| Stateless result identity | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | `McpServer` stamps its configured identity by default in successful MCP 2026-07-28 result `_meta["io.modelcontextprotocol/serverInfo"]`, while a handler-authored value at that key wins; discovery has no body `serverInfo`, and missing or malformed peer identity is treated as anonymous. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/types_test.dart`](../test/types_test.dart) cover discovery, list/tool/task/subscription metadata merging, handler precedence, anonymous identity, and legacy isolation. | Dart temporarily reads legacy discovery-body identity from published TypeScript and Python beta servers; TypeScript SDK #2513 preview validates the final result metadata shape. | Conformance PR #403 checks discovery identity metadata; published alpha.9 predates it. | Verified locally; published peer/referee gaps | | JSON-RPC envelopes and errors | [Base protocol](https://modelcontextprotocol.io/specification/draft/basic) | String and integer request IDs retain their wire identity, arbitrary JSON-RPC error `data` remains observable, and unknown metadata is preserved. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Cross-SDK fixtures exercise normal success and error envelopes. | Error and malformed-request scenarios in alpha.9. | Verified | -| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | MCP 2026-07-28 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. A `HeaderMismatch` refreshes `tools/list` metadata before one retry. Stateless SSE responses preserve browser CORS headers. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart) | TypeScript SDK beta validates `x-mcp-header` mirroring, raw header rejection, and retry after a schema refresh; Chrome validates the real cross-origin MCP 2026-07-28 path. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.9 cases. | Verified | -| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | MCP 2026-07-28 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors and JSON-RPC batches, and cancels a stateless request by closing only that POST response stream without legacy notification redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart) | Bidirectional TypeScript SDK beta interop cancels streamed responses, proves that each server observes the closed response, and completes follow-up calls. Loopback HTTP and real Chrome add signal, timeout, public subscription cancellation, sibling isolation, cleanup, and recovery coverage. Python cancellation is not yet covered. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.9 cases. | Verified | -| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart) | TypeScript SDK beta client fixture checks discovery cache metadata and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.9. | Verified | -| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for MCP 2025-11-25 behavior. The built-in validator defaults to Draft 2020-12, accepts an explicitly declared Draft 7 schema for MCP 2025-11-25 compatibility, and synchronously resolves local fragments plus absolute or relative identifiers that stay inside the supplied schema document, including dynamic references. Unresolved references outside that document, unsupported dialects, and custom vocabularies are rejected without network I/O. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/shared/json_schema_validator_test.dart`](../test/shared/json_schema_validator_test.dart), [`test/shared/json_schema_validator_io_test.dart`](../test/shared/json_schema_validator_io_test.dart), [`tool/testing/run_json_schema_2020_12_suite.dart`](../tool/testing/run_json_schema_2020_12_suite.dart), [`tool/testing/run_json_schema_draft7_suite.dart`](../tool/testing/run_json_schema_draft7_suite.dart), [`tool/testing/json_schema_suite_runner.dart`](../tool/testing/json_schema_suite_runner.dart), [`tool/testing/json_schema_test_suite_ref.txt`](../tool/testing/json_schema_test_suite_ref.txt), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | TypeScript SDK beta fixture validates `tools/list` and `tools/call`; schema-validation semantics and the no-network policy are locally tested. | Both MCP 2026-07-28 server and client suites are green with no expected failures. The pinned official JSON Schema Test Suite gates pass all 1,242 supported Draft 2020-12 assertions and all 904 supported Draft 7 assertions, while a loopback security test proves that rejected network `$ref` values cause no HTTP request. | Verified | +| Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | MCP 2026-07-28 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. A `HeaderMismatch` refreshes `tools/list` metadata before one retry. Stateless SSE responses preserve browser CORS headers. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart) | The TypeScript SDK #2513 preview validates `x-mcp-header` mirroring and raw rejection against Dart; the published TypeScript server validates Dart's schema-refresh retry. Chrome validates the real cross-origin path. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.9 cases. | Verified | +| Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | MCP 2026-07-28 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors and JSON-RPC batches, and cancels a stateless request by closing only that POST response stream without legacy notification redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart) | The published TypeScript server path verifies Dart request cancellation and recovery; the #2513 preview verifies the reverse server-observed close. Loopback HTTP and real Chrome add sibling isolation and cleanup coverage. Python cancellation is not yet covered. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.9 cases. | Verified | +| Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart) | The TypeScript SDK #2513 preview fixture checks discovery and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.9. | Verified | +| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for MCP 2025-11-25 behavior. The built-in validator defaults to Draft 2020-12, accepts an explicitly declared Draft 7 schema for MCP 2025-11-25 compatibility, and synchronously resolves local fragments plus absolute or relative identifiers that stay inside the supplied schema document, including dynamic references. Unresolved references outside that document, unsupported dialects, and custom vocabularies are rejected without network I/O. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/shared/json_schema_validator_test.dart`](../test/shared/json_schema_validator_test.dart), [`test/shared/json_schema_validator_io_test.dart`](../test/shared/json_schema_validator_io_test.dart), [`tool/testing/run_json_schema_2020_12_suite.dart`](../tool/testing/run_json_schema_2020_12_suite.dart), [`tool/testing/run_json_schema_draft7_suite.dart`](../tool/testing/run_json_schema_draft7_suite.dart), [`tool/testing/json_schema_suite_runner.dart`](../tool/testing/json_schema_suite_runner.dart), [`tool/testing/json_schema_test_suite_ref.txt`](../tool/testing/json_schema_test_suite_ref.txt), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | The published TypeScript server and #2513 preview client validate `tools/list` and `tools/call`; schema-validation semantics and the no-network policy are locally tested. | The MCP 2026-07-28 client suite is green; the published server suite keeps only the documented `server-stateless` pre-#3002 gap expected. The pinned official JSON Schema Test Suite gates pass all supported assertions, while a loopback security test proves rejected network `$ref` values cause no HTTP request. | Verified with a published referee gap | | Resource semantics | [Resources](https://modelcontextprotocol.io/specification/draft/server/resources) | Successful reads preserve typed contents and cache metadata; a missing resource returns `InvalidParams` (`-32602`) rather than an empty result. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/mcp_server_test.dart`](../test/server/mcp_server_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart) | Current beta fixtures focus on discovery and tools rather than MCP 2026-07-28 resource errors. | Official `sep-2164-resource-not-found` plus resource list/read scenarios in alpha.9. | Verified | -| MRTR and elicitation | [MRTR](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr), [Elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation) | MCP 2026-07-28 `input_required` results are emitted only for supported requests and require advertised client capabilities. URL-mode `elicitation/create` uses `mode`, `message`, and `url` without legacy `elicitationId` or `notifications/elicitation/complete`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart), [`example/mcp_2026_07_28/`](../example/mcp_2026_07_28/) | TypeScript SDK beta client fixture completes an MCP 2026-07-28 `input_required` retry flow against the Dart server; the reverse Dart stable-profile client fixture completes an `input_required` elicitation retry against the TypeScript SDK beta server. | `mrtr` scenarios in alpha.9. | Verified | -| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions), [Schema reference](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenresult) | Each `subscriptions/listen` stream acknowledges before any later notification carrying that subscription ID. Other subscription IDs may interleave on stdio. The SDK filters unsupported types, correlates through `io.modelcontextprotocol/subscriptionId`, and returns the same ID on graceful close. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart), [`example/mcp_2026_07_28/`](../example/mcp_2026_07_28/) | TypeScript SDK beta fixture validates `subscriptions/listen` acknowledgment and list-change notification correlation against the Dart server. | Subscription scenarios in alpha.9. | Verified | -| Deprecated request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | The deprecated MCP 2026-07-28 logging wire behavior is retained for compatibility with request-scoped metadata, while removed stable-era core RPCs and notifications are rejected in the MCP 2026-07-28 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | TypeScript SDK beta fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.9. | Verified | +| MRTR and elicitation | [MRTR](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr), [Elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation) | MCP 2026-07-28 `input_required` results are emitted only for supported requests and require advertised client capabilities. URL-mode `elicitation/create` uses `mode`, `message`, and `url` without legacy `elicitationId` or `notifications/elicitation/complete`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart), [`example/mcp_2026_07_28/`](../example/mcp_2026_07_28/) | The TypeScript SDK #2513 preview client completes an MCP 2026-07-28 `input_required` retry against Dart; the Dart stable-profile client completes the reverse retry against the published TypeScript beta server. | `mrtr` scenarios in alpha.9. | Verified | +| Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions), [Schema reference](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenresult) | Each `subscriptions/listen` stream acknowledges before any later notification carrying that subscription ID. Other subscription IDs may interleave on stdio. The SDK filters unsupported types, correlates through `io.modelcontextprotocol/subscriptionId`, and returns the same ID on graceful close. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart), [`example/mcp_2026_07_28/`](../example/mcp_2026_07_28/) | The TypeScript SDK #2513 preview validates acknowledgment and list-change notification correlation against Dart. | Subscription scenarios in alpha.9. | Verified | +| Deprecated request-scoped logging and removed core RPCs | [Logging](https://modelcontextprotocol.io/specification/draft/server/utilities/logging), [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | The deprecated MCP 2026-07-28 logging wire behavior is retained for compatibility with request-scoped metadata, while removed stable-era core RPCs and notifications are rejected in the MCP 2026-07-28 profile. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/server_advanced_test.dart`](../test/server/server_advanced_test.dart) | The TypeScript SDK #2513 preview fixture validates raw removed-RPC rejection against the Dart server. | Removed-RPC and logging scenarios in alpha.9. | Verified | | Tasks extension | [Tasks extension SEP-2663](https://tasks.extensions.modelcontextprotocol.io/seps/2663-tasks-extension) | The `io.modelcontextprotocol/tasks` extension negotiates task support, returns extension result shapes, and implements `tasks/get`, `tasks/update`, and `tasks/cancel` without restoring removed core task augmentation. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/types/tasks_extension_test.dart`](../test/types/tasks_extension_test.dart), [`test/client/task_client_test.dart`](../test/client/task_client_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), CLI task-extension conformance cases | No checked-in MCP 2026-07-28 cross-SDK task-extension fixture yet. | The alpha.9 official server/client scenario lists do not cover the Tasks extension. | Local only | | Authorization and HTTP deployment security | [Authorization](https://modelcontextprotocol.io/specification/draft/basic/authorization), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | Covers OAuth protected-resource discovery, bearer challenges, PKCE S256, callback state, trusted discovery origins, redirect refusal, resource-bound token exchange, exact Origin handling, Host validation, and safe loopback defaults. | [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/server/streamable_security_harness_test.dart`](../test/server/streamable_security_harness_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/example/oauth_client_example_test.dart`](../test/example/oauth_client_example_test.dart), [`example/authentication/`](../example/authentication/) | Stable TypeScript OAuth interop covers the complete protected-resource flow; current MCP 2026-07-28 beta interop has limited credentialed coverage. | The alpha.9 client runner contains authorization scenarios; server-side deployment policy remains locally tested. | Verified | | MCP 2026-07-28 preview public APIs | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | APIs useful only for MCP 2026-07-28, such as non-object structured tool output and MCP 2026-07-28 protocol profiles, are documented as preview APIs. Callers can explicitly select the legacy profile when those APIs are unsuitable. | [`doc/mcp-2026-07-28.md`](mcp-2026-07-28.md), public dartdoc on protocol profiles and MCP 2026-07-28 helpers. | Not cross-SDK specific. | Covered indirectly by MCP 2026-07-28 conformance and local parser/serializer tests. | Verified | @@ -123,10 +128,21 @@ schedule. These are gaps in external evidence unless stated otherwise; they are not known missing core protocol behavior. +- Published `@modelcontextprotocol/conformance@0.2.0-alpha.9` predates spec + PR #3002 and conformance PR #403, so `server-stateless` is an expected + scenario-level failure while merged-PR-source verification is green. +- Published TypeScript SDK beta.4 requires body `DiscoverResult.serverInfo`. + Dart keeps the Dart-client -> TypeScript-server path through a temporary read + fallback, while the TypeScript-client -> Dart-server direction remains an + exact expected negotiation gap until TypeScript SDK PR #2513 is released. +- Published Python SDK `mcp==2.0.0b1` also requires body + `DiscoverResult.serverInfo`. Dart-client -> Python-server remains required + through the same read fallback; Python-client -> Dart-server asserts its + exact 2026 -> 2025 fallback as a self-expiring expected gap. - The official conformance package is still alpha, so its scenario inventory and assertions may continue to change before the final specification tag. - The current alpha.9 server and client suites pass with no expected failures; - a local network-`$ref` security canary remains as regression coverage. + The current alpha.9 client suite passes with no expected failures; a local + network-`$ref` security canary remains as regression coverage. - The Tasks extension is locally covered but is absent from the current official alpha.9 scenario inventory and from checked-in MCP 2026-07-28 cross-SDK interop. Do not describe it as cross-SDK verified yet. diff --git a/lib/src/client/client.dart b/lib/src/client/client.dart index a42e9c99..315ad642 100644 --- a/lib/src/client/client.dart +++ b/lib/src/client/client.dart @@ -555,8 +555,12 @@ class McpClient extends Protocol { versionedTransport?.protocolVersion = negotiatedProtocolVersion; + final discoveredServer = result.serverInfo; + final serverDescription = discoveredServer == null + ? 'unknown' + : '${discoveredServer.name} ${discoveredServer.version}'; _logger.debug( - "MCP Server Discovered. Server: ${result.serverInfo.name} ${result.serverInfo.version}, Protocol: $negotiatedProtocolVersion", + "MCP Server Discovered. Server: $serverDescription, Protocol: $negotiatedProtocolVersion", ); return result; diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index d8e654a0..8f74f65f 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -5,6 +5,7 @@ import 'package:mcp_dart/src/shared/logging.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:mcp_dart/src/types/json_rpc.dart' as json_rpc; +import 'package:mcp_dart/src/types/validation.dart' show readOptionalJsonObject; final _logger = Logger("mcp_dart.server"); @@ -209,11 +210,12 @@ class Server extends Protocol { ); } + final hasClientInfo = meta?.containsKey(McpMetaKey.clientInfo) == true; final clientInfo = meta?[McpMetaKey.clientInfo]; - if (clientInfo is! Map) { + if (hasClientInfo && clientInfo is! Map) { return McpError( ErrorCode.invalidParams.value, - 'Missing required request metadata: ${McpMetaKey.clientInfo}', + 'Invalid stateless request metadata: ${McpMetaKey.clientInfo}', ); } @@ -226,7 +228,9 @@ class Server extends Protocol { } try { - Implementation.fromJson(clientInfo.cast()); + if (hasClientInfo) { + Implementation.fromJson(clientInfo!.cast()); + } ClientCapabilities.fromJson(clientCapabilities.cast()); } catch (error) { return McpError( @@ -938,6 +942,15 @@ class Server extends Protocol { ); } + final resultMeta = { + ...?readOptionalJsonObject(json['_meta'], 'Result._meta'), + }; + // Handler-authored metadata intentionally wins, including an explicit null + // that configures this result as anonymous. Receivers treat malformed + // optional identity as anonymous rather than using it for behavior. + resultMeta.putIfAbsent(McpMetaKey.serverInfo, _serverInfo.toJson); + json['_meta'] = resultMeta; + return json; } diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 782e90ab..07d99c03 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1665,10 +1665,23 @@ abstract class Protocol { return; } + final serializedResult = serializeIncomingResult(request, result); + final serializedMeta = readOptionalJsonObject( + serializedResult['_meta'], + 'Result._meta', + ); + Map? responseMeta; + if (result.meta != null || serializedResult.containsKey('_meta')) { + responseMeta = { + ...?result.meta, + ...?serializedMeta, + }; + } + final response = JsonRpcResponse( id: request.id, - result: serializeIncomingResult(request, result), - meta: _mergeRelatedTaskMeta(result.meta, relatedTaskJson), + result: serializedResult, + meta: _mergeRelatedTaskMeta(responseMeta, relatedTaskJson), ); if (relatedTaskId != null && _taskMessageQueue != null) { diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index 71b3667e..a940cc6f 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -1355,8 +1355,12 @@ class DiscoverResult implements CacheableResultData { /// Capabilities the server supports. final ServerCapabilities capabilities; - /// Information about the server implementation. - final Implementation serverInfo; + /// Information about the server implementation, when advertised in result + /// metadata. + /// + /// `server/discover` identity is optional in MCP `2026-07-28` and is carried + /// by `_meta.io.modelcontextprotocol/serverInfo` rather than the result body. + final Implementation? serverInfo; /// Instructions describing how to use the server and its features. final String? instructions; @@ -1377,7 +1381,7 @@ class DiscoverResult implements CacheableResultData { this.resultType = 'complete', required this.supportedVersions, required this.capabilities, - required this.serverInfo, + this.serverInfo, this.instructions, this.ttlMs, this.cacheScope, @@ -1402,6 +1406,36 @@ class DiscoverResult implements CacheableResultData { ); } + final meta = readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'); + Implementation? readServerInfo(Object? value, String fieldName) { + if (value == null) return null; + try { + return Implementation.fromJson(readJsonObject(value, fieldName)); + } on FormatException { + // Server identity is optional, self-reported display metadata. Invalid + // identity must not change discovery or connection behavior. + return null; + } + } + + final hasMetadataServerInfo = + meta?.containsKey(McpMetaKey.serverInfo) == true; + final metadataServerInfo = hasMetadataServerInfo + ? readServerInfo( + meta![McpMetaKey.serverInfo], + 'DiscoverResult._meta.${McpMetaKey.serverInfo}', + ) + : null; + final sanitizedMeta = hasMetadataServerInfo && metadataServerInfo == null + ? ({...meta!}..remove(McpMetaKey.serverInfo)) + : meta; + final serverInfo = hasMetadataServerInfo + ? metadataServerInfo + // Temporary read compatibility until corrected TypeScript and Python + // SDK releases ship. Their pinned beta fixtures still send the + // pre-#3002 body field; Dart never emits it. + : readServerInfo(json['serverInfo'], 'DiscoverResult.serverInfo'); + return DiscoverResult( supportedVersions: [ for (final version in supportedVersions) @@ -1410,9 +1444,7 @@ class DiscoverResult implements CacheableResultData { capabilities: ServerCapabilities.fromJson( readJsonObject(json['capabilities'], 'DiscoverResult.capabilities'), ), - serverInfo: Implementation.fromJson( - readJsonObject(json['serverInfo'], 'DiscoverResult.serverInfo'), - ), + serverInfo: serverInfo, instructions: readOptionalString( json['instructions'], 'DiscoverResult.instructions', @@ -1422,7 +1454,7 @@ class DiscoverResult implements CacheableResultData { json['cacheScope'], 'DiscoverResult.cacheScope', ), - meta: readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'), + meta: sanitizedMeta, ); } @@ -1438,15 +1470,19 @@ class DiscoverResult implements CacheableResultData { ); } + final resultMeta = { + if (serverInfo != null) McpMetaKey.serverInfo: serverInfo!.toJson(), + ...?readOptionalJsonObject(meta, 'DiscoverResult._meta'), + }; + return { 'resultType': resultType, 'supportedVersions': supportedVersions, 'capabilities': capabilities.toJson(omitLegacyTasks: true), - 'serverInfo': serverInfo.toJson(), if (instructions != null) 'instructions': instructions, if (ttlMs != null) 'ttlMs': ttlMs, if (cacheScope != null) 'cacheScope': cacheScope, - if (meta != null) '_meta': readJsonObject(meta, 'DiscoverResult._meta'), + if (meta != null || serverInfo != null) '_meta': resultMeta, }; } } diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index b7802042..1090410b 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -141,13 +141,15 @@ String? negotiateProtocolVersion( return null; } -/// Standard MCP `_meta` keys used by the `2026-07-28` stateless request model. +/// Standard MCP `_meta` keys used by the `2026-07-28` stateless request and +/// result models. /// /// `_meta` itself is extensible; keys not defined by MCP remain application or /// extension metadata and are preserved on the wire. class McpMetaKey { static const protocolVersion = 'io.modelcontextprotocol/protocolVersion'; static const clientInfo = 'io.modelcontextprotocol/clientInfo'; + static const serverInfo = 'io.modelcontextprotocol/serverInfo'; static const clientCapabilities = 'io.modelcontextprotocol/clientCapabilities'; static const logLevel = 'io.modelcontextprotocol/logLevel'; @@ -160,7 +162,7 @@ class McpMetaKey { /// request model. Map buildProtocolRequestMeta({ required String protocolVersion, - required Implementation clientInfo, + Implementation? clientInfo, required ClientCapabilities clientCapabilities, Map? meta, Object? logLevel, @@ -169,7 +171,7 @@ Map buildProtocolRequestMeta({ return { ...?meta, McpMetaKey.protocolVersion: protocolVersion, - McpMetaKey.clientInfo: clientInfo.toJson(), + if (clientInfo != null) McpMetaKey.clientInfo: clientInfo.toJson(), McpMetaKey.clientCapabilities: clientCapabilities.toJson( omitLegacyTasks: isStatelessProtocolVersion(protocolVersion), omitLegacyRootsListChanged: isStatelessProtocolVersion(protocolVersion), @@ -689,15 +691,30 @@ class JsonRpcResponse extends JsonRpcMessage { const JsonRpcResponse({required this.id, required this.result, this.meta}); @override - Map toJson() => { - 'jsonrpc': jsonrpc, - 'id': _requestIdToJson(id, 'JsonRpcResponse.id'), - 'result': { - ...readJsonObject(result, 'JsonRpcResponse.result'), - if (meta != null) - '_meta': readJsonObject(meta, 'JsonRpcResponse._meta'), - }, - }; + Map toJson() { + final resultJson = readJsonObject(result, 'JsonRpcResponse.result'); + final resultMeta = readOptionalJsonObject( + resultJson['_meta'], + 'JsonRpcResponse.result._meta', + ); + final responseMeta = + meta == null ? null : readJsonObject(meta, 'JsonRpcResponse._meta'); + final wireResult = Map.from(resultJson)..remove('_meta'); + final wireMeta = { + ...?resultMeta, + ...?responseMeta, + }; + final hasWireMeta = resultJson.containsKey('_meta') || meta != null; + + return { + 'jsonrpc': jsonrpc, + 'id': _requestIdToJson(id, 'JsonRpcResponse.id'), + 'result': { + ...wireResult, + if (hasWireMeta) '_meta': wireMeta, + }, + }; + } } // --- JSON-RPC Error --- diff --git a/packages/mcp_dart_cli/lib/src/conformance_runner.dart b/packages/mcp_dart_cli/lib/src/conformance_runner.dart index a52cb360..f5c31b9a 100644 --- a/packages/mcp_dart_cli/lib/src/conformance_runner.dart +++ b/packages/mcp_dart_cli/lib/src/conformance_runner.dart @@ -13,6 +13,7 @@ const String _previewProtocolVersion = '2026-07-28'; const String _protocolVersionMetaKey = 'io.modelcontextprotocol/protocolVersion'; const String _clientInfoMetaKey = 'io.modelcontextprotocol/clientInfo'; +const String _serverInfoMetaKey = 'io.modelcontextprotocol/serverInfo'; const String _clientCapabilitiesMetaKey = 'io.modelcontextprotocol/clientCapabilities'; const String _resultTypeComplete = 'complete'; @@ -291,7 +292,7 @@ class ConformanceRunner { suite: _specSuite, name: 'stateless.requires-complete-request-meta', description: - 'Rejects MCP 2026-07-28 stateless requests whose _meta omits required client identity or capability fields.', + 'Accepts MCP 2026-07-28 stateless requests without optional client identity while requiring client capabilities.', check: _statelessRequestsRequireCompleteRequestMeta, ), _ConformanceCase( @@ -921,9 +922,11 @@ class _DiscoveringConformanceTransport extends Transport _previewProtocolVersion, ], 'capabilities': capabilities, - 'serverInfo': const { - 'name': 'conformance-server', - 'version': '1.0.0', + '_meta': const { + _serverInfoMetaKey: { + 'name': 'conformance-server', + 'version': '1.0.0', + }, }, 'ttlMs': 0, 'cacheScope': _cacheScopePrivate, @@ -1348,9 +1351,15 @@ Future _serverDiscoverReturnsSupportedCapabilities() async { 'Expected server/discover to include $_previewProtocolVersion.', ); } - final serverInfo = result['serverInfo']; + final resultMeta = result['_meta']; + final serverInfo = resultMeta is Map ? resultMeta[_serverInfoMetaKey] : null; if (serverInfo is! Map || serverInfo['name'] != 'server') { - throw StateError('Expected server/discover to include server identity.'); + throw StateError( + 'Expected server/discover to include server identity in result metadata.', + ); + } + if (result.containsKey('serverInfo')) { + throw StateError('server/discover must not emit body serverInfo.'); } if (result['instructions'] != 'Conformance server.') { throw StateError('Expected server/discover to include instructions.'); @@ -1394,17 +1403,36 @@ Future _rejectsUnsupportedStatelessProtocolVersion() async { } Future _statelessRequestsRequireCompleteRequestMeta() async { - final scenarios = <({String id, Map meta, String missing})>[ - ( + final transport = _ConformanceTransport(); + final server = McpServer( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.stable, + ), + ); + + await server.connect(transport); + transport.emit( + const JsonRpcRequest( id: 'missing-client-info', + method: _serverDiscoverMethod, meta: { _protocolVersionMetaKey: _previewProtocolVersion, _clientCapabilitiesMetaKey: {}, }, - missing: _clientInfoMetaKey, ), - ( + ); + await _settle(); + _expectSingleErrorFreeResponse( + transport.sentMessages, + id: 'missing-client-info', + ); + transport.sentMessages.clear(); + + transport.emit( + const JsonRpcRequest( id: 'missing-client-capabilities', + method: _serverDiscoverMethod, meta: { _protocolVersionMetaKey: _previewProtocolVersion, _clientInfoMetaKey: { @@ -1412,36 +1440,15 @@ Future _statelessRequestsRequireCompleteRequestMeta() async { 'version': '1.0.0', }, }, - missing: _clientCapabilitiesMetaKey, - ), - ]; - - final transport = _ConformanceTransport(); - final server = McpServer( - const Implementation(name: 'server', version: '1.0.0'), - options: const McpServerOptions( - protocol: McpProtocol.stable, ), ); - - await server.connect(transport); - for (final scenario in scenarios) { - transport.emit( - JsonRpcListToolsRequest( - id: scenario.id, - meta: scenario.meta, - ), - ); - await _settle(); - - _expectSingleError( - transport.sentMessages, - id: scenario.id, - code: ErrorCode.invalidParams.value, - messageContains: scenario.missing, - ); - transport.sentMessages.clear(); - } + await _settle(); + _expectSingleError( + transport.sentMessages, + id: 'missing-client-capabilities', + code: ErrorCode.invalidParams.value, + messageContains: _clientCapabilitiesMetaKey, + ); await server.close(); } @@ -1500,9 +1507,11 @@ Future _httpModernProtocolErrorsRetryDiscovery() async { 'capabilities': { 'tools': {}, }, - 'serverInfo': { - 'name': 'modern-http-server', - 'version': '1.0.0', + '_meta': { + _serverInfoMetaKey: { + 'name': 'modern-http-server', + 'version': '1.0.0', + }, }, 'ttlMs': 0, 'cacheScope': _cacheScopePrivate, @@ -4425,11 +4434,17 @@ Future _taskStoreUsesTaskExtensionResultShapes() async { transport.sentMessages, id: 'task-store-cancel', ); - if (cancelResponse.result.length != 1 || - cancelResponse.result['resultType'] != _resultTypeComplete) { + final cancelResult = cancelResponse.result; + final cancelMeta = cancelResult['_meta']; + final cancelServerInfo = + cancelMeta is Map ? cancelMeta[_serverInfoMetaKey] : null; + if (cancelResult.length != 2 || + cancelResult['resultType'] != _resultTypeComplete || + cancelServerInfo is! Map || + cancelServerInfo['name'] != 'server') { throw StateError( - 'Expected built-in tasks/cancel to acknowledge with complete result, ' - 'got ${cancelResponse.result}.', + 'Expected built-in tasks/cancel to acknowledge with complete result ' + 'and server identity metadata, got $cancelResult.', ); } final cancelledTask = await store.getTask(workingTask.taskId); diff --git a/packages/mcp_dart_cli/lib/src/inspect_client_command.dart b/packages/mcp_dart_cli/lib/src/inspect_client_command.dart index ae8b62ee..b7d9110f 100644 --- a/packages/mcp_dart_cli/lib/src/inspect_client_command.dart +++ b/packages/mcp_dart_cli/lib/src/inspect_client_command.dart @@ -392,11 +392,12 @@ class ClientInspectorHarness { ); } + final hasClientInfo = meta?.containsKey(McpMetaKey.clientInfo) == true; final clientInfo = meta?[McpMetaKey.clientInfo]; - if (clientInfo is! Map) { + if (hasClientInfo && clientInfo is! Map) { return McpError( ErrorCode.invalidParams.value, - 'Missing required request metadata: ${McpMetaKey.clientInfo}', + 'Invalid stateless request metadata: ${McpMetaKey.clientInfo}', ); } final clientCapabilities = meta?[McpMetaKey.clientCapabilities]; @@ -409,7 +410,9 @@ class ClientInspectorHarness { } try { - Implementation.fromJson(clientInfo.cast()); + if (clientInfo is Map) { + Implementation.fromJson(clientInfo.cast()); + } ClientCapabilities.fromJson( clientCapabilities.cast(), ); @@ -425,7 +428,8 @@ class ClientInspectorHarness { void _captureStatelessClientMetadata(Map meta) { _clientProtocolVersion = meta[McpMetaKey.protocolVersion] as String; - _clientInfo = (meta[McpMetaKey.clientInfo] as Map).cast(); + final clientInfo = meta[McpMetaKey.clientInfo]; + _clientInfo = clientInfo is Map ? clientInfo.cast() : null; _clientCapabilities = (meta[McpMetaKey.clientCapabilities] as Map).cast(); } @@ -695,10 +699,25 @@ class ClientInspectorHarness { } void _sendResult(Object? id, Map result) { + final wireResult = {...result}; + if (_sawDiscover) { + final existingMeta = result['_meta']; + final resultMeta = { + if (existingMeta is Map) ...existingMeta.cast(), + }; + resultMeta.putIfAbsent( + McpMetaKey.serverInfo, + () => { + 'name': 'mcp_dart_client_inspector', + 'version': cli_version.packageVersion, + }, + ); + wireResult['_meta'] = resultMeta; + } _send({ 'jsonrpc': jsonRpcVersion, 'id': id, - 'result': result, + 'result': wireResult, }); } @@ -981,6 +1000,12 @@ class ClientInspectorHarness { 'lifecycle.client-info', 'Client provided implementation name and version.', ); + } else if (stateless && _clientInfo == null) { + _checks.info( + 'lifecycle.client-info', + 'Client did not expose usable optional clientInfo metadata and is ' + 'anonymous.', + ); } else { _checks.fail( 'lifecycle.client-info', diff --git a/packages/mcp_dart_cli/lib/src/inspect_server_command.dart b/packages/mcp_dart_cli/lib/src/inspect_server_command.dart index 35ec0a48..dbfd7ae1 100644 --- a/packages/mcp_dart_cli/lib/src/inspect_server_command.dart +++ b/packages/mcp_dart_cli/lib/src/inspect_server_command.dart @@ -275,6 +275,12 @@ class McpServerInspector { if (serverInfo != null) { metadata['serverInfo'] = serverInfo.toJson(); _checkImplementation(checks, serverInfo); + } else if (_usesStatelessProtocol(client)) { + checks.info( + 'lifecycle.server-info', + 'Server did not expose usable optional stateless serverInfo and is ' + 'anonymous.', + ); } else { checks.fail( 'lifecycle.server-info', diff --git a/packages/mcp_dart_cli/test/fixtures/stateless_inventory_server.dart b/packages/mcp_dart_cli/test/fixtures/stateless_inventory_server.dart index 4b22f09b..f6c12970 100644 --- a/packages/mcp_dart_cli/test/fixtures/stateless_inventory_server.dart +++ b/packages/mcp_dart_cli/test/fixtures/stateless_inventory_server.dart @@ -1,7 +1,17 @@ import 'dart:convert'; import 'dart:io'; -Future main() async { +Future main(List args) async { + final anonymous = args.contains('--anonymous'); + final Map? resultMeta = + anonymous + ? null + : { + 'io.modelcontextprotocol/serverInfo': { + 'name': 'stateless-inventory-fixture', + 'version': '1.0.0', + }, + }; await for (final line in stdin .transform(utf8.decoder) .transform(const LineSplitter())) { @@ -22,10 +32,7 @@ Future main() async { 'tools': {}, 'logging': {}, }, - 'serverInfo': { - 'name': 'stateless-inventory-fixture', - 'version': '1.0.0', - }, + if (resultMeta != null) '_meta': resultMeta, }); break; case 'tools/list': @@ -34,6 +41,7 @@ Future main() async { 'ttlMs': 0, 'cacheScope': 'private', 'tools': >[], + if (resultMeta != null) '_meta': resultMeta, }); break; case 'ping': diff --git a/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart b/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart index de330df6..999ba102 100644 --- a/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart +++ b/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart @@ -231,6 +231,90 @@ void main() { }, ); + test( + 'accepts anonymous stateless clients and stamps server identity', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'client_harness_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + final report = File('${tempDir.path}/report.json'); + final clientLines = StreamController(); + final outputLines = []; + final harness = ClientInspectorHarness( + reportFile: report, + idleTimeout: const Duration(milliseconds: 50), + maxRuntime: const Duration(seconds: 1), + clientLines: clientLines.stream, + writeLine: outputLines.add, + ); + final meta = buildProtocolRequestMeta( + protocolVersion: previewProtocolVersion, + clientCapabilities: const ClientCapabilities(), + ); + + final runFuture = harness.run(); + clientLines + ..add( + jsonEncode( + JsonRpcServerDiscoverRequest(id: 1, meta: meta).toJson(), + ), + ) + ..add( + jsonEncode( + JsonRpcListToolsRequest(id: 2, meta: meta).toJson(), + ), + ); + await clientLines.close(); + await runFuture; + + final responses = + outputLines.map(jsonDecode).cast>().toList(); + final discoverResponse = responses.singleWhere( + (response) => response['id'] == 1, + ); + final toolsResponse = responses.singleWhere( + (response) => response['id'] == 2, + ); + expect(discoverResponse, isNot(contains('error'))); + expect(toolsResponse, isNot(contains('error'))); + + for (final response in >[ + discoverResponse, + toolsResponse, + ]) { + final result = response['result'] as Map; + final resultMeta = result['_meta'] as Map; + expect( + resultMeta[McpMetaKey.serverInfo], + allOf( + containsPair('name', 'mcp_dart_client_inspector'), + containsPair('version', isNotEmpty), + ), + ); + } + expect( + discoverResponse['result'] as Map, + isNot(contains('serverInfo')), + ); + + final json = + jsonDecode(await report.readAsString()) as Map; + expect(json['passed'], isTrue); + final checks = + (json['checks'] as List).cast>(); + expect( + checks.singleWhere( + (check) => check['id'] == 'lifecycle.client-info', + ), + allOf( + containsPair('status', 'info'), + containsPair('message', contains('optional clientInfo')), + ), + ); + }, + ); + test( 'inspects a client handshake and observed operations in-process', () async { @@ -331,7 +415,7 @@ void main() { }, ); - test('rejects stateless operations without per-request metadata', () async { + test('rejects invalid stateless operations', () async { final tempDir = await Directory.systemTemp.createTemp('client_harness_'); addTearDown(() => tempDir.delete(recursive: true)); final report = File('${tempDir.path}/report.json'); @@ -372,6 +456,19 @@ void main() { 'method': Method.tasksList, 'params': {'_meta': meta}, }), + ) + ..add( + jsonEncode({ + 'jsonrpc': jsonRpcVersion, + 'id': 4, + 'method': Method.toolsList, + 'params': { + '_meta': { + ...meta, + McpMetaKey.clientInfo: null, + }, + }, + }), ); await clientLines.close(); await runFuture; @@ -389,6 +486,16 @@ void main() { (removedMethodError['error'] as Map)['code'], ErrorCode.methodNotFound.value, ); + final invalidIdentityError = responses.singleWhere( + (response) => response['id'] == 4, + ); + expect( + invalidIdentityError['error'] as Map, + allOf( + containsPair('code', ErrorCode.invalidParams.value), + containsPair('message', contains(McpMetaKey.clientInfo)), + ), + ); final json = jsonDecode(await report.readAsString()) as Map; expect(json['passed'], isFalse); diff --git a/packages/mcp_dart_cli/test/src/inspect_server_command_test.dart b/packages/mcp_dart_cli/test/src/inspect_server_command_test.dart index c946c715..c2d4cafe 100644 --- a/packages/mcp_dart_cli/test/src/inspect_server_command_test.dart +++ b/packages/mcp_dart_cli/test/src/inspect_server_command_test.dart @@ -140,6 +140,54 @@ void main() { }); group('McpServerInspector', () { + test( + 'stateless fixture stamps identity on every successful result', + () async { + final process = await Process.start( + Platform.resolvedExecutable, + [ + 'run', + 'test/fixtures/stateless_inventory_server.dart', + ], + ); + final responsesFuture = + process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .take(2) + .map((line) => jsonDecode(line) as Map) + .toList(); + + for (final request in >[ + { + 'jsonrpc': '2.0', + 'id': 1, + 'method': Method.serverDiscover, + }, + { + 'jsonrpc': '2.0', + 'id': 2, + 'method': Method.toolsList, + }, + ]) { + process.stdin.writeln(jsonEncode(request)); + } + await process.stdin.close(); + + final responses = await responsesFuture; + expect(await process.exitCode, isZero); + expect(responses, hasLength(2)); + for (final response in responses) { + final result = (response['result'] as Map).cast(); + final meta = (result['_meta'] as Map).cast(); + expect(meta[McpMetaKey.serverInfo], { + 'name': 'stateless-inventory-fixture', + 'version': '1.0.0', + }); + } + }, + ); + test('normalizes and orders path issuer metadata probes like the SDK', () { final inspector = McpServerInspector(logger: MockLogger()); @@ -233,6 +281,29 @@ void main() { expect(report.inventory, isNot(contains('toolCalls'))); }); + test('accepts anonymous 2026 server identity', () async { + final report = await McpServerInspector(logger: MockLogger()).inspect( + const ServerInspectionTarget( + command: 'dart', + serverArgs: [ + 'run', + 'test/fixtures/stateless_inventory_server.dart', + '--anonymous', + ], + url: null, + env: {}, + ), + ); + + expect(report.passed, isTrue); + expect(report.metadata, isNot(contains('serverInfo'))); + final serverInfoCheck = report.checks.singleWhere( + (check) => check.id == 'lifecycle.server-info', + ); + expect(serverInfoCheck.status, 'info'); + expect(serverInfoCheck.message, contains('optional')); + }); + test( 'fails configured tool output schema checks on invalid output', () async { diff --git a/test/conformance/2026_07_28_expected_failures.txt b/test/conformance/2026_07_28_expected_failures.txt index e5a4c2b9..a569e9c5 100644 --- a/test/conformance/2026_07_28_expected_failures.txt +++ b/test/conformance/2026_07_28_expected_failures.txt @@ -1,9 +1,10 @@ # Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.9 # against the full 2026-07-28/DRAFT server suite. # -# The alpha.9 server suite has no expected failures against the Dart fixture. +# Keep this list scenario-based so the baseline is easy to review. The runner +# fails on an unexpected pass, forcing stale entries to be removed. # -# Keep this list scenario-based so the baseline is easy to review. When a -# scenario turns green, remove it from this file in the same PR as the fix. -# -# No expected server failures are currently tracked. +# alpha.9 predates spec PR #3002 / conformance PR #403. Its server-stateless +# scenario still rejects omitted clientInfo and requires body +# DiscoverResult.serverInfo instead of result _meta server identity. +server-stateless diff --git a/test/conformance/README.md b/test/conformance/README.md index 68fea15a..3ca65d75 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -63,8 +63,12 @@ and `--spec-version 2026-07-28`, and writes per-run artifacts under Expected failures live in `2026_07_28_expected_failures.txt`. When a scenario is fixed, remove it from that file so the baseline remains useful. -As of `@modelcontextprotocol/conformance@0.2.0-alpha.9`, the full MCP 2026-07-28 -server suite has no expected failures against the Dart fixture. +As of `@modelcontextprotocol/conformance@0.2.0-alpha.9`, the +`server-stateless` scenario is expected to fail because that published referee +predates spec PR #3002: it still requires request `clientInfo` and body +`DiscoverResult.serverInfo`. The checked-in expected-failure entry must be +removed when a published conformance package includes PR #403; latest-main +verification is run separately against the final metadata shape. Run the current client baseline from the repository root: diff --git a/test/interop/python_2026_07_28/README.md b/test/interop/python_2026_07_28/README.md index 56aa8d3e..9ed825f3 100644 --- a/test/interop/python_2026_07_28/README.md +++ b/test/interop/python_2026_07_28/README.md @@ -1,9 +1,10 @@ # Python SDK 2026-07-28 Interop -This fixture verifies the MCP `2026-07-28` path in both directions -against the official Python SDK `mcp==2.0.0b1` package. It is separate from the -stable Python fixture, which continues to cover the released MCP 2025-11-25 -specification. +This fixture tracks both MCP `2026-07-28` directions against the official +Python SDK `mcp==2.0.0b1` package: Dart client -> Python server remains a +required compatible path, while Python client -> Dart server records the +package's pre-spec-#3002 discovery gap. It is separate from the stable Python +fixture, which continues to cover the released MCP 2025-11-25 specification. ## Run @@ -14,9 +15,16 @@ python3 -m venv .dart_tool/python-2026-interop .dart_tool/python-2026-interop/bin/python -m pip install \ -r test/interop/python_2026_07_28/requirements.txt MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ - dart run tool/testing/run_python_2026_07_28_interop.dart + dart run tool/testing/run_python_2026_07_28_interop.dart \ + --direction=dart-to-python +MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ + dart run tool/testing/run_python_2026_07_28_interop.dart \ + --direction=python-to-dart \ + --expect-published-python-client-gap ``` -The runner checks Python client -> Dart server negotiation, `tools/list`, and -`tools/call`, then checks Dart client -> Python server discovery, tool listing, -and tool execution. Both paths must negotiate MCP 2026-07-28. +The Dart client -> Python server direction remains required and checks +discovery, tool listing, and tool execution. The published Python beta client +predates spec PR #3002 and requires obsolete body `serverInfo`, so the reverse +direction asserts its exact 2026 -> 2025 fallback as a temporary expected gap. +The expected-gap command fails if the beta starts passing or fails differently. diff --git a/test/interop/ts_2026_07_28/README.md b/test/interop/ts_2026_07_28/README.md index d49787e5..3b974dc8 100644 --- a/test/interop/ts_2026_07_28/README.md +++ b/test/interop/ts_2026_07_28/README.md @@ -6,10 +6,10 @@ path against the official TypeScript SDK work in progress. It is intentionally separate from `test/interop/ts`, which tracks the published stable TypeScript SDK and MCP 2025-11-25 behavior. The fixture pins published `@modelcontextprotocol/client@2.0.0-beta.4` and -`@modelcontextprotocol/server@2.0.0-beta.4` packages. The TypeScript client path -is a draft-aligned smoke check against the Dart MCP 2026-07-28 server. The -reverse Dart client path is a draft-aligned smoke check against the TypeScript -beta server. +`@modelcontextprotocol/server@2.0.0-beta.4` packages. Dart client to TypeScript +server passes against that pin. The published TypeScript client predates spec +PR #3002, so its reverse direction records the exact negotiation gap while a +TypeScript SDK #2513 preview is used for forward-looking bidirectional checks. ## Run @@ -19,11 +19,37 @@ From the repository root: cd test/interop/ts_2026_07_28 npm ci cd ../../.. -dart run tool/testing/run_ts_2026_07_28_interop.dart +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=dart-to-ts +dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=ts-to-dart \ + --expect-published-ts-client-gap ``` -The runner starts `test/conformance/mcp_2026_07_28_server.dart`, waits for its -bound local URL, and then runs `src/client.mjs` against it. The fixture asserts: +The published-beta reverse path first probes +`test/conformance/mcp_2026_07_28_server.dart` directly and requires +`server/discover` to advertise `2026-07-28`, omit body `serverInfo`, and expose +identity in `_meta["io.modelcontextprotocol/serverInfo"]`. It then accepts only the +known beta.4 `ERA_NEGOTIATION_FAILED` message. An unexpected pass or any other +failure is an error. + +Install the TypeScript SDK #2513 preview artifact without changing the checked-in +beta.4 pin, then run the reverse path without the expected-gap flag: + +```bash +( + fixture=test/interop/ts_2026_07_28 + trap 'npm --prefix "$fixture" ci' EXIT + npm --prefix "$fixture" install --no-save --package-lock=false \ + https://pkg.pr.new/@modelcontextprotocol/client@2513 + dart run tool/testing/run_ts_2026_07_28_interop.dart \ + --direction=ts-to-dart +) +``` + +The `EXIT` trap restores the published beta.4 fixture even if the preview run +fails. With the preview, +`src/client.mjs` asserts: - TypeScript client negotiation selects MCP 2026-07-28. - `server/discover` advertises MCP 2026-07-28 and exposes cache metadata through @@ -54,9 +80,9 @@ bound local URL, and then runs `src/client.mjs` against it. The fixture asserts: server observes cancellation without `notifications/cancelled`, and a follow-up status call succeeds. -The runner also starts `src/server.mjs` with the TypeScript beta +The Dart-to-TypeScript direction starts `src/server.mjs` with the TypeScript beta `createMcpHandler` entry and runs a Dart stable-profile client against it. That -reverse path asserts `server/discover` negotiation, `tools/list`, `tools/call`, +direction asserts `server/discover` negotiation, `tools/list`, `tools/call`, a one-time `HeaderMismatch` recovery that refreshes `tools/list` before retrying with the discovered `Mcp-Param-*` header, an MCP 2026-07-28 `input_required` elicitation retry, request-stream cancellation observed through the TypeScript @@ -72,5 +98,7 @@ assertion source and document the beta gap near the test. CI runs this fixture in the dedicated `Run MCP 2026-07-28 Interop` workflow for relevant PRs, daily scheduled drift checks, and manual dispatch. Keep the fixture pinned to a published TypeScript SDK beta that exposes the -MCP 2026-07-28 draft path and passes this runner; do not treat package -publication alone as enough to re-pin without rerunning the interop check. +MCP 2026-07-28 draft path. Do not restore obsolete Dart body output to make an +older peer pass, and do not treat package publication alone as enough to re-pin +without rerunning both explicit directions and removing stale expected-gap +handling. diff --git a/test/interop/ts_2026_07_28/src/client.mjs b/test/interop/ts_2026_07_28/src/client.mjs index 1dbf3aa4..626625aa 100644 --- a/test/interop/ts_2026_07_28/src/client.mjs +++ b/test/interop/ts_2026_07_28/src/client.mjs @@ -1,6 +1,7 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const PROTOCOL_VERSION = '2026-07-28'; +const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo'; const CLIENT_INFO = { name: 'mcp-dart-ts-2026-07-28-client', version: '0.0.0' }; function readArg(args, name) { @@ -507,9 +508,18 @@ async function main() { discover.supportedVersions?.includes(PROTOCOL_VERSION), `server/discover did not advertise ${PROTOCOL_VERSION}: ${JSON.stringify(discover)}`, ); + const discoverServerInfo = discover._meta?.[SERVER_INFO_META_KEY]; assert( - discover.serverInfo?.name === 'dart-test-server', - `server/discover returned unexpected serverInfo: ${JSON.stringify(discover.serverInfo)}`, + discoverServerInfo?.name === 'dart-test-server', + `server/discover returned unexpected result metadata serverInfo: ${JSON.stringify(discoverServerInfo)}`, + ); + assert( + !Object.hasOwn(discover, 'serverInfo'), + `server/discover must not return obsolete body serverInfo: ${JSON.stringify(discover)}`, + ); + assert( + client.getServerVersion()?.name === 'dart-test-server', + `client did not retain result metadata serverInfo: ${JSON.stringify(client.getServerVersion())}`, ); const tools = await client.listTools(); diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 3e8168f5..3736e53b 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -101,6 +101,8 @@ class DiscoveringClientTransport extends Transport this.capabilities = const ServerCapabilities( tools: ServerCapabilitiesTools(), ), + this.discoverServerInfo = + const Implementation(name: 'server', version: '1.0.0'), this.toolsListResult = const { 'resultType': resultTypeComplete, 'tools': [], @@ -114,6 +116,7 @@ class DiscoveringClientTransport extends Transport final List unsupportedDiscoverProtocolVersions; final Object? unsupportedDiscoverData; final ServerCapabilities capabilities; + final Implementation? discoverServerInfo; final Map toolsListResult; final void Function(JsonRpcRequest request)? onRequest; final List sentMessages = []; @@ -162,7 +165,7 @@ class DiscoveringClientTransport extends Transport result: DiscoverResult( supportedVersions: discoverVersions, capabilities: capabilities, - serverInfo: const Implementation(name: 'server', version: '1.0.0'), + serverInfo: discoverServerInfo, ttlMs: 0, cacheScope: CacheScope.private, ).toJson(), @@ -464,6 +467,54 @@ void main() { expect(meta[McpMetaKey.logLevel], 'debug'); }); + test('builds stateless request metadata without client identity', () { + final meta = buildProtocolRequestMeta( + protocolVersion: previewProtocolVersion, + clientCapabilities: const ClientCapabilities(), + ); + + expect(meta[McpMetaKey.protocolVersion], previewProtocolVersion); + expect(meta[McpMetaKey.clientCapabilities], {}); + expect(meta, isNot(contains(McpMetaKey.clientInfo))); + }); + + test('response serialization preserves and merges result metadata', () { + const response = JsonRpcResponse( + id: 1, + result: { + '_meta': { + 'com.example/result': true, + 'com.example/shared': 'result', + }, + }, + meta: { + 'com.example/response': true, + 'com.example/shared': 'response', + }, + ); + + final result = response.toJson()['result'] as Map; + expect(result['_meta'], { + 'com.example/result': true, + 'com.example/response': true, + 'com.example/shared': 'response', + }); + + const emptyResponseMeta = JsonRpcResponse(id: 2, result: {}, meta: {}); + expect( + emptyResponseMeta.toJson()['result'], + containsPair('_meta', {}), + ); + const emptyResultMeta = JsonRpcResponse( + id: 3, + result: {'_meta': {}}, + ); + expect( + emptyResultMeta.toJson()['result'], + containsPair('_meta', {}), + ); + }); + test('rejects invalid 2026 request metadata keys during construction', () { for (final key in [ '/name', @@ -1217,15 +1268,86 @@ void main() { [previewProtocolVersion], ); expect(resultJson['capabilities'], {'tools': {}}); + expect(resultJson, isNot(contains('serverInfo'))); + expect(resultJson['_meta'], { + McpMetaKey.serverInfo: { + 'name': 'server', + 'version': '1.0.0', + }, + }); expect(resultJson['ttlMs'], 1000); expect(resultJson['cacheScope'], CacheScope.public); final parsedResult = DiscoverResult.fromJson(resultJson); + expect(parsedResult.serverInfo?.name, 'server'); expect( parsedResult.instructions, 'Use the tools.', ); expect(parsedResult.ttlMs, 1000); expect(parsedResult.cacheScope, CacheScope.public); + + final identityFreeResult = DiscoverResult.fromJson({ + 'resultType': resultTypeComplete, + 'supportedVersions': [previewProtocolVersion], + 'capabilities': {}, + }); + expect(identityFreeResult.serverInfo, isNull); + + final malformedIdentityResult = DiscoverResult.fromJson({ + 'resultType': resultTypeComplete, + 'supportedVersions': [previewProtocolVersion], + 'capabilities': {}, + '_meta': { + McpMetaKey.serverInfo: 'malformed', + 'com.example/trace': 'trace-1', + }, + }); + expect(malformedIdentityResult.serverInfo, isNull); + expect(malformedIdentityResult.toJson()['_meta'], { + 'com.example/trace': 'trace-1', + }); + + final malformedMetadataDoesNotUseLegacyBody = DiscoverResult.fromJson({ + 'resultType': resultTypeComplete, + 'supportedVersions': [previewProtocolVersion], + 'capabilities': {}, + '_meta': {McpMetaKey.serverInfo: 'malformed'}, + 'serverInfo': {'name': 'legacy-server', 'version': '1.0.0'}, + }); + expect(malformedMetadataDoesNotUseLegacyBody.serverInfo, isNull); + + final explicitEmptyMeta = const DiscoverResult( + supportedVersions: [previewProtocolVersion], + capabilities: ServerCapabilities(), + meta: {}, + ).toJson(); + expect(explicitEmptyMeta, containsPair('_meta', {})); + + // Temporary compatibility for the pinned TypeScript and Python beta + // fixtures. Remove once corrected SDK releases are pinned. + final legacyIdentity = DiscoverResult.fromJson({ + 'resultType': resultTypeComplete, + 'supportedVersions': [previewProtocolVersion], + 'capabilities': {}, + 'serverInfo': {'name': 'legacy-server', 'version': '1.0.0'}, + }); + expect(legacyIdentity.serverInfo?.name, 'legacy-server'); + + final handlerIdentity = const DiscoverResult( + supportedVersions: [previewProtocolVersion], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'configured', version: '1.0.0'), + meta: { + McpMetaKey.serverInfo: { + 'name': 'handler', + 'version': '2.0.0', + }, + }, + ).toJson(); + expect(handlerIdentity['_meta'][McpMetaKey.serverInfo], { + 'name': 'handler', + 'version': '2.0.0', + }); }); test('stateless metadata omits legacy task capabilities', () { @@ -1315,10 +1437,6 @@ void main() { ...result, 'capabilities': 'bad', }), - () => DiscoverResult.fromJson({ - ...result, - 'serverInfo': 'bad', - }), () => DiscoverResult.fromJson({ ...result, 'instructions': 1, @@ -1340,6 +1458,11 @@ void main() { ]) { expect(parse, throwsFormatException); } + + expect( + DiscoverResult.fromJson({...result, 'serverInfo': 'bad'}).serverInfo, + isNull, + ); }); test('requires complete resultType on server/discover results', () { @@ -2529,7 +2652,12 @@ void main() { ), ), ); - return const EmptyResult(); + return const EmptyResult( + meta: { + 'com.example/trace': 'subscription-trace', + McpMetaKey.subscriptionId: 'handler-value', + }, + ); }, (id, params, meta) => JsonRpcSubscriptionsListenRequest( id: id, @@ -2572,8 +2700,12 @@ void main() { ); final response = transport.sentMessages.last as JsonRpcResponse; expect(response.result['_meta'], { + 'com.example/trace': 'subscription-trace', McpMetaKey.subscriptionId: 'sub-1', + McpMetaKey.serverInfo: {'name': 'server', 'version': '1.0.0'}, }); + final wireResult = response.toJson()['result'] as Map; + expect(wireResult['_meta'], response.result['_meta']); }); test('server rejects subscription notifications before acknowledgment', @@ -2792,6 +2924,14 @@ void main() { expect(tools['ttlMs'], 300000); expect(tools['cacheScope'], CacheScope.public); + for (final response in responses) { + expect(response.result['_meta'], { + 'io.modelcontextprotocol/serverInfo': { + 'name': 'server', + 'version': '1.0.0', + }, + }); + } for (final response in responses.skip(1)) { expect(response.result['resultType'], resultTypeComplete); expect(response.result['ttlMs'], 0); @@ -2945,8 +3085,13 @@ void main() { expect(responses[0].result['taskId'], 'task-1'); expect(responses[0].result['ttlMs'], 60000); expect(responses[0].result, isNot(contains('ttl'))); - expect(responses[1].result, {'resultType': resultTypeComplete}); - expect(responses[2].result, {'resultType': resultTypeComplete}); + for (final response in responses.skip(1)) { + expect(response.result['resultType'], resultTypeComplete); + expect(response.result['_meta'][McpMetaKey.serverInfo], { + 'name': 'server', + 'version': '1.0.0', + }); + } }); test('server task store uses task extension results for stateless requests', @@ -3021,7 +3166,11 @@ void main() { expect(responses[0].result['result']['content'], [ {'type': 'text', 'text': 'done'}, ]); - expect(responses[1].result, {'resultType': resultTypeComplete}); + expect(responses[1].result['resultType'], resultTypeComplete); + expect(responses[1].result['_meta'][McpMetaKey.serverInfo], { + 'name': 'server', + 'version': '1.0.0', + }); expect( (await store.getTask(workingTask.taskId))?.status, TaskStatus.cancelled, @@ -4340,7 +4489,11 @@ void main() { response.result['supportedVersions'], contains(previewProtocolVersion), ); - expect(response.result['serverInfo']['name'], 'server'); + expect(response.result, isNot(contains('serverInfo'))); + expect(response.result['_meta'][McpMetaKey.serverInfo], { + 'name': 'server', + 'version': '1.0.0', + }); expect(response.result['instructions'], 'Discovery instructions.'); }); @@ -4384,12 +4537,186 @@ void main() { final response = transport.sentMessages.single as JsonRpcResponse; final tools = response.result['tools'] as List; expect(tools.single['name'], 'echo'); + expect(response.result['_meta'], { + McpMetaKey.serverInfo: { + 'name': 'server', + 'version': '1.0.0', + }, + }); expect(receivedProtocolVersion, previewProtocolVersion); expect(receivedClientInfo?.name, 'client'); expect(receivedClientInfo?.version, '1.0.0'); expect(receivedClientCapabilities?.toJson(), isEmpty); }); + test('server preserves handler result metadata and identity', () async { + final server = Server( + const Implementation(name: 'configured', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.require2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [], + meta: { + 'com.example/trace': 'trace-1', + McpMetaKey.serverInfo: { + 'name': 'handler', + 'version': '2.0.0', + }, + }, + ), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive(JsonRpcListToolsRequest(id: 1, meta: _clientMeta())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['_meta'], { + 'com.example/trace': 'trace-1', + McpMetaKey.serverInfo: { + 'name': 'handler', + 'version': '2.0.0', + }, + }); + final wireResult = response.toJson()['result'] as Map; + expect(wireResult['_meta'], response.result['_meta']); + await server.close(); + }); + + test('legacy initialize keeps body identity without result metadata', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions(protocol: McpProtocol.legacy), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + JsonRpcInitializeRequest( + id: 1, + initParams: const InitializeRequestParams( + protocolVersion: latestInitializationProtocolVersion, + capabilities: ClientCapabilities(), + clientInfo: Implementation(name: 'client', version: '1.0.0'), + ), + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['serverInfo'], { + 'name': 'server', + 'version': '1.0.0', + }); + expect(response.result, isNot(contains('_meta'))); + final wireResult = response.toJson()['result'] as Map; + expect(wireResult, isNot(contains('_meta'))); + await server.close(); + }); + + test('server accepts stateless requests without client identity', () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.require2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + RequestHandlerExtra? receivedExtra; + server.setRequestHandler( + Method.toolsList, + (request, extra) async { + receivedExtra = extra; + return const ListToolsResult(tools: []); + }, + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive( + const JsonRpcListToolsRequest( + id: 'anonymous-client', + meta: { + McpMetaKey.protocolVersion: previewProtocolVersion, + McpMetaKey.clientCapabilities: {}, + }, + ), + ); + await _pump(); + + expect(transport.sentMessages.single, isA()); + expect(receivedExtra?.clientInfo, isNull); + expect(receivedExtra?.clientCapabilities?.toJson(), isEmpty); + await server.close(); + }); + + test('server rejects explicitly invalid stateless client identity metadata', + () async { + final server = Server( + const Implementation(name: 'server', version: '1.0.0'), + options: const McpServerOptions(protocol: McpProtocol.require2026), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + for (final clientInfo in [ + null, + 'not-an-object', + {'name': 'missing-version'}, + ]) { + transport.receive( + JsonRpcListToolsRequest( + id: 'invalid-${clientInfo.runtimeType}', + meta: { + McpMetaKey.protocolVersion: previewProtocolVersion, + McpMetaKey.clientCapabilities: {}, + McpMetaKey.clientInfo: clientInfo, + }, + ), + ); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + transport.sentMessages.clear(); + } + + transport.receive( + const JsonRpcListToolsRequest( + id: 'missing-capabilities', + meta: { + McpMetaKey.protocolVersion: previewProtocolVersion, + }, + ), + ); + await _pump(); + final response = transport.sentMessages.single as JsonRpcError; + expect(response.error.code, ErrorCode.invalidParams.value); + expect(response.error.message, contains(McpMetaKey.clientCapabilities)); + await server.close(); + }); + test('stateless handlers receive request-local client metadata', () async { final server = Server( const Implementation(name: 'server', version: '1.0.0'), @@ -4730,11 +5057,7 @@ void main() { McpMetaKey.protocolVersion: previewProtocolVersion, McpMetaKey.clientCapabilities: {}, }), - isA().having( - (error) => error.message, - 'message', - contains(McpMetaKey.clientInfo), - ), + isNull, ); expect( validateToolRequest({ @@ -5060,6 +5383,19 @@ void main() { expect(listRequest.meta?[McpMetaKey.clientCapabilities], {}); }); + test('client discovery succeeds without server identity', () async { + final transport = DiscoveringClientTransport(discoverServerInfo: null); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions(protocol: McpProtocol.require2026), + ); + + await client.connect(transport); + + expect(client.getProtocolVersion(), previewProtocolVersion); + expect(client.getServerVersion(), isNull); + }); + test('stateless client rejects legacy task request options before send', () async { final transport = DiscoveringClientTransport(); diff --git a/test/tool/run_python_2026_07_28_interop_test.dart b/test/tool/run_python_2026_07_28_interop_test.dart new file mode 100644 index 00000000..a72425f0 --- /dev/null +++ b/test/tool/run_python_2026_07_28_interop_test.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + test('published Python gap flag requires the Python client direction', + () async { + final result = await Process.run( + Platform.resolvedExecutable, + [ + 'run', + 'tool/testing/run_python_2026_07_28_interop.dart', + '--direction=dart-to-python', + '--expect-published-python-client-gap', + ], + workingDirectory: Directory.current.path, + ); + + expect(result.exitCode, 64); + expect( + result.stderr, + contains( + '--expect-published-python-client-gap requires the python-to-dart direction', + ), + ); + expect(result.stdout, isNot(contains('[dart-server]'))); + }); +} diff --git a/test/tool/run_ts_2026_07_28_interop_test.dart b/test/tool/run_ts_2026_07_28_interop_test.dart new file mode 100644 index 00000000..54b17aa5 --- /dev/null +++ b/test/tool/run_ts_2026_07_28_interop_test.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:test/test.dart'; + +void main() { + test('published TypeScript gap flag requires the TS client direction', + () async { + final result = await Process.run( + Platform.resolvedExecutable, + [ + 'run', + 'tool/testing/run_ts_2026_07_28_interop.dart', + '--direction=dart-to-ts', + '--expect-published-ts-client-gap', + ], + workingDirectory: Directory.current.path, + ); + + expect(result.exitCode, 64); + expect( + result.stderr, + contains( + '--expect-published-ts-client-gap requires the ts-to-dart direction', + ), + ); + }); +} diff --git a/test/types_test.dart b/test/types_test.dart index 2ea08c5b..6ed85945 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -373,7 +373,9 @@ void main() { 'resultType': resultTypeComplete, 'supportedVersions': [previewProtocolVersion], 'capabilities': {}, - 'serverInfo': {'name': 'server', 'version': '1.0.0'}, + '_meta': { + McpMetaKey.serverInfo: {'name': 'server', 'version': '1.0.0'}, + }, }; for (final parse in [ @@ -401,10 +403,6 @@ void main() { ...discoverResult, 'capabilities': 'bad', }), - () => DiscoverResult.fromJson({ - ...discoverResult, - 'serverInfo': 'bad', - }), () => DiscoverResult.fromJson({ ...discoverResult, 'instructions': 1, @@ -412,6 +410,14 @@ void main() { ]) { expect(parse, throwsA(isA())); } + + expect( + DiscoverResult.fromJson({ + ...discoverResult, + '_meta': {McpMetaKey.serverInfo: 'bad'}, + }).serverInfo, + isNull, + ); }); }); diff --git a/tool/testing/run_python_2026_07_28_interop.dart b/tool/testing/run_python_2026_07_28_interop.dart index 14ff64a5..c2e3d8bf 100644 --- a/tool/testing/run_python_2026_07_28_interop.dart +++ b/tool/testing/run_python_2026_07_28_interop.dart @@ -18,16 +18,80 @@ Future main(List args) async { return; } + final direction = args + .where((argument) => argument.startsWith('--direction=')) + .map((argument) => argument.substring('--direction='.length)) + .firstOrNull; + if (direction != null && + direction != 'all' && + direction != 'dart-to-python' && + direction != 'python-to-dart') { + stderr.writeln( + 'Invalid --direction. Use all, dart-to-python, or python-to-dart.', + ); + exitCode = 64; + return; + } + final selectedDirection = direction ?? 'all'; + final expectPublishedPythonGap = + args.contains('--expect-published-python-client-gap'); + if (expectPublishedPythonGap && selectedDirection == 'dart-to-python') { + stderr.writeln( + '--expect-published-python-client-gap requires the python-to-dart ' + 'direction (or all).', + ); + exitCode = 64; + return; + } + try { - await _runPythonClientAgainstDartServer(repoRoot, fixtureDir, python); - await _runDartClientAgainstPythonServer(repoRoot, fixtureDir, python); + if (selectedDirection != 'dart-to-python') { + final result = + await _runPythonClientAgainstDartServer(repoRoot, fixtureDir, python); + if (expectPublishedPythonGap) { + final isExpectedGap = result.exitCode != 0 && + result.output.contains( + 'Expected protocol 2026-07-28, got 2025-11-25', + ); + if (!isExpectedGap) { + if (result.exitCode == 0) { + throw StateError( + 'Published Python client unexpectedly passed; remove the ' + 'temporary spec #3002 expected-gap handling.', + ); + } + throw StateError( + 'Python client failed for an unexpected reason ' + '(exit ${result.exitCode}).', + ); + } + stdout.writeln( + '[expected-gap] Published Python beta client predates spec #3002; ' + 'remove this expectation after its 2026 schema is updated.', + ); + } else if (result.exitCode != 0) { + throw StateError( + 'Python MCP 2026-07-28 client exited with ${result.exitCode}', + ); + } + } + if (selectedDirection != 'python-to-dart') { + await _runDartClientAgainstPythonServer(repoRoot, fixtureDir, python); + } } on Object catch (error) { stderr.writeln('Python 2026-07-28 interop failed: $error'); exitCode = 1; } } -Future _runPythonClientAgainstDartServer( +class _PythonClientRun { + const _PythonClientRun(this.exitCode, this.output); + + final int exitCode; + final String output; +} + +Future<_PythonClientRun> _runPythonClientAgainstDartServer( Directory repoRoot, Directory fixtureDir, String python, @@ -57,6 +121,7 @@ Future _runPythonClientAgainstDartServer( ), ); final serverStderr = _pipeLines(server.stderr, stderr, '[dart-server]'); + late _PythonClientRun result; try { final url = await serverUrl.future.timeout( @@ -70,21 +135,35 @@ Future _runPythonClientAgainstDartServer( ['client.py', '--url', url], workingDirectory: fixtureDir.path, ); - final clientStdout = _pipeLines(client.stdout, stdout, '[python-client]'); - final clientStderr = _pipeLines(client.stderr, stderr, '[python-client]'); - final clientExit = await client.exitCode.timeout( - const Duration(seconds: 30), + final clientOutput = StringBuffer(); + final clientStdout = _pipeLines( + client.stdout, + stdout, + '[python-client]', + onLine: clientOutput.writeln, ); - await Future.wait([clientStdout, clientStderr]); - if (clientExit != 0) { - throw StateError( - 'Python MCP 2026-07-28 client exited with $clientExit', + final clientStderr = _pipeLines( + client.stderr, + stderr, + '[python-client]', + onLine: clientOutput.writeln, + ); + late int clientExit; + try { + clientExit = await client.exitCode.timeout( + const Duration(seconds: 30), ); + } finally { + await _terminate(client); + await Future.wait([clientStdout, clientStderr]); } + result = _PythonClientRun(clientExit, clientOutput.toString()); } finally { await _terminate(server); await Future.wait([serverStdout, serverStderr]); } + + return result; } Future _runDartClientAgainstPythonServer( diff --git a/tool/testing/run_ts_2026_07_28_interop.dart b/tool/testing/run_ts_2026_07_28_interop.dart index c8d67fd9..1ba9a228 100644 --- a/tool/testing/run_ts_2026_07_28_interop.dart +++ b/tool/testing/run_ts_2026_07_28_interop.dart @@ -24,6 +24,32 @@ Future main(List args) async { return; } + final direction = args + .where((argument) => argument.startsWith('--direction=')) + .map((argument) => argument.substring('--direction='.length)) + .firstOrNull; + if (direction != null && + direction != 'all' && + direction != 'dart-to-ts' && + direction != 'ts-to-dart') { + stderr.writeln( + 'Invalid --direction. Use all, dart-to-ts, or ts-to-dart.', + ); + exitCode = 64; + return; + } + final selectedDirection = direction ?? 'all'; + final expectPublishedTsGap = + args.contains('--expect-published-ts-client-gap'); + if (expectPublishedTsGap && selectedDirection == 'dart-to-ts') { + stderr.writeln( + '--expect-published-ts-client-gap requires the ts-to-dart direction ' + '(or all).', + ); + exitCode = 64; + return; + } + if (!clientPackage.existsSync()) { stderr.writeln( 'Missing TypeScript fixture dependencies. Run:\n' @@ -44,15 +70,54 @@ Future main(List args) async { } try { - await _runTsClientAgainstDartServer(repoRoot, fixtureDir); - await _runDartClientAgainstTsServer(repoRoot, fixtureDir); + if (selectedDirection != 'ts-to-dart') { + await _runDartClientAgainstTsServer(repoRoot, fixtureDir); + } + if (selectedDirection != 'dart-to-ts') { + final result = await _runTsClientAgainstDartServer(repoRoot, fixtureDir); + if (expectPublishedTsGap) { + final isExpectedGap = result.exitCode != 0 && + result.output.contains('ERA_NEGOTIATION_FAILED') && + result.output.contains( + 'server did not offer pinned protocol version 2026-07-28 ' + 'via server/discover', + ); + if (!isExpectedGap) { + if (result.exitCode == 0) { + throw StateError( + 'Published TypeScript client unexpectedly passed; remove the ' + 'temporary #2513 expected-gap handling.', + ); + } + throw StateError( + 'TypeScript client failed for an unexpected reason ' + '(exit ${result.exitCode}).', + ); + } + stdout.writeln( + '[expected-gap] Published TypeScript beta client predates spec #3002; ' + 'remove this expectation after TypeScript SDK #2513 is released.', + ); + } else if (result.exitCode != 0) { + throw StateError( + 'TypeScript 2026-07-28 client exited with ${result.exitCode}', + ); + } + } } on Object catch (error) { stderr.writeln('TS 2026-07-28 interop failed: $error'); exitCode = 1; } } -Future _runTsClientAgainstDartServer( +class _TsClientRun { + const _TsClientRun(this.exitCode, this.output); + + final int exitCode; + final String output; +} + +Future<_TsClientRun> _runTsClientAgainstDartServer( Directory repoRoot, Directory fixtureDir, ) async { @@ -81,6 +146,7 @@ Future _runTsClientAgainstDartServer( ), ); final serverStderr = _pipeLines(server.stderr, stderr, '[dart-server]'); + late _TsClientRun result; try { final url = await serverUrl.future.timeout( @@ -90,27 +156,140 @@ Future _runTsClientAgainstDartServer( }, ); + await _assertDartDiscoveryWire(url); + final client = await Process.start( 'node', ['src/client.mjs', '--url', url], workingDirectory: fixtureDir.path, ); - final clientStdout = _pipeLines(client.stdout, stdout, '[ts-client]'); - final clientStderr = _pipeLines(client.stderr, stderr, '[ts-client]'); - final clientExit = await client.exitCode.timeout( - const Duration(seconds: 30), + final clientOutput = StringBuffer(); + final clientStdout = _pipeLines( + client.stdout, + stdout, + '[ts-client]', + onLine: clientOutput.writeln, ); - await Future.wait([clientStdout, clientStderr]); - - if (clientExit != 0) { - throw StateError( - 'TypeScript MCP 2026-07-28 client exited with $clientExit', + final clientStderr = _pipeLines( + client.stderr, + stderr, + '[ts-client]', + onLine: clientOutput.writeln, + ); + late int clientExit; + try { + clientExit = await client.exitCode.timeout( + const Duration(seconds: 30), ); + } finally { + await _terminate(client); + await Future.wait([clientStdout, clientStderr]); } + result = _TsClientRun(clientExit, clientOutput.toString()); } finally { await _terminate(server); await Future.wait([serverStdout, serverStderr]); } + + return result; +} + +Future _assertDartDiscoveryWire(String url) async { + final httpClient = HttpClient(); + try { + final request = await httpClient.postUrl(Uri.parse(url)); + request.headers.contentType = ContentType.json; + request.headers.set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ); + request.headers.set('MCP-Protocol-Version', previewProtocolVersion); + request.headers.set('Mcp-Method', Method.serverDiscover); + request.add( + utf8.encode( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': 'dart-discovery-wire-probe', + 'method': Method.serverDiscover, + 'params': { + '_meta': { + McpMetaKey.protocolVersion: previewProtocolVersion, + McpMetaKey.clientCapabilities: {}, + }, + }, + }), + ), + ); + + final response = await request.close().timeout( + const Duration(seconds: 20), + ); + final body = await response.transform(utf8.decoder).join(); + if (response.statusCode != HttpStatus.ok) { + throw StateError( + 'Dart server/discover wire probe returned HTTP ' + '${response.statusCode}: $body', + ); + } + + final envelope = _decodeJsonOrSse(body); + final result = envelope is Map ? envelope['result'] : null; + if (result is! Map) { + throw StateError( + 'Dart server/discover wire probe returned no result object: $body', + ); + } + final supportedVersions = result['supportedVersions']; + if (supportedVersions is! List || + !supportedVersions.contains(previewProtocolVersion)) { + throw StateError( + 'Dart server/discover did not advertise $previewProtocolVersion: ' + '$result', + ); + } + if (result.containsKey('serverInfo')) { + throw StateError( + 'Dart server/discover emitted obsolete body serverInfo: $result', + ); + } + final meta = result['_meta']; + final serverInfo = meta is Map ? meta[McpMetaKey.serverInfo] : null; + if (serverInfo is! Map || + serverInfo['name'] != 'dart-test-server' || + serverInfo['version'] != '1.0.0') { + throw StateError( + 'Dart server/discover omitted or malformed result metadata ' + 'serverInfo: $result', + ); + } + + stdout.writeln( + '[dart-server-probe] verified spec #3002 discovery wire shape', + ); + } finally { + httpClient.close(force: true); + } +} + +Object? _decodeJsonOrSse(String body) { + try { + return jsonDecode(body); + } on FormatException { + for (final line in const LineSplitter().convert(body)) { + if (!line.startsWith('data:')) continue; + final data = line.substring('data:'.length).trimLeft(); + if (data.isEmpty) continue; + try { + return jsonDecode(data); + } on FormatException { + continue; + } + } + throw FormatException( + 'Dart server/discover wire probe returned neither JSON nor JSON SSE data.', + body, + ); + } } Future _runDartClientAgainstTsServer( From f33564a3d7f67bf9738fb636051e156230bb3bc2 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Sun, 19 Jul 2026 21:58:56 -0400 Subject: [PATCH 2/3] fix: harden 2026 identity compliance --- .github/workflows/interop_2026_07_28.yml | 4 + CHANGELOG.md | 17 +- doc/interoperability.md | 2 +- doc/mcp-2026-07-28.md | 24 +- doc/spec-coverage-2026-07-28.md | 27 +- lib/src/server/server.dart | 29 +- lib/src/shared/protocol.dart | 25 +- lib/src/types/initialization.dart | 50 +-- lib/src/types/json_rpc.dart | 13 +- packages/mcp_dart_cli/CHANGELOG.md | 5 + .../lib/src/inspect_client_command.dart | 91 +++--- .../test/src/inspect_client_command_test.dart | 231 ++++++++++++++ .../2026_07_28_expected_failures.txt | 8 +- ...2026_07_28_post_3002_expected_failures.txt | 4 + test/conformance/README.md | 22 +- .../conformance_expected_failures.dart | 285 ++++++++++++++++++ .../conformance_expected_failures_test.dart | 180 +++++++++++ .../run_2026_07_28_server_conformance.dart | 133 ++++++-- test/interop/python_2026_07_28/README.md | 11 +- .../python_2026_07_28/requirements.txt | 2 +- test/mcp_2026_07_28_test.dart | 281 +++++++++++++++-- .../run_python_2026_07_28_interop_test.dart | 110 +++++++ test/tool/run_ts_2026_07_28_interop_test.dart | 62 ++++ test/types_test.dart | 6 +- tool/testing/bounded_response_body.dart | 52 ++++ .../mcp_2026_07_28_discovery_wire_probe.dart | 143 +++++++++ ...cp_2026_07_28_spec_document_inventory.json | 26 +- tool/testing/mcp_2026_07_28_spec_ref.txt | 2 +- .../run_python_2026_07_28_interop.dart | 7 + tool/testing/run_ts_2026_07_28_interop.dart | 105 +------ 30 files changed, 1692 insertions(+), 265 deletions(-) create mode 100644 test/conformance/2026_07_28_post_3002_expected_failures.txt create mode 100644 test/conformance/conformance_expected_failures.dart create mode 100644 test/conformance/conformance_expected_failures_test.dart create mode 100644 tool/testing/bounded_response_body.dart create mode 100644 tool/testing/mcp_2026_07_28_discovery_wire_probe.dart diff --git a/.github/workflows/interop_2026_07_28.yml b/.github/workflows/interop_2026_07_28.yml index ec8f9e5e..ab7af1d1 100644 --- a/.github/workflows/interop_2026_07_28.yml +++ b/.github/workflows/interop_2026_07_28.yml @@ -20,6 +20,8 @@ on: - 'tool/testing/run_ts_2026_07_28_interop.dart' - 'tool/testing/run_python_2026_07_28_interop.dart' - 'tool/testing/run_browser_2026_07_28_interop.dart' + - 'tool/testing/bounded_response_body.dart' + - 'tool/testing/mcp_2026_07_28_discovery_wire_probe.dart' - '.github/workflows/interop_2026_07_28.yml' - 'pubspec.yaml' - 'pubspec.lock' @@ -34,6 +36,8 @@ on: - 'tool/testing/run_ts_2026_07_28_interop.dart' - 'tool/testing/run_python_2026_07_28_interop.dart' - 'tool/testing/run_browser_2026_07_28_interop.dart' + - 'tool/testing/bounded_response_body.dart' + - 'tool/testing/mcp_2026_07_28_discovery_wire_probe.dart' - '.github/workflows/interop_2026_07_28.yml' - 'pubspec.yaml' - 'pubspec.lock' diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8c7ed8..4e68107d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,16 @@ ### Changed -- Aligned MCP `2026-07-28` identity with spec PR #3002: request - `clientInfo` is optional, servers stamp their identity by default in successful - stateless result `_meta["io.modelcontextprotocol/serverInfo"]` while preserving - handler-authored metadata, and - `server/discover` no longer emits body `serverInfo`. `DiscoverResult.serverInfo` - is now nullable, so callers must handle anonymous discovery. MCP `2025-11-25` - initialization remains unchanged. +- Aligned MCP `2026-07-28` identity with spec PR #3002: client identity is + optional, and servers stamp validated identity in successful stateless result + `_meta` by default instead of the discovery body. A handler `null` omits the + optional key; received canonical `null` or malformed identities are rejected. + +### Breaking and compatibility notes + +- `DiscoverResult.serverInfo` is now nullable because MCP `2026-07-28` permits + anonymous servers. Check for `null` before reading identity fields. MCP + `2025-11-25` behavior is unchanged. ## 2.3.0-dev.2 diff --git a/doc/interoperability.md b/doc/interoperability.md index fba7ac27..1d5057d4 100644 --- a/doc/interoperability.md +++ b/doc/interoperability.md @@ -29,7 +29,7 @@ For MCP 2026-07-28 coverage, see the | TypeScript SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/ts_2026_07_28/`](../test/interop/ts_2026_07_28/), [`tool/testing/run_ts_2026_07_28_interop.dart`](../tool/testing/run_ts_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Known published-beta gap | Published `@modelcontextprotocol/client@2.0.0-beta.4` predates spec PR #3002 and rejects discovery without obsolete body `serverInfo`. CI asserts the exact negotiation failure as temporary expected drift; a TypeScript SDK PR #2513 preview validates the final result metadata identity shape. | | Dart MCP 2026-07-28 client -> TypeScript SDK beta server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/ts_2026_07_28/src/server.mjs`](../test/interop/ts_2026_07_28/src/server.mjs), [`tool/testing/run_ts_2026_07_28_interop.dart`](../tool/testing/run_ts_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses the published TypeScript SDK beta server through its `createMcpHandler` entry and a temporary read-only fallback for legacy body `serverInfo`; covers `server/discover`, `tools/list`, `tools/call`, one-time `HeaderMismatch` metadata refresh and retry, MCP 2026-07-28 `input_required`, request-stream cancellation, and post-cancellation recovery. | | Dart client -> Python MCP server | stdio | Server-dependent | [`doc/transports.md`](transports.md#connect-to-python-server) | Documented recipe | The transport can spawn Python servers over stdio; the stable recipe remains separate from the MCP 2026-07-28 beta fixture. | -| Python SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/`](../test/interop/python_2026_07_28/), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Known published-beta gap | Published `mcp==2.0.0b1` predates spec PR #3002 and falls back to MCP 2025-11-25 when canonical discovery omits obsolete body `serverInfo`. CI asserts that exact fallback as temporary expected drift. | +| Python SDK beta client -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/`](../test/interop/python_2026_07_28/), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Known published-beta gap | Published `mcp==2.0.0b2` predates spec PR #3002 and falls back to MCP 2025-11-25 when canonical discovery omits obsolete body `serverInfo`. CI asserts that exact fallback as temporary expected drift. | | Dart MCP 2026-07-28 client -> Python SDK beta server | Streamable HTTP | MCP 2026-07-28 | [`test/interop/python_2026_07_28/server.py`](../test/interop/python_2026_07_28/server.py), [`tool/testing/run_python_2026_07_28_interop.dart`](../tool/testing/run_python_2026_07_28_interop.dart), [`interop_2026_07_28.yml`](../.github/workflows/interop_2026_07_28.yml) | Verified | Uses the official Python SDK beta server through the temporary read-only fallback for legacy body `serverInfo`; covers discovery, protocol selection, `tools/list`, and `tools/call`. | | Dart browser client -> Dart server | Streamable HTTP | MCP 2025-11-25 and MCP 2026-07-28 | [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart), [`tool/testing/run_browser_2026_07_28_interop.dart`](../tool/testing/run_browser_2026_07_28_interop.dart) | Verified | A real Chrome client completes 12 tool-list requests and 12 tool calls in each profile over cross-origin Streamable HTTP. It also proves MCP 2026-07-28 request-stream cancellation and recovery. The MCP 2025-11-25 case waits for response-stream reconnect timers and guards against browser connection-slot exhaustion. | | Flutter Web example -> Dart server | Streamable HTTP | MCP 2026-07-28 | [`example/flutter_http_client/test/browser_e2e_test.dart`](../example/flutter_http_client/test/browser_e2e_test.dart), [`tool/testing/run_flutter_web_example_e2e.dart`](../tool/testing/run_flutter_web_example_e2e.dart), [`test_core.yml`](../.github/workflows/test_core.yml) | Verified | The example's real service layer runs in Chrome and completes connection, 12 tool-list requests, 12 tool calls, expected RPC-error recovery, reconnect, a post-reconnect request, and disconnect. Deterministic widget tests cover the UI separately. Flutter Web cannot spawn stdio servers. | diff --git a/doc/mcp-2026-07-28.md b/doc/mcp-2026-07-28.md index 7ac4cb8c..466e4144 100644 --- a/doc/mcp-2026-07-28.md +++ b/doc/mcp-2026-07-28.md @@ -72,12 +72,26 @@ MCP `2026-07-28` carries peer identity in stateless metadata: reject a value that is present but is not a valid `Implementation` object. - `McpServer` stamps its configured identity in `_meta["io.modelcontextprotocol/serverInfo"]` on successful stateless results - by default; a handler-authored value at that key wins. `server/discover` no - longer emits a body `serverInfo` field. + by default. A valid handler-authored `Implementation` value at that key wins; + `null` configures an anonymous result by omitting the key, while malformed + non-null values fail before serialization. `server/discover` no longer emits + a body `serverInfo` field. - `DiscoverResult.serverInfo` is nullable and reads result metadata for - convenience. Missing or malformed optional identity is anonymous; - applications must not use this self-reported display/debug field for security - or protocol behavior. + convenience. Missing canonical identity is anonymous; a present malformed or + `null` canonical identity is rejected. The temporary pre-#3002 body fallback + ignores malformed legacy identity. Applications must not use self-reported + identity for security or protocol behavior. + +Code that previously dereferenced discovery identity must now handle an +anonymous server: + +```dart +final discovery = await client.discoverServer(); +final serverInfo = discovery.serverInfo; +if (serverInfo != null) { + print('${serverInfo.name} ${serverInfo.version}'); +} +``` The parser temporarily accepts the pre-spec top-level discovery identity sent by published TypeScript and Python SDK beta releases. Dart never emits that legacy diff --git a/doc/spec-coverage-2026-07-28.md b/doc/spec-coverage-2026-07-28.md index 65cae522..ea4b58a7 100644 --- a/doc/spec-coverage-2026-07-28.md +++ b/doc/spec-coverage-2026-07-28.md @@ -34,6 +34,19 @@ dart run test/conformance/run_2026_07_28_server_conformance.dart dart run test/conformance/run_2026_07_28_client_conformance.dart ``` +The published conformance alpha predates spec PR #3002. Reproduce the corrected +stateless identity check against the immutable merged conformance PR #403 +source with: + +```bash +dart run test/conformance/run_2026_07_28_server_conformance.dart \ + --scenario server-stateless \ + --conformance-package \ + github:modelcontextprotocol/conformance#d1c0b9591786726d8a4bec05306eb103ba6894ff \ + --expected-failures \ + test/conformance/2026_07_28_post_3002_expected_failures.txt +``` + Until the final tag exists, CI checks out the commit pinned in [`tool/testing/mcp_2026_07_28_spec_ref.txt`](../tool/testing/mcp_2026_07_28_spec_ref.txt). It parses all 128 machine-readable examples and inventories all 31 official @@ -41,7 +54,7 @@ draft specification documents against checked-in scope, evidence, and normalized SHA-256 content hashes. Any prose change at a new pinned revision fails the inventory until that document is explicitly reviewed and its hash is updated. The dev.2 matrix was last reviewed against upstream commit -[`a50ba9a`](https://github.com/modelcontextprotocol/modelcontextprotocol/tree/a50ba9af0896c518dff323a6b2d80376cd59c47c). +[`71e3069`](https://github.com/modelcontextprotocol/modelcontextprotocol/tree/71e306956a4959c9655e5036be215d41986596e6). The readable source links below remain mutable; the immutable commit keeps a moving draft from changing the evidence underneath a green run. @@ -108,13 +121,13 @@ schedule. | --- | --- | --- | --- | --- | --- | --- | | Default stable profile with legacy opt-out | [Versioning and compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) | The dev.2 preview defaults to `McpProtocol.stable`, while callers can explicitly select `McpProtocol.legacy` or `McpProtocol.require2026`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`doc/mcp-2026-07-28.md`](mcp-2026-07-28.md) | TypeScript SDK beta interop covers explicit MCP 2026-07-28 negotiation; local tests cover the default-option path. | MCP 2025-11-25 and MCP 2026-07-28 conformance both run in CI. | Verified | | Version negotiation and discovery | [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Servers implement `server/discover`, advertise supported versions and capabilities, reject unsupported versions with draft error data, and clients retry or fall back according to transport-era rules. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart), [`test/conformance/mcp_2026_07_28_client.dart`](../test/conformance/mcp_2026_07_28_client.dart) | Dart clients retain temporary legacy-body read compatibility with published TypeScript and Python beta servers. Their published beta clients remain known pre-#3002 gaps against a spec-correct Dart server; TypeScript SDK #2513 preview provides forward validation. | Published alpha.9 predates #3002; conformance PR #403 semantics are verified locally against the merged PR source. | Verified locally; published peer/referee gaps | -| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every MCP 2026-07-28 request carries protocol version and client capabilities in `_meta`; client identity is optional, while a present malformed identity is rejected. Servers do not infer protocol state from a prior request. Non-MCP metadata remains opaque and is preserved. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | Published TypeScript SDK beta clients exercise identified requests; local tests and merged conformance PR #403 source cover anonymous requests. | Published alpha.9 incorrectly requires `clientInfo`; `server-stateless` is expected-failed until a release includes conformance PR #403. | Verified locally; published referee gap | -| Stateless result identity | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | `McpServer` stamps its configured identity by default in successful MCP 2026-07-28 result `_meta["io.modelcontextprotocol/serverInfo"]`, while a handler-authored value at that key wins; discovery has no body `serverInfo`, and missing or malformed peer identity is treated as anonymous. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/types_test.dart`](../test/types_test.dart) cover discovery, list/tool/task/subscription metadata merging, handler precedence, anonymous identity, and legacy isolation. | Dart temporarily reads legacy discovery-body identity from published TypeScript and Python beta servers; TypeScript SDK #2513 preview validates the final result metadata shape. | Conformance PR #403 checks discovery identity metadata; published alpha.9 predates it. | Verified locally; published peer/referee gaps | +| Stateless request metadata | [Overview](https://modelcontextprotocol.io/specification/draft/basic), [Versioning](https://modelcontextprotocol.io/specification/draft/basic/versioning) | Every MCP 2026-07-28 request carries protocol version and client capabilities in `_meta`; client identity is optional, while a present malformed identity is rejected. Servers do not infer protocol state from a prior request. Non-MCP metadata remains opaque and is preserved. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart) | Published TypeScript SDK beta clients exercise identified requests; local tests and merged conformance PR #403 source cover anonymous requests. | Published alpha.9 incorrectly requires `clientInfo`; its three stale `server-stateless` diagnostics are matched exactly until a release includes conformance PR #403. | Verified locally; published referee gap | +| Stateless result identity | [Schema reference](https://modelcontextprotocol.io/specification/draft/schema) | `McpServer` stamps its configured identity by default in successful MCP 2026-07-28 result `_meta["io.modelcontextprotocol/serverInfo"]`. A valid handler-authored value wins, `null` omits the optional key, and malformed non-null output is rejected before serialization. Discovery has no body `serverInfo`; missing canonical identity is anonymous, while a present malformed or `null` canonical value is rejected. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/types_test.dart`](../test/types_test.dart) cover discovery, list/tool/task/subscription metadata merging, handler precedence, anonymous identity, malformed output, and legacy isolation. | Dart temporarily reads legacy discovery-body identity from published TypeScript and Python beta servers and ignores malformed values in that obsolete location; TypeScript SDK #2513 preview validates the final result metadata shape. | Conformance PR #403 checks discovery identity metadata; published alpha.9 predates it. | Verified locally; published peer/referee gaps | | JSON-RPC envelopes and errors | [Base protocol](https://modelcontextprotocol.io/specification/draft/basic) | String and integer request IDs retain their wire identity, arbitrary JSON-RPC error `data` remains observable, and unknown metadata is preserved. | [`test/types_edge_cases_test.dart`](../test/types_edge_cases_test.dart), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | Cross-SDK fixtures exercise normal success and error envelopes. | Error and malformed-request scenarios in alpha.9. | Verified | | Streamable HTTP routing headers | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Transports](https://modelcontextprotocol.io/specification/draft/basic/transports) | MCP 2026-07-28 HTTP POST requests include required protocol, method, name, and parameter-routing headers; mismatches reject with draft header errors. A `HeaderMismatch` refreshes `tools/list` metadata before one retry. Stateless SSE responses preserve browser CORS headers. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_https_test.dart`](../test/server/streamable_https_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart) | The TypeScript SDK #2513 preview validates `x-mcp-header` mirroring and raw rejection against Dart; the published TypeScript server validates Dart's schema-refresh retry. Chrome validates the real cross-origin path. | `stateless-http.requires-routing-headers`, `stateless-http.validates-parameter-headers`, and related alpha.9 cases. | Verified | | Removed session and resumability behavior | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog) | MCP 2026-07-28 Streamable HTTP omits protocol-level sessions, rejects removed GET/DELETE behaviors and JSON-RPC batches, and cancels a stateless request by closing only that POST response stream without legacy notification redelivery. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/streamable_mcp_server_test.dart`](../test/server/streamable_mcp_server_test.dart), [`test/client/streamable_https_test.dart`](../test/client/streamable_https_test.dart), [`test/browser/mcp_2026_07_28_streamable_http_test.dart`](../test/browser/mcp_2026_07_28_streamable_http_test.dart) | The published TypeScript server path verifies Dart request cancellation and recovery; the #2513 preview verifies the reverse server-observed close. Loopback HTTP and real Chrome add sibling isolation and cleanup coverage. Python cancellation is not yet covered. | `stateless-http.rejects-non-post-methods`, `stateless-http.rejects-batch-payloads`, and related alpha.9 cases. | Verified | | Cacheable results and deterministic lists | [Key changes](https://modelcontextprotocol.io/specification/draft/changelog), [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) | `server/discover`, list, and read responses include `resultType`, `ttlMs`, and `cacheScope`; stateless `tools/list` is deterministic and omits stable-only tool execution metadata. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart) | The TypeScript SDK #2513 preview fixture checks discovery and `tools/list` cache metadata. | Cacheable-result and tools-list scenarios in alpha.9. | Verified | -| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for MCP 2025-11-25 behavior. The built-in validator defaults to Draft 2020-12, accepts an explicitly declared Draft 7 schema for MCP 2025-11-25 compatibility, and synchronously resolves local fragments plus absolute or relative identifiers that stay inside the supplied schema document, including dynamic references. Unresolved references outside that document, unsupported dialects, and custom vocabularies are rejected without network I/O. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/shared/json_schema_validator_test.dart`](../test/shared/json_schema_validator_test.dart), [`test/shared/json_schema_validator_io_test.dart`](../test/shared/json_schema_validator_io_test.dart), [`tool/testing/run_json_schema_2020_12_suite.dart`](../tool/testing/run_json_schema_2020_12_suite.dart), [`tool/testing/run_json_schema_draft7_suite.dart`](../tool/testing/run_json_schema_draft7_suite.dart), [`tool/testing/json_schema_suite_runner.dart`](../tool/testing/json_schema_suite_runner.dart), [`tool/testing/json_schema_test_suite_ref.txt`](../tool/testing/json_schema_test_suite_ref.txt), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | The published TypeScript server and #2513 preview client validate `tools/list` and `tools/call`; schema-validation semantics and the no-network policy are locally tested. | The MCP 2026-07-28 client suite is green; the published server suite keeps only the documented `server-stateless` pre-#3002 gap expected. The pinned official JSON Schema Test Suite gates pass all supported assertions, while a loopback security test proves rejected network `$ref` values cause no HTTP request. | Verified with a published referee gap | +| Tools and JSON Schema 2020-12 | [Tools](https://modelcontextprotocol.io/specification/draft/server/tools), [Overview JSON Schema usage](https://modelcontextprotocol.io/specification/draft/basic) | Tool schemas preserve JSON Schema 2020-12 constructs, including nested boolean schemas; stable root-object compatibility remains intact for MCP 2025-11-25 behavior. The built-in validator defaults to Draft 2020-12, accepts an explicitly declared Draft 7 schema for MCP 2025-11-25 compatibility, and synchronously resolves local fragments plus absolute or relative identifiers that stay inside the supplied schema document, including dynamic references. Unresolved references outside that document, unsupported dialects, and custom vocabularies are rejected without network I/O. | [`test/tool_schema_test.dart`](../test/tool_schema_test.dart), [`test/shared/json_schema_validator_test.dart`](../test/shared/json_schema_validator_test.dart), [`test/shared/json_schema_validator_io_test.dart`](../test/shared/json_schema_validator_io_test.dart), [`tool/testing/run_json_schema_2020_12_suite.dart`](../tool/testing/run_json_schema_2020_12_suite.dart), [`tool/testing/run_json_schema_draft7_suite.dart`](../tool/testing/run_json_schema_draft7_suite.dart), [`tool/testing/json_schema_suite_runner.dart`](../tool/testing/json_schema_suite_runner.dart), [`tool/testing/json_schema_test_suite_ref.txt`](../tool/testing/json_schema_test_suite_ref.txt), [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart) | The published TypeScript server and #2513 preview client validate `tools/list` and `tools/call`; schema-validation semantics and the no-network policy are locally tested. | The MCP 2026-07-28 client suite is green; the published server suite keeps only the three exactly matched `server-stateless` pre-#3002 diagnostics. The pinned official JSON Schema Test Suite gates pass all supported assertions, while a loopback security test proves rejected network `$ref` values cause no HTTP request. | Verified with a published referee gap | | Resource semantics | [Resources](https://modelcontextprotocol.io/specification/draft/server/resources) | Successful reads preserve typed contents and cache metadata; a missing resource returns `InvalidParams` (`-32602`) rather than an empty result. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/server/mcp_server_test.dart`](../test/server/mcp_server_test.dart), [`test/types/resources_test.dart`](../test/types/resources_test.dart) | Current beta fixtures focus on discovery and tools rather than MCP 2026-07-28 resource errors. | Official `sep-2164-resource-not-found` plus resource list/read scenarios in alpha.9. | Verified | | MRTR and elicitation | [MRTR](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr), [Elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation) | MCP 2026-07-28 `input_required` results are emitted only for supported requests and require advertised client capabilities. URL-mode `elicitation/create` uses `mode`, `message`, and `url` without legacy `elicitationId` or `notifications/elicitation/complete`. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/elicitation_test.dart`](../test/elicitation_test.dart), [`example/mcp_2026_07_28/`](../example/mcp_2026_07_28/) | The TypeScript SDK #2513 preview client completes an MCP 2026-07-28 `input_required` retry against Dart; the Dart stable-profile client completes the reverse retry against the published TypeScript beta server. | `mrtr` scenarios in alpha.9. | Verified | | Subscriptions | [Subscriptions](https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions), [Schema reference](https://modelcontextprotocol.io/specification/draft/schema#subscriptionslistenresult) | Each `subscriptions/listen` stream acknowledges before any later notification carrying that subscription ID. Other subscription IDs may interleave on stdio. The SDK filters unsupported types, correlates through `io.modelcontextprotocol/subscriptionId`, and returns the same ID on graceful close. | [`test/mcp_2026_07_28_test.dart`](../test/mcp_2026_07_28_test.dart), [`test/conformance/mcp_2026_07_28_server.dart`](../test/conformance/mcp_2026_07_28_server.dart), [`example/mcp_2026_07_28/`](../example/mcp_2026_07_28/) | The TypeScript SDK #2513 preview validates acknowledgment and list-change notification correlation against Dart. | Subscription scenarios in alpha.9. | Verified | @@ -129,13 +142,13 @@ These are gaps in external evidence unless stated otherwise; they are not known missing core protocol behavior. - Published `@modelcontextprotocol/conformance@0.2.0-alpha.9` predates spec - PR #3002 and conformance PR #403, so `server-stateless` is an expected - scenario-level failure while merged-PR-source verification is green. + PR #3002 and conformance PR #403, so its three stale `server-stateless` + diagnostics are matched exactly while merged-PR-source verification is green. - Published TypeScript SDK beta.4 requires body `DiscoverResult.serverInfo`. Dart keeps the Dart-client -> TypeScript-server path through a temporary read fallback, while the TypeScript-client -> Dart-server direction remains an exact expected negotiation gap until TypeScript SDK PR #2513 is released. -- Published Python SDK `mcp==2.0.0b1` also requires body +- Published Python SDK `mcp==2.0.0b2` also requires body `DiscoverResult.serverInfo`. Dart-client -> Python-server remains required through the same read fallback; Python-client -> Dart-server asserts its exact 2026 -> 2025 fallback as a self-expiring expected gap. diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 8f74f65f..163b981b 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -5,7 +5,8 @@ import 'package:mcp_dart/src/shared/logging.dart'; import 'package:mcp_dart/src/shared/protocol.dart'; import 'package:mcp_dart/src/types.dart'; import 'package:mcp_dart/src/types/json_rpc.dart' as json_rpc; -import 'package:mcp_dart/src/types/validation.dart' show readOptionalJsonObject; +import 'package:mcp_dart/src/types/validation.dart' + show readJsonObject, readOptionalJsonObject; final _logger = Logger("mcp_dart.server"); @@ -945,10 +946,28 @@ class Server extends Protocol { final resultMeta = { ...?readOptionalJsonObject(json['_meta'], 'Result._meta'), }; - // Handler-authored metadata intentionally wins, including an explicit null - // that configures this result as anonymous. Receivers treat malformed - // optional identity as anonymous rather than using it for behavior. - resultMeta.putIfAbsent(McpMetaKey.serverInfo, _serverInfo.toJson); + final handlerMeta = readOptionalJsonObject(result.meta, 'Result._meta'); + final hasHandlerServerInfo = + handlerMeta?.containsKey(McpMetaKey.serverInfo) == true; + if (hasHandlerServerInfo || resultMeta.containsKey(McpMetaKey.serverInfo)) { + final serverInfo = hasHandlerServerInfo + ? handlerMeta![McpMetaKey.serverInfo] + : resultMeta[McpMetaKey.serverInfo]; + if (serverInfo == null) { + // An anonymous result omits the optional identity property. JSON null + // is not a valid Implementation value. + resultMeta.remove(McpMetaKey.serverInfo); + } else { + final serverInfoJson = readJsonObject( + serverInfo, + 'Result._meta.${McpMetaKey.serverInfo}', + ); + Implementation.fromJson(serverInfoJson); + resultMeta[McpMetaKey.serverInfo] = serverInfoJson; + } + } else { + resultMeta[McpMetaKey.serverInfo] = _serverInfo.toJson(); + } json['_meta'] = resultMeta; return json; diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 07d99c03..782882f9 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -600,6 +600,19 @@ abstract class Protocol { return; } + final resultMeta = readOptionalJsonObject( + resultJson['_meta'], + 'MCP stateless Result._meta', + ); + if (resultMeta?.containsKey(McpMetaKey.serverInfo) == true) { + Implementation.fromJson( + readJsonObject( + resultMeta![McpMetaKey.serverInfo], + 'MCP stateless Result._meta.${McpMetaKey.serverInfo}', + ), + ); + } + final resultType = resultJson['resultType']; if (resultType == null) { throw const FormatException( @@ -1671,11 +1684,13 @@ abstract class Protocol { 'Result._meta', ); Map? responseMeta; - if (result.meta != null || serializedResult.containsKey('_meta')) { - responseMeta = { - ...?result.meta, - ...?serializedMeta, - }; + if (serializedResult.containsKey('_meta')) { + // The serializer may validate or sanitize reserved metadata, so its + // output is authoritative. Falling back only when `_meta` was omitted + // preserves compatibility with older custom result implementations. + responseMeta = serializedMeta ?? {}; + } else if (result.meta != null) { + responseMeta = readJsonObject(result.meta, 'Result._meta'); } final response = JsonRpcResponse( diff --git a/lib/src/types/initialization.dart b/lib/src/types/initialization.dart index a940cc6f..6a81655f 100644 --- a/lib/src/types/initialization.dart +++ b/lib/src/types/initialization.dart @@ -1407,34 +1407,32 @@ class DiscoverResult implements CacheableResultData { } final meta = readOptionalJsonObject(json['_meta'], 'DiscoverResult._meta'); - Implementation? readServerInfo(Object? value, String fieldName) { + Implementation? readLegacyServerInfo(Object? value) { if (value == null) return null; try { - return Implementation.fromJson(readJsonObject(value, fieldName)); + return Implementation.fromJson( + readJsonObject(value, 'DiscoverResult.serverInfo'), + ); } on FormatException { - // Server identity is optional, self-reported display metadata. Invalid - // identity must not change discovery or connection behavior. + // Temporary read compatibility ignores malformed pre-#3002 body + // identity. The canonical metadata property remains strictly parsed. return null; } } final hasMetadataServerInfo = meta?.containsKey(McpMetaKey.serverInfo) == true; - final metadataServerInfo = hasMetadataServerInfo - ? readServerInfo( - meta![McpMetaKey.serverInfo], - 'DiscoverResult._meta.${McpMetaKey.serverInfo}', - ) - : null; - final sanitizedMeta = hasMetadataServerInfo && metadataServerInfo == null - ? ({...meta!}..remove(McpMetaKey.serverInfo)) - : meta; final serverInfo = hasMetadataServerInfo - ? metadataServerInfo + ? Implementation.fromJson( + readJsonObject( + meta![McpMetaKey.serverInfo], + 'DiscoverResult._meta.${McpMetaKey.serverInfo}', + ), + ) // Temporary read compatibility until corrected TypeScript and Python // SDK releases ship. Their pinned beta fixtures still send the // pre-#3002 body field; Dart never emits it. - : readServerInfo(json['serverInfo'], 'DiscoverResult.serverInfo'); + : readLegacyServerInfo(json['serverInfo']); return DiscoverResult( supportedVersions: [ @@ -1454,7 +1452,7 @@ class DiscoverResult implements CacheableResultData { json['cacheScope'], 'DiscoverResult.cacheScope', ), - meta: sanitizedMeta, + meta: meta, ); } @@ -1472,8 +1470,26 @@ class DiscoverResult implements CacheableResultData { final resultMeta = { if (serverInfo != null) McpMetaKey.serverInfo: serverInfo!.toJson(), - ...?readOptionalJsonObject(meta, 'DiscoverResult._meta'), }; + final jsonMeta = readOptionalJsonObject(meta, 'DiscoverResult._meta'); + if (jsonMeta != null) { + resultMeta.addAll(jsonMeta); + if (jsonMeta.containsKey(McpMetaKey.serverInfo)) { + final metadataServerInfo = jsonMeta[McpMetaKey.serverInfo]; + if (metadataServerInfo == null) { + // A null override represents an anonymous result. The optional + // property must be absent rather than encoded with a null value. + resultMeta.remove(McpMetaKey.serverInfo); + } else { + Implementation.fromJson( + readJsonObject( + metadataServerInfo, + 'DiscoverResult._meta.${McpMetaKey.serverInfo}', + ), + ); + } + } + } return { 'resultType': resultType, diff --git a/lib/src/types/json_rpc.dart b/lib/src/types/json_rpc.dart index 1090410b..a600782d 100644 --- a/lib/src/types/json_rpc.dart +++ b/lib/src/types/json_rpc.dart @@ -158,8 +158,10 @@ class McpMetaKey { const McpMetaKey._(); } -/// Builds request metadata required by the `2026-07-28` stateless -/// request model. +/// Builds request metadata for the `2026-07-28` stateless request model. +/// +/// [clientInfo] is recommended by MCP but may be omitted for an anonymous +/// client. Protocol version and client capabilities remain required. Map buildProtocolRequestMeta({ required String protocolVersion, Implementation? clientInfo, @@ -167,9 +169,12 @@ Map buildProtocolRequestMeta({ Map? meta, Object? logLevel, }) { - validateRequestMeta(meta, validateKeys: true); + final requestMeta = { + ...?validateRequestMeta(meta, validateKeys: true), + }..remove(McpMetaKey.clientInfo); + return { - ...?meta, + ...requestMeta, McpMetaKey.protocolVersion: protocolVersion, if (clientInfo != null) McpMetaKey.clientInfo: clientInfo.toJson(), McpMetaKey.clientCapabilities: clientCapabilities.toJson( diff --git a/packages/mcp_dart_cli/CHANGELOG.md b/packages/mcp_dart_cli/CHANGELOG.md index 2611a4de..6ebddb04 100644 --- a/packages/mcp_dart_cli/CHANGELOG.md +++ b/packages/mcp_dart_cli/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Inspection recognizes stateless clients that skip optional discovery, accepts + omitted client/server identity, and emits server identity in result `_meta`. + ## 0.2.0-dev.2 - Updated generated projects and inspection to use `mcp_dart ^2.3.0-dev.2`, diff --git a/packages/mcp_dart_cli/lib/src/inspect_client_command.dart b/packages/mcp_dart_cli/lib/src/inspect_client_command.dart index b7d9110f..6b3dcff1 100644 --- a/packages/mcp_dart_cli/lib/src/inspect_client_command.dart +++ b/packages/mcp_dart_cli/lib/src/inspect_client_command.dart @@ -150,6 +150,8 @@ class ClientInspectorHarness { String? _clientProtocolVersion; String? _firstMethod; bool _sawAnyMessage = false; + bool _observedStatelessRequest = false; + bool _validatedStatelessRequest = false; bool _sawDiscover = false; bool _sawInitialize = false; bool _sawInitialized = false; @@ -265,7 +267,8 @@ class ClientInspectorHarness { if (method == Method.initialize) { _sawInitialize = true; } - if (_sawDiscover && method != Method.serverDiscover) { + if (_isStatelessRequest(method, parsedRequest)) { + _observedStatelessRequest = true; final metadataError = _validateStatelessRequestMetadata(parsedRequest); if (metadataError != null) { _recordStatelessMetadataError(method, metadataError); @@ -277,6 +280,10 @@ class ClientInspectorHarness { ); return; } + if (!_validatedStatelessRequest) { + _captureStatelessClientMetadata(parsedRequest.meta!); + } + _validatedStatelessRequest = true; if (_statelessRemovedRequestMethods.contains(method)) { _statelessRemovedMethods.add(method); _sendError( @@ -336,21 +343,15 @@ class ClientInspectorHarness { } } - void _handleDiscover(JsonRpcRequest request) { - final metadataError = _validateStatelessRequestMetadata(request); - if (metadataError != null) { - _recordStatelessMetadataError(Method.serverDiscover, metadataError); - _sendError( - request.id, - metadataError.code, - metadataError.message, - data: metadataError.data, - ); - return; + bool _isStatelessRequest(String method, JsonRpcRequest request) { + if (method == Method.serverDiscover || _validatedStatelessRequest) { + return true; } + return request.meta?.containsKey(McpMetaKey.protocolVersion) == true; + } + void _handleDiscover(JsonRpcRequest request) { _sawDiscover = true; - _captureStatelessClientMetadata(request.meta!); _sendResult( request.id, const DiscoverResult( @@ -470,7 +471,8 @@ class ClientInspectorHarness { } void _handleNotification(Map notification, String method) { - if (_sawDiscover && _statelessRemovedNotificationMethods.contains(method)) { + if (_validatedStatelessRequest && + _statelessRemovedNotificationMethods.contains(method)) { _statelessRemovedMethods.add(method); } if (method == Method.notificationsInitialized) { @@ -578,7 +580,7 @@ class ClientInspectorHarness { Map result, { bool cacheable = false, }) { - if (!_sawDiscover) { + if (!_validatedStatelessRequest) { _sendResult(id, result); return; } @@ -700,7 +702,7 @@ class ClientInspectorHarness { void _sendResult(Object? id, Map result) { final wireResult = {...result}; - if (_sawDiscover) { + if (_validatedStatelessRequest) { final existingMeta = result['_meta']; final resultMeta = { if (existingMeta is Map) ...existingMeta.cast(), @@ -795,7 +797,7 @@ class ClientInspectorHarness { ); } - if (_observedMethods.contains(Method.serverDiscover)) { + if (_observedStatelessProtocol) { _checkStatelessLifecycle(); } else { _checkLegacyLifecycle(); @@ -834,28 +836,41 @@ class ClientInspectorHarness { ); } + bool get _observedStatelessProtocol => _observedStatelessRequest; + void _checkStatelessLifecycle() { - if (_firstMethod == Method.serverDiscover) { - _checks.pass( - 'lifecycle.discover-first', - 'Client sent server/discover before other MCP methods.', - ); + if (_observedMethods.contains(Method.serverDiscover)) { + if (_firstMethod == Method.serverDiscover) { + _checks.pass( + 'lifecycle.discover-first', + 'Client sent server/discover before other MCP methods.', + ); + } else { + _checks.fail( + 'lifecycle.discover-first', + 'Client did not send server/discover as its first MCP method.', + ); + } + + if (_sawDiscover) { + _checks.pass( + 'lifecycle.discover', + 'Client completed the stateless server/discover handshake.', + ); + } else { + _checks.fail( + 'lifecycle.discover', + 'Client did not complete a valid server/discover handshake.', + ); + } } else { - _checks.fail( + _checks.info( 'lifecycle.discover-first', - 'Client did not send server/discover as its first MCP method.', - ); - } - - if (_sawDiscover) { - _checks.pass( - 'lifecycle.discover', - 'Client completed the stateless server/discover handshake.', + 'Client used stateless MCP without optional server/discover.', ); - } else { - _checks.fail( + _checks.info( 'lifecycle.discover', - 'Client did not complete a valid server/discover handshake.', + 'Client skipped the optional server/discover handshake.', ); } @@ -883,7 +898,7 @@ class ClientInspectorHarness { ); } - if (_statelessMetadataErrors.isEmpty && _sawDiscover) { + if (_statelessMetadataErrors.isEmpty && _validatedStatelessRequest) { _checks.pass( 'lifecycle.stateless-request-metadata', 'Client supplied required metadata on stateless requests.', @@ -956,13 +971,13 @@ class ClientInspectorHarness { } void _checkClientMetadata() { - final stateless = _observedMethods.contains(Method.serverDiscover); + final stateless = _observedStatelessProtocol; if (_clientProtocolVersion == null || _clientProtocolVersion!.isEmpty) { _checks.fail( 'lifecycle.protocol-version', stateless - ? 'server/discover request protocol metadata is missing.' + ? 'Stateless request protocol metadata is missing or invalid.' : 'initialize.params.protocolVersion is missing.', ); } else if (stateless && _clientProtocolVersion == previewProtocolVersion) { @@ -1070,7 +1085,7 @@ class ClientInspectorHarness { } void _checkActiveProbes() { - if (_sawDiscover) { + if (_observedStatelessProtocol) { _checkStatelessClientCapability( capability: 'roots', id: 'client.roots.list', diff --git a/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart b/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart index 999ba102..03d3c5ed 100644 --- a/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart +++ b/packages/mcp_dart_cli/test/src/inspect_client_command_test.dart @@ -315,6 +315,225 @@ void main() { }, ); + test( + 'inspects direct stateless requests without optional discovery', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'client_harness_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + final report = File('${tempDir.path}/report.json'); + final clientLines = StreamController(); + final outputLines = []; + final harness = ClientInspectorHarness( + reportFile: report, + idleTimeout: const Duration(milliseconds: 50), + maxRuntime: const Duration(seconds: 1), + activeProbes: true, + clientLines: clientLines.stream, + writeLine: outputLines.add, + ); + final meta = buildProtocolRequestMeta( + protocolVersion: previewProtocolVersion, + clientInfo: const Implementation( + name: 'direct-stateless-client', + version: '1.0.0', + ), + clientCapabilities: const ClientCapabilities( + roots: ClientCapabilitiesRoots(), + sampling: ClientCapabilitiesSampling(), + elicitation: ClientElicitation.formOnly(), + ), + ); + + final runFuture = harness.run(); + clientLines.add( + jsonEncode( + JsonRpcListToolsRequest(id: 1, meta: meta).toJson(), + ), + ); + await clientLines.close(); + await runFuture; + + final response = jsonDecode(outputLines.single) as Map; + expect(response, isNot(contains('error'))); + final result = response['result'] as Map; + expect( + result, + allOf( + containsPair('resultType', resultTypeComplete), + containsPair('ttlMs', 0), + containsPair('cacheScope', CacheScope.private), + ), + ); + expect( + (result['_meta'] as Map)[McpMetaKey.serverInfo], + allOf( + containsPair('name', 'mcp_dart_client_inspector'), + containsPair('version', isNotEmpty), + ), + ); + + final json = + jsonDecode(await report.readAsString()) as Map; + expect(json['passed'], isTrue); + final metadata = json['metadata'] as Map; + expect(metadata['protocolVersion'], previewProtocolVersion); + expect( + metadata['clientInfo'], + allOf( + containsPair('name', 'direct-stateless-client'), + containsPair('version', '1.0.0'), + ), + ); + final checks = + (json['checks'] as List).cast>(); + expect( + checks, + isNot( + contains( + anyOf( + containsPair('id', 'lifecycle.initialize-first'), + containsPair('id', 'lifecycle.initialize'), + ), + ), + ), + ); + for (final id in [ + 'lifecycle.stateless-no-initialize', + 'lifecycle.stateless-request-metadata', + 'lifecycle.protocol-version', + 'lifecycle.client-info', + 'lifecycle.client-capabilities', + ]) { + expect( + checks, + contains( + allOf(containsPair('id', id), containsPair('status', 'pass')), + ), + reason: id, + ); + } + for (final id in [ + 'lifecycle.discover-first', + 'lifecycle.discover', + ]) { + expect( + checks, + contains( + allOf(containsPair('id', id), containsPair('status', 'info')), + ), + reason: id, + ); + } + for (final id in [ + 'client.roots.list', + 'client.sampling.create-message', + 'client.elicitation.create', + ]) { + expect( + checks, + contains( + allOf( + containsPair('id', id), + containsPair('status', 'info'), + containsPair('message', contains('MRTR input requests')), + ), + ), + reason: id, + ); + } + }, + ); + + test( + 'reports malformed direct stateless metadata without requiring initialize', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'client_harness_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + final report = File('${tempDir.path}/report.json'); + final clientLines = StreamController(); + final outputLines = []; + final harness = ClientInspectorHarness( + reportFile: report, + idleTimeout: const Duration(milliseconds: 50), + maxRuntime: const Duration(seconds: 1), + clientLines: clientLines.stream, + writeLine: outputLines.add, + ); + + final runFuture = harness.run(); + clientLines.add( + jsonEncode( + const JsonRpcListToolsRequest( + id: 1, + meta: { + McpMetaKey.protocolVersion: previewProtocolVersion, + }, + ).toJson(), + ), + ); + await clientLines.close(); + await runFuture; + + final response = jsonDecode(outputLines.single) as Map; + expect( + response['error'], + allOf( + containsPair('code', ErrorCode.invalidParams.value), + containsPair('message', contains(McpMetaKey.clientCapabilities)), + ), + ); + + final json = + jsonDecode(await report.readAsString()) as Map; + expect(json['passed'], isFalse); + final checks = + (json['checks'] as List).cast>(); + final checkIds = checks.map((check) => check['id']).toSet(); + expect( + checkIds, + isNot( + anyOf( + contains('lifecycle.initialize-first'), + contains('lifecycle.initialize'), + ), + ), + ); + expect( + checks.singleWhere( + (check) => check['id'] == 'lifecycle.stateless-request-metadata', + ), + allOf( + containsPair('status', 'fail'), + containsPair( + 'details', + containsPair( + 'errors', + contains( + contains(McpMetaKey.clientCapabilities), + ), + ), + ), + ), + ); + for (final id in [ + 'lifecycle.discover-first', + 'lifecycle.discover', + ]) { + expect( + checks, + contains( + allOf(containsPair('id', id), containsPair('status', 'info')), + ), + reason: id, + ); + } + }, + ); + test( 'inspects a client handshake and observed operations in-process', () async { @@ -389,6 +608,18 @@ void main() { (initializeResponse['result'] as Map)['protocolVersion'], stableProtocolVersion, ); + final toolsResponse = responses.singleWhere( + (response) => response['id'] == 2, + ); + expect( + toolsResponse['result'] as Map, + allOf( + isNot(contains('resultType')), + isNot(contains('ttlMs')), + isNot(contains('cacheScope')), + isNot(contains('_meta')), + ), + ); final toolCallResponse = responses.singleWhere( (response) => response['id'] == 3, ); diff --git a/test/conformance/2026_07_28_expected_failures.txt b/test/conformance/2026_07_28_expected_failures.txt index a569e9c5..6925d81a 100644 --- a/test/conformance/2026_07_28_expected_failures.txt +++ b/test/conformance/2026_07_28_expected_failures.txt @@ -1,10 +1,12 @@ # Expected failures for @modelcontextprotocol/conformance@0.2.0-alpha.9 # against the full 2026-07-28/DRAFT server suite. # -# Keep this list scenario-based so the baseline is easy to review. The runner -# fails on an unexpected pass, forcing stale entries to be removed. +# Each non-comment line is one exact JSON diagnostic. The runner requires the +# complete failure set to match, so timeouts and unrelated regressions fail CI. # # alpha.9 predates spec PR #3002 / conformance PR #403. Its server-stateless # scenario still rejects omitted clientInfo and requires body # DiscoverResult.serverInfo instead of result _meta server identity. -server-stateless +{"scenario":"server-stateless","checkId":"sep-2575-request-meta-invalid-missing-client-info","status":"FAILURE","errorMessage":"Expected error code -32602, got undefined","fieldIssue":"missing-client-info"} +{"scenario":"server-stateless","checkId":"sep-2575-http-server-meta-invalid-400","status":"FAILURE","errorMessage":"Expected HTTP 400 Bad Request, got status code 200","fieldIssue":"missing-client-info"} +{"scenario":"server-stateless","checkId":"sep-2575-server-implements-discover","status":"FAILURE","errorMessage":"Missing mandatory fields in discover response setup"} diff --git a/test/conformance/2026_07_28_post_3002_expected_failures.txt b/test/conformance/2026_07_28_post_3002_expected_failures.txt new file mode 100644 index 00000000..5f92c281 --- /dev/null +++ b/test/conformance/2026_07_28_post_3002_expected_failures.txt @@ -0,0 +1,4 @@ +# Expected failures for the merged conformance PR #403 source. +# +# PR #403 includes the spec PR #3002 identity corrections, so the focused +# server-stateless verification has no expected failures. diff --git a/test/conformance/README.md b/test/conformance/README.md index 3ca65d75..3a25369c 100644 --- a/test/conformance/README.md +++ b/test/conformance/README.md @@ -60,15 +60,27 @@ response mode, runs the full MCP `2026-07-28` server scenario list from and `--spec-version 2026-07-28`, and writes per-run artifacts under `.dart_tool/conformance/2026_07_28/`. -Expected failures live in `2026_07_28_expected_failures.txt`. When a scenario is -fixed, remove it from that file so the baseline remains useful. +Expected failures live in `2026_07_28_expected_failures.txt` as exact JSON +diagnostics. The runner accepts only the complete pinned diagnostic set with +exit code 1; a timeout, unreadable report, or different failure fails CI. As of `@modelcontextprotocol/conformance@0.2.0-alpha.9`, the `server-stateless` scenario is expected to fail because that published referee predates spec PR #3002: it still requires request `clientInfo` and body -`DiscoverResult.serverInfo`. The checked-in expected-failure entry must be -removed when a published conformance package includes PR #403; latest-main -verification is run separately against the final metadata shape. +`DiscoverResult.serverInfo`. The three checked-in diagnostics must be removed +when a published conformance package includes PR #403. + +Until then, reproduce the corrected `server-stateless` check against the +immutable merged PR #403 source: + +```bash +dart run test/conformance/run_2026_07_28_server_conformance.dart \ + --scenario server-stateless \ + --conformance-package \ + github:modelcontextprotocol/conformance#d1c0b9591786726d8a4bec05306eb103ba6894ff \ + --expected-failures \ + test/conformance/2026_07_28_post_3002_expected_failures.txt +``` Run the current client baseline from the repository root: diff --git a/test/conformance/conformance_expected_failures.dart b/test/conformance/conformance_expected_failures.dart new file mode 100644 index 00000000..0e60459c --- /dev/null +++ b/test/conformance/conformance_expected_failures.dart @@ -0,0 +1,285 @@ +import 'dart:convert'; +import 'dart:io'; + +class ConformanceFailureDiagnostic { + const ConformanceFailureDiagnostic({ + required this.scenario, + required this.checkId, + required this.status, + required this.errorMessage, + required this.fieldIssue, + }); + + factory ConformanceFailureDiagnostic.fromExpectedJson( + Map json, + ) { + const supportedKeys = { + 'scenario', + 'checkId', + 'status', + 'errorMessage', + 'fieldIssue', + }; + final unsupportedKeys = json.keys.toSet().difference(supportedKeys); + if (unsupportedKeys.isNotEmpty) { + throw FormatException( + 'Unsupported expected-failure fields: ' + '${unsupportedKeys.toList()..sort()}', + ); + } + + final scenario = json['scenario']; + final checkId = json['checkId']; + final status = json['status']; + final errorMessage = json['errorMessage']; + final fieldIssue = json['fieldIssue']; + if (scenario is! String || scenario.isEmpty) { + throw const FormatException( + 'Expected-failure scenario must be a non-empty string.', + ); + } + if (checkId is! String || checkId.isEmpty) { + throw const FormatException( + 'Expected-failure checkId must be a non-empty string.', + ); + } + if (status != 'FAILURE') { + throw const FormatException( + 'Expected-failure status must be FAILURE.', + ); + } + if (errorMessage is! String || errorMessage.isEmpty) { + throw const FormatException( + 'Expected-failure errorMessage must be a non-empty string.', + ); + } + if (fieldIssue != null && fieldIssue is! String) { + throw const FormatException( + 'Expected-failure fieldIssue must be a string when present.', + ); + } + + return ConformanceFailureDiagnostic( + scenario: scenario, + checkId: checkId, + status: status as String, + errorMessage: errorMessage, + fieldIssue: fieldIssue as String?, + ); + } + + final String scenario; + final String checkId; + final String status; + final String? errorMessage; + final String? fieldIssue; + + String get description { + final issue = fieldIssue == null ? '' : ' [$fieldIssue]'; + return '$checkId$issue: $status' + '${errorMessage == null ? '' : ' ($errorMessage)'}'; + } + + Map toJson() => { + 'scenario': scenario, + 'checkId': checkId, + 'status': status, + if (errorMessage != null) 'errorMessage': errorMessage, + if (fieldIssue != null) 'fieldIssue': fieldIssue, + }; + + @override + bool operator ==(Object other) => + other is ConformanceFailureDiagnostic && + scenario == other.scenario && + checkId == other.checkId && + status == other.status && + errorMessage == other.errorMessage && + fieldIssue == other.fieldIssue; + + @override + int get hashCode => Object.hash( + scenario, + checkId, + status, + errorMessage, + fieldIssue, + ); +} + +class ConformanceFailureComparison { + const ConformanceFailureComparison({ + required this.missing, + required this.unexpected, + }); + + final List missing; + final List unexpected; + + bool get matches => missing.isEmpty && unexpected.isEmpty; +} + +Future> readExpectedConformanceFailures( + String path, +) async { + final file = File(path); + if (!await file.exists()) { + return const []; + } + + final failures = []; + final lines = await file.readAsLines(); + for (var index = 0; index < lines.length; index++) { + final line = lines[index].trim(); + if (line.isEmpty || line.startsWith('#')) { + continue; + } + try { + final decoded = jsonDecode(line); + if (decoded is! Map) { + throw const FormatException( + 'Expected-failure entry must be a JSON object.', + ); + } + failures.add( + ConformanceFailureDiagnostic.fromExpectedJson(decoded), + ); + } on FormatException catch (error) { + throw FormatException( + '$path:${index + 1}: ${error.message}', + error.source, + error.offset, + ); + } + } + return failures; +} + +Future> readConformanceFailureDiagnostics({ + required Directory outputDirectory, + required String scenario, +}) async { + final checkFiles = []; + await for (final entity + in outputDirectory.list(recursive: true, followLinks: false)) { + if (entity is File && entity.uri.pathSegments.last == 'checks.json') { + checkFiles.add(entity); + } + } + checkFiles.sort((left, right) => left.path.compareTo(right.path)); + if (checkFiles.length != 1) { + throw StateError( + 'Expected exactly one checks.json under ${outputDirectory.path}, ' + 'found ${checkFiles.length}.', + ); + } + + final decoded = jsonDecode(await checkFiles.single.readAsString()); + if (decoded is! List) { + throw FormatException( + '${checkFiles.single.path} must contain a JSON array.', + ); + } + + final failures = []; + for (var index = 0; index < decoded.length; index++) { + final entry = decoded[index]; + if (entry is! Map) { + throw FormatException( + '${checkFiles.single.path}: entry $index must be a JSON object.', + ); + } + final status = entry['status']; + if (status == 'SUCCESS' || status == 'SKIPPED') { + continue; + } + final checkId = entry['id']; + final errorMessage = entry['errorMessage']; + final details = entry['details']; + final fieldIssue = details is Map ? details['fieldIssue'] : null; + if (checkId is! String || checkId.isEmpty || status is! String) { + throw FormatException( + '${checkFiles.single.path}: entry $index has an invalid id or status.', + ); + } + if (errorMessage != null && errorMessage is! String) { + throw FormatException( + '${checkFiles.single.path}: entry $index has a non-string ' + 'errorMessage.', + ); + } + if (fieldIssue != null && fieldIssue is! String) { + throw FormatException( + '${checkFiles.single.path}: entry $index has a non-string fieldIssue.', + ); + } + failures.add( + ConformanceFailureDiagnostic( + scenario: scenario, + checkId: checkId, + status: status, + errorMessage: errorMessage as String?, + fieldIssue: fieldIssue as String?, + ), + ); + } + return failures; +} + +ConformanceFailureComparison compareConformanceFailures( + Iterable expected, + Iterable actual, +) { + final expectedCounts = _countDiagnostics(expected); + final actualCounts = _countDiagnostics(actual); + final missing = _difference(expectedCounts, actualCounts); + final unexpected = _difference(actualCounts, expectedCounts); + missing.sort((left, right) => left.description.compareTo(right.description)); + unexpected.sort( + (left, right) => left.description.compareTo(right.description), + ); + return ConformanceFailureComparison( + missing: missing, + unexpected: unexpected, + ); +} + +bool isExpectedConformanceFailure({ + required bool timedOut, + required int? exitCode, + required String? diagnosticReadError, + required Iterable expected, + required Iterable actual, +}) { + if (timedOut || + exitCode != 1 || + diagnosticReadError != null || + expected.isEmpty) { + return false; + } + return compareConformanceFailures(expected, actual).matches; +} + +Map _countDiagnostics( + Iterable diagnostics, +) { + final counts = {}; + for (final diagnostic in diagnostics) { + counts.update(diagnostic, (count) => count + 1, ifAbsent: () => 1); + } + return counts; +} + +List _difference( + Map left, + Map right, +) { + final difference = []; + for (final entry in left.entries) { + final count = entry.value - (right[entry.key] ?? 0); + for (var index = 0; index < count; index++) { + difference.add(entry.key); + } + } + return difference; +} diff --git a/test/conformance/conformance_expected_failures_test.dart b/test/conformance/conformance_expected_failures_test.dart new file mode 100644 index 00000000..df33e746 --- /dev/null +++ b/test/conformance/conformance_expected_failures_test.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; + +import 'conformance_expected_failures.dart'; + +void main() { + late Directory temporaryDirectory; + + setUp(() async { + temporaryDirectory = await Directory.systemTemp.createTemp( + 'mcp-conformance-expected-failures-', + ); + }); + + tearDown(() async { + await temporaryDirectory.delete(recursive: true); + }); + + test('reads comment-friendly exact JSON diagnostics', () async { + final file = File('${temporaryDirectory.path}/expected.txt'); + await file.writeAsString( + '# pinned referee drift\n' + '${jsonEncode(_missingClientInfo.toJson())}\n', + ); + + final failures = await readExpectedConformanceFailures(file.path); + + expect(failures, [_missingClientInfo]); + }); + + test('rejects unsupported expected-failure fields', () async { + final file = File('${temporaryDirectory.path}/expected.txt'); + await file.writeAsString( + '{"scenario":"server-stateless","checkId":"check",' + '"status":"FAILURE","errorMessage":"known","typo":true}\n', + ); + + await expectLater( + readExpectedConformanceFailures(file.path), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Unsupported expected-failure fields'), + ), + ), + ); + }); + + test('reads every non-success and non-skipped check diagnostic', () async { + final output = Directory('${temporaryDirectory.path}/output/nested'); + await output.create(recursive: true); + await File('${output.path}/checks.json').writeAsString( + jsonEncode([ + { + 'id': 'passing-check', + 'status': 'SUCCESS', + }, + { + 'id': 'skipped-check', + 'status': 'SKIPPED', + }, + { + 'id': _missingClientInfo.checkId, + 'status': _missingClientInfo.status, + 'errorMessage': _missingClientInfo.errorMessage, + 'details': {'fieldIssue': _missingClientInfo.fieldIssue}, + }, + { + 'id': 'unexpected-error', + 'status': 'ERROR', + 'errorMessage': 'referee crashed', + }, + ]), + ); + + final failures = await readConformanceFailureDiagnostics( + outputDirectory: Directory('${temporaryDirectory.path}/output'), + scenario: 'server-stateless', + ); + + expect( + failures, + [ + _missingClientInfo, + const ConformanceFailureDiagnostic( + scenario: 'server-stateless', + checkId: 'unexpected-error', + status: 'ERROR', + errorMessage: 'referee crashed', + fieldIssue: null, + ), + ], + ); + }); + + test('matches only the complete diagnostic multiset from exit 1', () { + expect( + isExpectedConformanceFailure( + timedOut: false, + exitCode: 1, + diagnosticReadError: null, + expected: const [_missingClientInfo], + actual: const [_missingClientInfo], + ), + isTrue, + ); + expect( + isExpectedConformanceFailure( + timedOut: false, + exitCode: 1, + diagnosticReadError: null, + expected: const [_missingClientInfo], + actual: const [_missingClientInfo, _unrelatedFailure], + ), + isFalse, + ); + expect( + isExpectedConformanceFailure( + timedOut: false, + exitCode: 1, + diagnosticReadError: null, + expected: const [_missingClientInfo, _unrelatedFailure], + actual: const [_missingClientInfo], + ), + isFalse, + ); + expect( + isExpectedConformanceFailure( + timedOut: false, + exitCode: 2, + diagnosticReadError: null, + expected: const [_missingClientInfo], + actual: const [_missingClientInfo], + ), + isFalse, + ); + }); + + test('never accepts a timeout or unreadable diagnostics', () { + expect( + isExpectedConformanceFailure( + timedOut: true, + exitCode: null, + diagnosticReadError: null, + expected: const [_missingClientInfo], + actual: const [], + ), + isFalse, + ); + expect( + isExpectedConformanceFailure( + timedOut: false, + exitCode: 1, + diagnosticReadError: 'missing checks.json', + expected: const [_missingClientInfo], + actual: const [_missingClientInfo], + ), + isFalse, + ); + }); +} + +const _missingClientInfo = ConformanceFailureDiagnostic( + scenario: 'server-stateless', + checkId: 'sep-2575-request-meta-invalid-missing-client-info', + status: 'FAILURE', + errorMessage: 'Expected error code -32602, got undefined', + fieldIssue: 'missing-client-info', +); + +const _unrelatedFailure = ConformanceFailureDiagnostic( + scenario: 'server-stateless', + checkId: 'unrelated-check', + status: 'FAILURE', + errorMessage: 'Unrelated regression', + fieldIssue: null, +); diff --git a/test/conformance/run_2026_07_28_server_conformance.dart b/test/conformance/run_2026_07_28_server_conformance.dart index 0636d711..942da3e6 100644 --- a/test/conformance/run_2026_07_28_server_conformance.dart +++ b/test/conformance/run_2026_07_28_server_conformance.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'conformance_expected_failures.dart'; import 'conformance_scenario_inventory.dart'; const _defaultConformancePackage = @@ -58,7 +59,7 @@ Future main(List args) async { return; } - final expectedFailures = await _readExpectedFailures( + final expectedFailures = await readExpectedConformanceFailures( options.expectedFailuresPath, ); final outputRoot = await _createOutputRoot(options.outputDir); @@ -122,13 +123,15 @@ Future main(List args) async { final unexpectedFailures = results .where( (result) => - !result.passed && !expectedFailures.contains(result.scenario), + !result.passed && !_isExpectedFailure(result, expectedFailures), ) .toList(); final unexpectedPasses = results .where( (result) => - result.passed && expectedFailures.contains(result.scenario), + result.passed && + _expectedForScenario(result.scenario, expectedFailures) + .isNotEmpty, ) .toList(); @@ -142,6 +145,7 @@ Future main(List args) async { stdout.writeln('Unexpected failures:'); for (final result in unexpectedFailures) { stdout.writeln(' - ${result.scenario} (${result.status})'); + _printExpectationMismatch(result, expectedFailures); } } if (unexpectedPasses.isNotEmpty) { @@ -164,23 +168,6 @@ Future main(List args) async { exit(exitCode); } -Future> _readExpectedFailures(String path) async { - final file = File(path); - if (!await file.exists()) { - return const {}; - } - - final entries = {}; - for (final line in await file.readAsLines()) { - final trimmed = line.trim(); - if (trimmed.isEmpty || trimmed.startsWith('#')) { - continue; - } - entries.add(trimmed); - } - return entries; -} - Future _createOutputRoot(String? outputDir) async { final root = outputDir == null ? Directory( @@ -252,7 +239,10 @@ Future<_ScenarioResult> _runScenario({ required String conformancePackage, required Duration timeout, }) async { - final outputDir = Directory('${outputRoot.path}/${_sanitize(scenario)}'); + final outputDir = Directory( + '${outputRoot.path}/${_sanitize(scenario)}/' + 'run-${DateTime.now().toUtc().microsecondsSinceEpoch}', + ); await outputDir.create(recursive: true); final process = await Process.start( @@ -290,12 +280,24 @@ Future<_ScenarioResult> _runScenario({ try { final code = await process.exitCode.timeout(timeout); await Future.wait([stdoutDone, stderrDone]); + var failureDiagnostics = const []; + String? diagnosticReadError; + try { + failureDiagnostics = await readConformanceFailureDiagnostics( + outputDirectory: outputDir, + scenario: scenario, + ); + } on Object catch (error) { + diagnosticReadError = '$error'; + } return _ScenarioResult( scenario: scenario, exitCode: code, timedOut: false, stdout: stdoutBuffer.toString(), stderr: stderrBuffer.toString(), + failureDiagnostics: failureDiagnostics, + diagnosticReadError: diagnosticReadError, ); } on TimeoutException { process.kill(ProcessSignal.sigkill); @@ -306,20 +308,26 @@ Future<_ScenarioResult> _runScenario({ timedOut: true, stdout: stdoutBuffer.toString(), stderr: stderrBuffer.toString(), + failureDiagnostics: const [], + diagnosticReadError: null, ); } } void _printScenarioResult( _ScenarioResult result, - Set expectedFailures, + List expectedFailures, ) { - final expected = expectedFailures.contains(result.scenario); + final expected = _expectedForScenario( + result.scenario, + expectedFailures, + ).isNotEmpty; + final matchedExpectation = _isExpectedFailure(result, expectedFailures); final marker = result.passed ? expected ? 'UNEXPECTED PASS' : 'PASS' - : expected + : matchedExpectation ? 'EXPECTED ${result.status.toUpperCase()}' : 'FAIL'; stdout.writeln('${marker.padRight(18)} ${result.scenario}'); @@ -328,19 +336,26 @@ void _printScenarioResult( Future _writeSummary( Directory outputRoot, List<_ScenarioResult> results, - Set expectedFailures, + List expectedFailures, String conformancePackage, ) async { final summary = { 'package': conformancePackage, - 'expectedFailures': expectedFailures.toList()..sort(), + 'expectedFailures': [ + for (final failure in expectedFailures) failure.toJson(), + ], 'results': [ for (final result in results) { 'scenario': result.scenario, 'status': result.status, 'exitCode': result.exitCode, - 'expectedFailure': expectedFailures.contains(result.scenario), + 'expectedFailure': _isExpectedFailure(result, expectedFailures), + 'failureDiagnostics': [ + for (final failure in result.failureDiagnostics) failure.toJson(), + ], + if (result.diagnosticReadError != null) + 'diagnosticReadError': result.diagnosticReadError, }, ], }; @@ -349,6 +364,66 @@ Future _writeSummary( ); } +List _expectedForScenario( + String scenario, + Iterable expectedFailures, +) { + return expectedFailures + .where((failure) => failure.scenario == scenario) + .toList(); +} + +bool _isExpectedFailure( + _ScenarioResult result, + Iterable expectedFailures, +) { + return isExpectedConformanceFailure( + timedOut: result.timedOut, + exitCode: result.exitCode, + diagnosticReadError: result.diagnosticReadError, + expected: _expectedForScenario(result.scenario, expectedFailures), + actual: result.failureDiagnostics, + ); +} + +void _printExpectationMismatch( + _ScenarioResult result, + Iterable expectedFailures, +) { + final expected = _expectedForScenario(result.scenario, expectedFailures); + if (expected.isEmpty) { + return; + } + if (result.timedOut) { + stdout.writeln(' expected failures never permit a timeout'); + return; + } + if (result.exitCode != 1) { + stdout.writeln( + ' expected conformance exit 1, got ${result.exitCode}', + ); + return; + } + if (result.diagnosticReadError != null) { + stdout.writeln( + ' could not read conformance diagnostics: ' + '${result.diagnosticReadError}', + ); + return; + } + + final comparison = compareConformanceFailures( + expected, + result.failureDiagnostics, + ); + for (final diagnostic in comparison.missing) { + stdout.writeln(' missing expected: ${diagnostic.description}'); + } + for (final diagnostic in comparison.unexpected) { + stdout.writeln(' unexpected: ${diagnostic.description}'); + } +} + String _sanitize(String value) { return value.replaceAll(RegExp('[^A-Za-z0-9_.-]'), '_'); } @@ -367,6 +442,8 @@ class _ScenarioResult { final bool timedOut; final String stdout; final String stderr; + final List failureDiagnostics; + final String? diagnosticReadError; const _ScenarioResult({ required this.scenario, @@ -374,6 +451,8 @@ class _ScenarioResult { required this.timedOut, required this.stdout, required this.stderr, + required this.failureDiagnostics, + required this.diagnosticReadError, }); bool get passed => !timedOut && exitCode == 0; diff --git a/test/interop/python_2026_07_28/README.md b/test/interop/python_2026_07_28/README.md index 9ed825f3..87268aad 100644 --- a/test/interop/python_2026_07_28/README.md +++ b/test/interop/python_2026_07_28/README.md @@ -1,7 +1,7 @@ # Python SDK 2026-07-28 Interop This fixture tracks both MCP `2026-07-28` directions against the official -Python SDK `mcp==2.0.0b1` package: Dart client -> Python server remains a +Python SDK `mcp==2.0.0b2` package: Dart client -> Python server remains a required compatible path, while Python client -> Dart server records the package's pre-spec-#3002 discovery gap. It is separate from the stable Python fixture, which continues to cover the released MCP 2025-11-25 specification. @@ -26,5 +26,10 @@ MCP_PYTHON=.dart_tool/python-2026-interop/bin/python \ The Dart client -> Python server direction remains required and checks discovery, tool listing, and tool execution. The published Python beta client predates spec PR #3002 and requires obsolete body `serverInfo`, so the reverse -direction asserts its exact 2026 -> 2025 fallback as a temporary expected gap. -The expected-gap command fails if the beta starts passing or fails differently. +direction first sends an independent anonymous raw `server/discover` request to +the Dart server. That probe requires MCP `2026-07-28` acceptance without +`clientInfo`, no obsolete body `serverInfo`, and canonical server identity in +`_meta["io.modelcontextprotocol/serverInfo"]`. Only then does the runner accept +the Python beta's exact 2026 -> 2025 fallback as a temporary expected gap. The +expected-gap command fails if the Dart wire shape regresses, the beta starts +passing, or it fails differently. diff --git a/test/interop/python_2026_07_28/requirements.txt b/test/interop/python_2026_07_28/requirements.txt index 3d8c20be..e3d4c5d6 100644 --- a/test/interop/python_2026_07_28/requirements.txt +++ b/test/interop/python_2026_07_28/requirements.txt @@ -1 +1 @@ -mcp==2.0.0b1 +mcp==2.0.0b2 diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 3736e53b..10f6ff5b 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -478,6 +478,42 @@ void main() { expect(meta, isNot(contains(McpMetaKey.clientInfo))); }); + test('typed client identity owns the reserved request metadata key', () { + for (final rawClientInfo in [ + null, + 'malformed', + const {'name': 'raw-client', 'version': '1.0.0'}, + ]) { + final callerMeta = { + McpMetaKey.clientInfo: rawClientInfo, + 'com.example.trace/id': 'trace-1', + }; + final meta = buildProtocolRequestMeta( + protocolVersion: previewProtocolVersion, + clientCapabilities: const ClientCapabilities(), + meta: callerMeta, + ); + + expect(meta, isNot(contains(McpMetaKey.clientInfo))); + expect(meta['com.example.trace/id'], 'trace-1'); + expect(callerMeta[McpMetaKey.clientInfo], equals(rawClientInfo)); + } + + final meta = buildProtocolRequestMeta( + protocolVersion: previewProtocolVersion, + clientInfo: const Implementation( + name: 'typed-client', + version: '2.0.0', + ), + clientCapabilities: const ClientCapabilities(), + meta: const {McpMetaKey.clientInfo: 'malformed'}, + ); + expect(meta[McpMetaKey.clientInfo], { + 'name': 'typed-client', + 'version': '2.0.0', + }); + }); + test('response serialization preserves and merges result metadata', () { const response = JsonRpcResponse( id: 1, @@ -515,6 +551,27 @@ void main() { ); }); + test('response metadata wins after result metadata merge', () { + const response = JsonRpcResponse( + id: 1, + result: { + '_meta': {McpMetaKey.serverInfo: 'overridden-malformed-value'}, + }, + meta: { + McpMetaKey.serverInfo: { + 'name': 'response-server', + 'version': '2.0.0', + }, + }, + ); + + final result = response.toJson()['result'] as Map; + expect((result['_meta'] as Map)[McpMetaKey.serverInfo], { + 'name': 'response-server', + 'version': '2.0.0', + }); + }); + test('rejects invalid 2026 request metadata keys during construction', () { for (final key in [ '/name', @@ -1293,28 +1350,35 @@ void main() { }); expect(identityFreeResult.serverInfo, isNull); - final malformedIdentityResult = DiscoverResult.fromJson({ - 'resultType': resultTypeComplete, - 'supportedVersions': [previewProtocolVersion], - 'capabilities': {}, - '_meta': { - McpMetaKey.serverInfo: 'malformed', - 'com.example/trace': 'trace-1', - }, - }); - expect(malformedIdentityResult.serverInfo, isNull); - expect(malformedIdentityResult.toJson()['_meta'], { - 'com.example/trace': 'trace-1', - }); + for (final malformedServerInfo in [ + null, + 'malformed', + const {'name': 'missing-version'}, + ]) { + expect( + () => DiscoverResult.fromJson({ + 'resultType': resultTypeComplete, + 'supportedVersions': [previewProtocolVersion], + 'capabilities': {}, + '_meta': { + McpMetaKey.serverInfo: malformedServerInfo, + 'com.example/trace': 'trace-1', + }, + }), + throwsFormatException, + ); + } - final malformedMetadataDoesNotUseLegacyBody = DiscoverResult.fromJson({ - 'resultType': resultTypeComplete, - 'supportedVersions': [previewProtocolVersion], - 'capabilities': {}, - '_meta': {McpMetaKey.serverInfo: 'malformed'}, - 'serverInfo': {'name': 'legacy-server', 'version': '1.0.0'}, - }); - expect(malformedMetadataDoesNotUseLegacyBody.serverInfo, isNull); + expect( + () => DiscoverResult.fromJson({ + 'resultType': resultTypeComplete, + 'supportedVersions': [previewProtocolVersion], + 'capabilities': {}, + '_meta': {McpMetaKey.serverInfo: 'malformed'}, + 'serverInfo': {'name': 'legacy-server', 'version': '1.0.0'}, + }), + throwsFormatException, + ); final explicitEmptyMeta = const DiscoverResult( supportedVersions: [previewProtocolVersion], @@ -1348,6 +1412,33 @@ void main() { 'name': 'handler', 'version': '2.0.0', }); + + final anonymousOverride = const DiscoverResult( + supportedVersions: [previewProtocolVersion], + capabilities: ServerCapabilities(), + serverInfo: Implementation(name: 'configured', version: '1.0.0'), + meta: { + McpMetaKey.serverInfo: null, + 'com.example/trace': 'trace-1', + }, + ).toJson(); + expect(anonymousOverride['_meta'], { + 'com.example/trace': 'trace-1', + }); + + for (final malformedServerInfo in [ + 'malformed', + const {'name': 'missing-version'}, + ]) { + expect( + () => DiscoverResult( + supportedVersions: const [previewProtocolVersion], + capabilities: const ServerCapabilities(), + meta: {McpMetaKey.serverInfo: malformedServerInfo}, + ).toJson(), + throwsA(isA()), + ); + } }); test('stateless metadata omits legacy task capabilities', () { @@ -4596,6 +4687,71 @@ void main() { await server.close(); }); + test('server omits anonymous handler identity from the wire', () async { + final server = Server( + const Implementation(name: 'configured', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.require2026, + capabilities: ServerCapabilities( + tools: ServerCapabilitiesTools(), + ), + ), + ); + server.setRequestHandler( + Method.toolsList, + (request, extra) async => const ListToolsResult( + tools: [], + meta: { + 'com.example/trace': 'trace-1', + McpMetaKey.serverInfo: null, + }, + ), + (id, params, meta) => JsonRpcListToolsRequest( + id: id, + params: params, + meta: meta, + ), + ); + final transport = RecordingTransport(); + await server.connect(transport); + + transport.receive(JsonRpcListToolsRequest(id: 1, meta: _clientMeta())); + await _pump(); + + final response = transport.sentMessages.single as JsonRpcResponse; + expect(response.result['_meta'], { + 'com.example/trace': 'trace-1', + }); + expect(response.meta, { + 'com.example/trace': 'trace-1', + }); + final wireResult = response.toJson()['result'] as Map; + expect(wireResult['_meta'], { + 'com.example/trace': 'trace-1', + }); + await server.close(); + }); + + test('server rejects malformed handler identity before serialization', () { + final server = Server( + const Implementation(name: 'configured', version: '1.0.0'), + options: const McpServerOptions( + protocol: McpProtocol.require2026, + ), + ); + + expect( + () => server.serializeIncomingResult( + JsonRpcListToolsRequest(id: 1, meta: _clientMeta()), + const ListToolsResult( + tools: [], + meta: {McpMetaKey.serverInfo: 'malformed'}, + ), + ), + throwsA(isA()), + ); + }); + test('legacy initialize keeps body identity without result metadata', () async { final server = Server( @@ -6818,6 +6974,57 @@ void main() { ); }); + test('client rejects malformed identity on stateless non-discovery results', + () async { + for (final malformedServerInfo in [ + null, + 'malformed', + const {'name': 'missing-version'}, + ]) { + final transport = DiscoveringClientTransport( + toolsListResult: { + 'resultType': resultTypeComplete, + 'tools': const [], + 'ttlMs': 0, + 'cacheScope': CacheScope.private, + '_meta': { + McpMetaKey.serverInfo: malformedServerInfo, + }, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.stable, + useServerDiscover: true, + ), + ); + + try { + await client.connect(transport); + await expectLater( + client.listTools(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + ErrorCode.internalError.value, + ) + .having( + (error) => error.message, + 'message', + contains('Failed to parse result for ${Method.toolsList}'), + ), + ), + reason: '$malformedServerInfo', + ); + } finally { + await client.close(); + } + } + }); + test('client rejects unrecognized stateless resultType values', () async { final transport = DiscoveringClientTransport( toolsListResult: const { @@ -7113,6 +7320,38 @@ void main() { expect(result.tools, isEmpty); }); + test('legacy client preserves opaque future reserved result metadata', + () async { + final transport = LegacyFallbackTransport( + toolsListResult: const { + 'tools': [], + '_meta': { + McpMetaKey.serverInfo: null, + }, + }, + ); + final client = McpClient( + const Implementation(name: 'client', version: '1.0.0'), + options: const McpClientOptions( + protocol: McpProtocol.stable, + useServerDiscover: true, + ), + ); + + try { + await client.connect(transport); + final result = await client.listTools(); + + expect( + client.getProtocolVersion(), + latestInitializationProtocolVersion, + ); + expect(result.meta, containsPair(McpMetaKey.serverInfo, null)); + } finally { + await client.close(); + } + }); + test('client rejects discovery when no compatible version is offered', () async { final transport = DiscoveringClientTransport( diff --git a/test/tool/run_python_2026_07_28_interop_test.dart b/test/tool/run_python_2026_07_28_interop_test.dart index a72425f0..095fe491 100644 --- a/test/tool/run_python_2026_07_28_interop_test.dart +++ b/test/tool/run_python_2026_07_28_interop_test.dart @@ -1,7 +1,11 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:mcp_dart/mcp_dart.dart'; import 'package:test/test.dart'; +import '../../tool/testing/mcp_2026_07_28_discovery_wire_probe.dart'; + void main() { test('published Python gap flag requires the Python client direction', () async { @@ -25,4 +29,110 @@ void main() { ); expect(result.stdout, isNot(contains('[dart-server]'))); }); + + group('Dart MCP 2026-07-28 discovery wire probe', () { + test('builds an anonymous discovery request', () { + final request = buildAnonymousMcp20260728DiscoveryRequest(); + final params = request['params']! as Map; + final meta = params['_meta']! as Map; + + expect(request['method'], Method.serverDiscover); + expect(meta[McpMetaKey.protocolVersion], previewProtocolVersion); + expect(meta[McpMetaKey.clientCapabilities], isEmpty); + expect(meta.containsKey(McpMetaKey.clientInfo), isFalse); + }); + + test('accepts canonical server identity metadata in JSON', () { + expect( + () => validateDartMcp20260728DiscoveryWireResponse( + jsonEncode(_validDiscoveryResponse()), + ), + returnsNormally, + ); + }); + + test('accepts canonical server identity metadata in SSE', () { + final response = jsonEncode(_validDiscoveryResponse()); + + expect( + () => validateDartMcp20260728DiscoveryWireResponse( + 'event: message\ndata: $response\n\n', + ), + returnsNormally, + ); + }); + + test('rejects obsolete body serverInfo', () { + final response = _validDiscoveryResponse(); + final result = response['result']! as Map; + result['serverInfo'] = const {'name': 'legacy', 'version': '1.0.0'}; + + expect( + () => validateDartMcp20260728DiscoveryWireResponse( + jsonEncode(response), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('obsolete body serverInfo'), + ), + ), + ); + }); + + test('rejects missing canonical server identity metadata', () { + final response = _validDiscoveryResponse(); + final result = response['result']! as Map; + result['_meta'] = {}; + + expect( + () => validateDartMcp20260728DiscoveryWireResponse( + jsonEncode(response), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('omitted or malformed result metadata serverInfo'), + ), + ), + ); + }); + + test('rejects anonymous discovery errors', () { + final response = _validDiscoveryResponse() + ..remove('result') + ..['error'] = const {'code': -32602, 'message': 'clientInfo required'}; + + expect( + () => validateDartMcp20260728DiscoveryWireResponse( + jsonEncode(response), + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('rejected anonymous server/discover'), + ), + ), + ); + }); + }); +} + +Map _validDiscoveryResponse() { + return { + 'jsonrpc': '2.0', + 'id': 'dart-discovery-wire-probe', + 'result': { + 'supportedVersions': [previewProtocolVersion], + '_meta': { + McpMetaKey.serverInfo: { + 'name': 'dart-test-server', + 'version': '1.0.0', + }, + }, + }, + }; } diff --git a/test/tool/run_ts_2026_07_28_interop_test.dart b/test/tool/run_ts_2026_07_28_interop_test.dart index 54b17aa5..e69437d0 100644 --- a/test/tool/run_ts_2026_07_28_interop_test.dart +++ b/test/tool/run_ts_2026_07_28_interop_test.dart @@ -1,7 +1,11 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:test/test.dart'; +import '../../tool/testing/bounded_response_body.dart'; + void main() { test('published TypeScript gap flag requires the TS client direction', () async { @@ -24,4 +28,62 @@ void main() { ), ); }); + + group('readBoundedUtf8ResponseBody', () { + test('decodes a response within the configured bounds', () async { + final body = Stream.value(utf8.encode('{"ok":true}')); + + final decoded = await readBoundedUtf8ResponseBody( + body, + timeout: const Duration(seconds: 1), + maxBytes: 32, + ); + + expect(decoded, '{"ok":true}'); + }); + + test('times out and cancels a response body that never closes', () async { + var cancelled = false; + final body = StreamController>( + onCancel: () { + cancelled = true; + }, + ); + body.add(utf8.encode('partial')); + + await expectLater( + readBoundedUtf8ResponseBody( + body.stream, + timeout: const Duration(milliseconds: 20), + maxBytes: 32, + ), + throwsA(isA()), + ); + + expect(cancelled, isTrue); + await body.close(); + }); + + test('rejects and cancels a response body over the byte limit', () async { + final body = Stream.fromIterable([ + utf8.encode('1234'), + utf8.encode('5'), + ]); + + await expectLater( + readBoundedUtf8ResponseBody( + body, + timeout: const Duration(seconds: 1), + maxBytes: 4, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('4-byte limit'), + ), + ), + ); + }); + }); } diff --git a/test/types_test.dart b/test/types_test.dart index 6ed85945..247bbc35 100644 --- a/test/types_test.dart +++ b/test/types_test.dart @@ -412,11 +412,11 @@ void main() { } expect( - DiscoverResult.fromJson({ + () => DiscoverResult.fromJson({ ...discoverResult, '_meta': {McpMetaKey.serverInfo: 'bad'}, - }).serverInfo, - isNull, + }), + throwsA(isA()), ); }); }); diff --git a/tool/testing/bounded_response_body.dart b/tool/testing/bounded_response_body.dart new file mode 100644 index 00000000..d3487982 --- /dev/null +++ b/tool/testing/bounded_response_body.dart @@ -0,0 +1,52 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +Future readBoundedUtf8ResponseBody( + Stream> body, { + required Duration timeout, + required int maxBytes, +}) async { + if (timeout <= Duration.zero) { + throw ArgumentError.value(timeout, 'timeout', 'Must be positive.'); + } + if (maxBytes <= 0) { + throw ArgumentError.value(maxBytes, 'maxBytes', 'Must be positive.'); + } + + final stopwatch = Stopwatch()..start(); + final iterator = StreamIterator>(body); + final bytes = BytesBuilder(copy: false); + try { + while (true) { + final remaining = timeout - stopwatch.elapsed; + if (remaining <= Duration.zero) { + throw TimeoutException( + 'Timed out reading the response body after $timeout.', + timeout, + ); + } + final hasNext = await iterator.moveNext().timeout( + remaining, + onTimeout: () => throw TimeoutException( + 'Timed out reading the response body after $timeout.', + timeout, + ), + ); + if (!hasNext) { + return utf8.decode(bytes.takeBytes()); + } + + final chunk = iterator.current; + if (bytes.length + chunk.length > maxBytes) { + throw StateError( + 'Response body exceeded the $maxBytes-byte limit.', + ); + } + bytes.add(chunk); + } + } finally { + stopwatch.stop(); + await iterator.cancel(); + } +} diff --git a/tool/testing/mcp_2026_07_28_discovery_wire_probe.dart b/tool/testing/mcp_2026_07_28_discovery_wire_probe.dart new file mode 100644 index 00000000..67057575 --- /dev/null +++ b/tool/testing/mcp_2026_07_28_discovery_wire_probe.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:mcp_dart/mcp_dart.dart'; + +import 'bounded_response_body.dart'; + +const _defaultTimeout = Duration(seconds: 20); +const _defaultMaxBodyBytes = 1024 * 1024; +const _requestId = 'dart-discovery-wire-probe'; + +Map buildAnonymousMcp20260728DiscoveryRequest() { + return { + 'jsonrpc': '2.0', + 'id': _requestId, + 'method': Method.serverDiscover, + 'params': { + '_meta': { + McpMetaKey.protocolVersion: previewProtocolVersion, + McpMetaKey.clientCapabilities: {}, + }, + }, + }; +} + +Future assertDartMcp20260728DiscoveryWire( + String url, { + Duration timeout = _defaultTimeout, + int maxBodyBytes = _defaultMaxBodyBytes, +}) async { + final httpClient = HttpClient()..connectionTimeout = timeout; + try { + final request = await httpClient.postUrl(Uri.parse(url)).timeout(timeout); + request.headers.contentType = ContentType.json; + request.headers.set( + HttpHeaders.acceptHeader, + 'application/json, text/event-stream', + ); + request.headers.set('MCP-Protocol-Version', previewProtocolVersion); + request.headers.set('Mcp-Method', Method.serverDiscover); + request.add( + utf8.encode(jsonEncode(buildAnonymousMcp20260728DiscoveryRequest())), + ); + + final response = await request.close().timeout(timeout); + final body = await readBoundedUtf8ResponseBody( + response, + timeout: timeout, + maxBytes: maxBodyBytes, + ); + if (response.statusCode != HttpStatus.ok) { + throw StateError( + 'Dart server/discover wire probe returned HTTP ' + '${response.statusCode}: ${_bodyPreview(body)}', + ); + } + + validateDartMcp20260728DiscoveryWireResponse(body); + } finally { + httpClient.close(force: true); + } +} + +void validateDartMcp20260728DiscoveryWireResponse(String body) { + final envelope = _decodeJsonOrSse(body); + if (envelope is! Map || + envelope['jsonrpc'] != '2.0' || + envelope['id'] != _requestId) { + throw StateError( + 'Dart server/discover wire probe returned an invalid JSON-RPC ' + 'envelope: ${_bodyPreview(body)}', + ); + } + if (envelope.containsKey('error')) { + throw StateError( + 'Dart server rejected anonymous server/discover: ${envelope['error']}', + ); + } + + final result = envelope['result']; + if (result is! Map) { + throw StateError( + 'Dart server/discover wire probe returned no result object: ' + '${_bodyPreview(body)}', + ); + } + final supportedVersions = result['supportedVersions']; + if (supportedVersions is! List || + !supportedVersions.contains(previewProtocolVersion)) { + throw StateError( + 'Dart server/discover did not advertise $previewProtocolVersion: ' + '$result', + ); + } + if (result.containsKey('serverInfo')) { + throw StateError( + 'Dart server/discover emitted obsolete body serverInfo: $result', + ); + } + + final meta = result['_meta']; + final serverInfo = meta is Map ? meta[McpMetaKey.serverInfo] : null; + if (serverInfo is! Map || + serverInfo['name'] != 'dart-test-server' || + serverInfo['version'] != '1.0.0') { + throw StateError( + 'Dart server/discover omitted or malformed result metadata ' + 'serverInfo: $result', + ); + } +} + +Object? _decodeJsonOrSse(String body) { + try { + return jsonDecode(body); + } on FormatException { + for (final line in const LineSplitter().convert(body)) { + if (!line.startsWith('data:')) continue; + final data = line.substring('data:'.length).trimLeft(); + if (data.isEmpty) continue; + try { + final decoded = jsonDecode(data); + if (decoded is Map && decoded['id'] == _requestId) { + return decoded; + } + } on FormatException { + continue; + } + } + throw FormatException( + 'Dart server/discover wire probe returned neither JSON nor a matching ' + 'JSON SSE event.', + _bodyPreview(body), + ); + } +} + +String _bodyPreview(String body) { + const maxCharacters = 1000; + if (body.length <= maxCharacters) return body; + return '${body.substring(0, maxCharacters)}...'; +} diff --git a/tool/testing/mcp_2026_07_28_spec_document_inventory.json b/tool/testing/mcp_2026_07_28_spec_document_inventory.json index 5f90b9a4..7fb240e2 100644 --- a/tool/testing/mcp_2026_07_28_spec_document_inventory.json +++ b/tool/testing/mcp_2026_07_28_spec_document_inventory.json @@ -4,7 +4,7 @@ "documents": { "architecture/index.mdx": { "scope": "Architecture and role context for the SDK client and server implementations.", - "sha256": "b3afbaa496cac959446ef7b137875350e0fe15bda425f7378cfb3a3f473b06b0", + "sha256": "00f3acefa150501311ff91bc3b4c278a6d6cf93e2aca566876a1221d0b6e338e", "evidence": [ "lib/src/client/client.dart", "lib/src/server/server.dart" @@ -46,7 +46,7 @@ }, "basic/index.mdx": { "scope": "Core JSON-RPC, stateless request metadata, result, error, and JSON Schema rules.", - "sha256": "37362008bccb4e6975df12b299f487527d61f7eafa261ef45b8160241442d1f2", + "sha256": "6b4f006a59842877eec7c401f1cb69d7199996e879afb9b3a16baf9667da8aa1", "evidence": [ "lib/src/types/json_rpc.dart", "test/mcp_2026_07_28_test.dart", @@ -72,7 +72,7 @@ }, "basic/patterns/mrtr.mdx": { "scope": "Core multi-round-trip input_required request and retry behavior.", - "sha256": "8eb86c86dd02a0abdbdf2fcc069662fe6600e9226248c6de50c95fe34fe9e2d5", + "sha256": "4c5666ff2f20980dcbaf24314bfa0ee460f2b45496cd27ccd7fbc6b4678880bf", "evidence": [ "lib/src/types/json_rpc.dart", "test/mcp_2026_07_28_test.dart", @@ -100,7 +100,7 @@ }, "basic/transports/index.mdx": { "scope": "Transport requirements shared by stdio and Streamable HTTP.", - "sha256": "df831894db607b5247c37d368e10a9e593b4c61002797d826fa430bc7fb90d50", + "sha256": "23ef5932869304b6f6d18ebe7e50ab98b2cecdece4ea5c2cd4320b15ccba937d", "evidence": [ "lib/src/shared/transport.dart", "test/shared/transport_api_compatibility_test.dart" @@ -108,7 +108,7 @@ }, "basic/transports/stdio.mdx": { "scope": "Stdio framing, process lifecycle, discovery probing, and legacy fallback.", - "sha256": "5aa46804a0e84ae84b4463acb1593dc5c7f90bfeaf013a7a393d1f9dc2b44e0b", + "sha256": "0e2b9315a1adc04107539ab9eed6591c9ac1861e543b2127f4716cffec22ced1", "evidence": [ "lib/src/shared/stdio.dart", "test/integration/stdio_integration_test.dart", @@ -136,7 +136,7 @@ }, "changelog.mdx": { "scope": "Authoritative 2025-to-2026 change inventory used to audit the SDK release delta.", - "sha256": "805c1569063741297d5e0777a4b31cbd65d3bd514e7856fd2ab974365722fa5d", + "sha256": "ae3b479c1a985ce073a3ce04c49471c6874d0ce3a141b4f8d64be43cc8cad9c6", "evidence": [ "doc/spec-coverage-2026-07-28.md", "test/mcp_2026_07_28_test.dart" @@ -187,7 +187,7 @@ }, "schema.mdx": { "scope": "Authoritative generated wire schema and its official machine-readable examples.", - "sha256": "b9c02d8d20a481187b0722325b564b5d1f5d5b0837a85f218659311553f6ad6e", + "sha256": "0ef01bddba15370b4b3df6781f64a0e09cbbf0e1815ceef45bd44375da18fcc9", "evidence": [ "tool/spec_example_audit.dart", "test/tool/spec_example_audit_test.dart", @@ -196,7 +196,7 @@ }, "server/discover.mdx": { "scope": "Required server/discover request, result, cache metadata, and capability advertisement.", - "sha256": "b9861bb66ca8505ff0a034247d6a065a2887fb156bb5dba35be7dbd8dfb56ae0", + "sha256": "668398cfdce5312a288fca3dc8e98ac1ddae363fe97a93d06afb4324f01f99fa", "evidence": [ "lib/src/types/initialization.dart", "lib/src/server/server.dart", @@ -213,7 +213,7 @@ }, "server/prompts.mdx": { "scope": "Prompt listing, retrieval, arguments, content, cache metadata, and list-change delivery.", - "sha256": "c5be28262bab8095704557f522c8f4d4084f283b7b57cfd65b9bcde4113b5817", + "sha256": "45402a66fc4556c73adeadb5283f167f5856c3a3eb83a1c2243bf4b6960b8b64", "evidence": [ "lib/src/types/prompts.dart", "test/server/mcp_test.dart", @@ -222,7 +222,7 @@ }, "server/resources.mdx": { "scope": "Resource and template listing, reads, errors, cache metadata, and subscription-delivered changes.", - "sha256": "ad88ba936b2da3f0a42a084d4a73bc69e31899adc7a8f0705c4ca4fb6cfe190d", + "sha256": "ae6bf2f2a065adb39b5906a5b1b70ae29fb8591249423a6309f085154304996d", "evidence": [ "lib/src/types/resources.dart", "test/types/resources_test.dart", @@ -232,7 +232,7 @@ }, "server/tools.mdx": { "scope": "Tool listing and invocation, JSON Schema 2020-12, structured output, deterministic ordering, headers, and MRTR.", - "sha256": "d31ad9c707486b2e1e9754167ae10c9bc990dd3af73ecdf00c414362cae8e8c1", + "sha256": "bc1408732947112ce6c69916d62518206c324e780aa9abf55242e300aceca01f", "evidence": [ "lib/src/types/tools.dart", "test/tool_schema_test.dart", @@ -251,7 +251,7 @@ }, "server/utilities/completion.mdx": { "scope": "Completion capability, request, result, reference, argument, and pagination-limit behavior.", - "sha256": "45812345a0e6b81b5a72dc98195485fec5d96063cd30c9b213df23b1d94e27a9", + "sha256": "7f0814fad2248e9931b7226cfb1e2437b02fdd75e3af05ddd1776ab194d5fa07", "evidence": [ "lib/src/types/completion.dart", "test/server/mcp_test.dart", @@ -269,7 +269,7 @@ }, "server/utilities/pagination.mdx": { "scope": "Cursor request and nextCursor result behavior for paginated core list methods.", - "sha256": "c484dcd9349d1953aac65002242bb4da64c6d7d038f4f5f0a937920b1f9163a9", + "sha256": "e67040d4c77e1b86dfa8efd61e445e88b574611bed5f66729e3b5267b9eec981", "evidence": [ "test/types/resources_test.dart", "test/types_test.dart", diff --git a/tool/testing/mcp_2026_07_28_spec_ref.txt b/tool/testing/mcp_2026_07_28_spec_ref.txt index 260060f7..197f8fdb 100644 --- a/tool/testing/mcp_2026_07_28_spec_ref.txt +++ b/tool/testing/mcp_2026_07_28_spec_ref.txt @@ -1 +1 @@ -a50ba9af0896c518dff323a6b2d80376cd59c47c +71e306956a4959c9655e5036be215d41986596e6 diff --git a/tool/testing/run_python_2026_07_28_interop.dart b/tool/testing/run_python_2026_07_28_interop.dart index c2e3d8bf..a7a4ccec 100644 --- a/tool/testing/run_python_2026_07_28_interop.dart +++ b/tool/testing/run_python_2026_07_28_interop.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; +import 'mcp_2026_07_28_discovery_wire_probe.dart'; + Future main(List args) async { final repoRoot = Directory.current; final fixtureDir = Directory('test/interop/python_2026_07_28'); @@ -130,6 +132,11 @@ Future<_PythonClientRun> _runPythonClientAgainstDartServer( throw TimeoutException('Timed out waiting for Dart server URL'); }, ); + await assertDartMcp20260728DiscoveryWire(url); + stdout.writeln( + '[dart-server-probe] verified anonymous spec #3002 discovery wire shape', + ); + final client = await Process.start( python, ['client.py', '--url', url], diff --git a/tool/testing/run_ts_2026_07_28_interop.dart b/tool/testing/run_ts_2026_07_28_interop.dart index 1ba9a228..b43b19cf 100644 --- a/tool/testing/run_ts_2026_07_28_interop.dart +++ b/tool/testing/run_ts_2026_07_28_interop.dart @@ -4,6 +4,8 @@ import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; +import 'mcp_2026_07_28_discovery_wire_probe.dart'; + Future main(List args) async { final repoRoot = Directory.current; final fixtureDir = Directory('test/interop/ts_2026_07_28'); @@ -156,7 +158,10 @@ Future<_TsClientRun> _runTsClientAgainstDartServer( }, ); - await _assertDartDiscoveryWire(url); + await assertDartMcp20260728DiscoveryWire(url); + stdout.writeln( + '[dart-server-probe] verified anonymous spec #3002 discovery wire shape', + ); final client = await Process.start( 'node', @@ -194,104 +199,6 @@ Future<_TsClientRun> _runTsClientAgainstDartServer( return result; } -Future _assertDartDiscoveryWire(String url) async { - final httpClient = HttpClient(); - try { - final request = await httpClient.postUrl(Uri.parse(url)); - request.headers.contentType = ContentType.json; - request.headers.set( - HttpHeaders.acceptHeader, - 'application/json, text/event-stream', - ); - request.headers.set('MCP-Protocol-Version', previewProtocolVersion); - request.headers.set('Mcp-Method', Method.serverDiscover); - request.add( - utf8.encode( - jsonEncode({ - 'jsonrpc': '2.0', - 'id': 'dart-discovery-wire-probe', - 'method': Method.serverDiscover, - 'params': { - '_meta': { - McpMetaKey.protocolVersion: previewProtocolVersion, - McpMetaKey.clientCapabilities: {}, - }, - }, - }), - ), - ); - - final response = await request.close().timeout( - const Duration(seconds: 20), - ); - final body = await response.transform(utf8.decoder).join(); - if (response.statusCode != HttpStatus.ok) { - throw StateError( - 'Dart server/discover wire probe returned HTTP ' - '${response.statusCode}: $body', - ); - } - - final envelope = _decodeJsonOrSse(body); - final result = envelope is Map ? envelope['result'] : null; - if (result is! Map) { - throw StateError( - 'Dart server/discover wire probe returned no result object: $body', - ); - } - final supportedVersions = result['supportedVersions']; - if (supportedVersions is! List || - !supportedVersions.contains(previewProtocolVersion)) { - throw StateError( - 'Dart server/discover did not advertise $previewProtocolVersion: ' - '$result', - ); - } - if (result.containsKey('serverInfo')) { - throw StateError( - 'Dart server/discover emitted obsolete body serverInfo: $result', - ); - } - final meta = result['_meta']; - final serverInfo = meta is Map ? meta[McpMetaKey.serverInfo] : null; - if (serverInfo is! Map || - serverInfo['name'] != 'dart-test-server' || - serverInfo['version'] != '1.0.0') { - throw StateError( - 'Dart server/discover omitted or malformed result metadata ' - 'serverInfo: $result', - ); - } - - stdout.writeln( - '[dart-server-probe] verified spec #3002 discovery wire shape', - ); - } finally { - httpClient.close(force: true); - } -} - -Object? _decodeJsonOrSse(String body) { - try { - return jsonDecode(body); - } on FormatException { - for (final line in const LineSplitter().convert(body)) { - if (!line.startsWith('data:')) continue; - final data = line.substring('data:'.length).trimLeft(); - if (data.isEmpty) continue; - try { - return jsonDecode(data); - } on FormatException { - continue; - } - } - throw FormatException( - 'Dart server/discover wire probe returned neither JSON nor JSON SSE data.', - body, - ); - } -} - Future _runDartClientAgainstTsServer( Directory repoRoot, Directory fixtureDir, From 0ea9bece505ebac227767642a785e0f7ed01fe15 Mon Sep 17 00:00:00 2001 From: Jhin Lee Date: Mon, 20 Jul 2026 06:25:01 -0400 Subject: [PATCH 3/3] fix: preserve custom result metadata --- lib/src/server/server.dart | 9 ++- lib/src/shared/protocol.dart | 15 +++-- test/mcp_2026_07_28_test.dart | 111 ++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/lib/src/server/server.dart b/lib/src/server/server.dart index 163b981b..de390d27 100644 --- a/lib/src/server/server.dart +++ b/lib/src/server/server.dart @@ -943,10 +943,15 @@ class Server extends Protocol { ); } + final handlerMeta = readOptionalJsonObject(result.meta, 'Result._meta'); + final serializedMeta = + readOptionalJsonObject(json['_meta'], 'Result._meta'); final resultMeta = { - ...?readOptionalJsonObject(json['_meta'], 'Result._meta'), + // Older custom results may expose metadata only through `meta`. Fall + // back to it only when the serializer omitted `_meta`; explicit wire + // metadata remains authoritative. + ...?(json.containsKey('_meta') ? serializedMeta : handlerMeta), }; - final handlerMeta = readOptionalJsonObject(result.meta, 'Result._meta'); final hasHandlerServerInfo = handlerMeta?.containsKey(McpMetaKey.serverInfo) == true; if (hasHandlerServerInfo || resultMeta.containsKey(McpMetaKey.serverInfo)) { diff --git a/lib/src/shared/protocol.dart b/lib/src/shared/protocol.dart index 782882f9..fa8b7f1a 100644 --- a/lib/src/shared/protocol.dart +++ b/lib/src/shared/protocol.dart @@ -1285,12 +1285,17 @@ abstract class Protocol { return resultJson; } + final serializedMeta = readOptionalJsonObject( + resultJson['_meta'], + 'SubscriptionsListenResult._meta', + ); final meta = { - ...readOptionalJsonObject( - resultJson['_meta'], - 'SubscriptionsListenResult._meta', - ) ?? - const {}, + ...?(resultJson.containsKey('_meta') + ? serializedMeta + : readOptionalJsonObject( + result.meta, + 'SubscriptionsListenResult.meta', + )), McpMetaKey.subscriptionId: request.id, }; return { diff --git a/test/mcp_2026_07_28_test.dart b/test/mcp_2026_07_28_test.dart index 10f6ff5b..66c6a345 100644 --- a/test/mcp_2026_07_28_test.dart +++ b/test/mcp_2026_07_28_test.dart @@ -312,6 +312,20 @@ class CompletedTaskHandler extends CancelTaskResultHandler { ); } +class LegacyMetadataResult implements BaseResultData { + @override + final Map? meta; + final Map? serializedMeta; + + const LegacyMetadataResult({this.meta, this.serializedMeta}); + + @override + Map toJson() => { + 'tools': [], + if (serializedMeta != null) '_meta': serializedMeta, + }; +} + Map _clientMeta({ String? protocolVersion, Implementation clientInfo = const Implementation( @@ -331,6 +345,20 @@ Map _clientMeta({ ); } +Map _serializeStatelessResult( + BaseResultData result, { + JsonRpcRequest? request, +}) { + final server = Server( + const Implementation(name: 'configured', version: '1.0.0'), + options: const McpServerOptions(protocol: McpProtocol.require2026), + ); + return server.serializeIncomingResult( + request ?? JsonRpcListToolsRequest(id: 1, meta: _clientMeta()), + result, + ); +} + Future _pump() => Future.delayed(Duration.zero); void _registerTaskGetExtensionHandler(Server server) { @@ -4687,6 +4715,89 @@ void main() { await server.close(); }); + test('server preserves metadata from legacy custom results', () { + final result = _serializeStatelessResult( + const LegacyMetadataResult( + meta: {'com.example/trace': 'trace-1'}, + ), + ); + + expect(result['_meta'], { + 'com.example/trace': 'trace-1', + McpMetaKey.serverInfo: { + 'name': 'configured', + 'version': '1.0.0', + }, + }); + }); + + test('explicit serialized metadata is authoritative for custom results', + () { + final result = _serializeStatelessResult( + const LegacyMetadataResult( + meta: { + 'com.example/handler-only': true, + 'com.example/shared': 'handler', + }, + serializedMeta: { + 'com.example/serialized-only': true, + 'com.example/shared': 'serialized', + }, + ), + ); + + expect(result['_meta'], { + 'com.example/shared': 'serialized', + 'com.example/serialized-only': true, + McpMetaKey.serverInfo: { + 'name': 'configured', + 'version': '1.0.0', + }, + }); + }); + + test('legacy custom subscription metadata survives owned metadata', () { + final result = _serializeStatelessResult( + const LegacyMetadataResult( + meta: { + 'com.example/trace': 'trace-1', + McpMetaKey.subscriptionId: 'handler-value', + }, + ), + request: JsonRpcSubscriptionsListenRequest( + id: 'sub-1', + listenParams: const SubscriptionsListenRequest( + notifications: SubscriptionFilter(toolsListChanged: true), + ), + meta: _clientMeta(), + ), + ); + + expect(result['_meta'], { + 'com.example/trace': 'trace-1', + McpMetaKey.subscriptionId: 'sub-1', + McpMetaKey.serverInfo: { + 'name': 'configured', + 'version': '1.0.0', + }, + }); + }); + + test('legacy custom anonymous identity preserves sibling metadata', () { + final result = _serializeStatelessResult( + const LegacyMetadataResult( + meta: { + 'com.example/trace': 'trace-1', + McpMetaKey.serverInfo: null, + }, + ), + ); + + expect(result['_meta'], { + 'com.example/trace': 'trace-1', + }); + }); + test('server omits anonymous handler identity from the wire', () async { final server = Server( const Implementation(name: 'configured', version: '1.0.0'),