diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd09201e..9bdae8d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -619,8 +619,9 @@ jobs: mcp-conformance: # The protocol org's own conformance suite (`@modelcontextprotocol/conformance`, pinned in # the script) against a local `http` server: the scenarios that apply to a server without - # the suite's reference fixtures (issue #1023). Linux only — on Windows the CLI aborts at - # exit with a libuv assertion even after every check passed. What the suite does not cover, + # the suite's reference fixtures (issue #1023). Linux only — on Windows an affected Node build + # aborts inside process.exit after every check passed (nodejs/node#56645, fixed in 24.20.0 + # and 26.7.0), which is a Node bug and not the suite's. What the suite does not cover, # the session-lifecycle clauses this server answers itself, is pinned by # tests/integration/cli/http-spec-contract.test.ts in build-and-test. name: MCP Conformance (official suite) @@ -645,9 +646,40 @@ jobs: - name: Run the applicable conformance scenarios run: node scripts/mcp-conformance.mjs --verbose + mcp-conformance-2026: + # The same suite's 0.2 line (the one the SDKs run) against the 2026-07-28 revision this + # server now speaks beside 2025-11-25 (issue #1025, step 2): `--requirements 2026-07-28`, + # everything the revision requires of a server, judged by the suite itself against + # scripts/conformance/expected-failures-2026-07-28.yaml (`--expected-failures`) — what + # cannot be tested without the suite's reference fixtures or an optional feature this + # server does not declare. The suite fails on an unlisted failure or warning and on a + # listed entry that passes, so the baseline can only shrink; its exit code is the verdict. + # Linux, where that exit code is always trustworthy (see the script's header). + name: MCP Conformance (2026-07-28) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup pnpm + uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22.x' + cache: 'pnpm' + - name: Install dependencies + run: pnpm install --frozen-lockfile + env: + SKIP_ADAPTER_VENDOR: 'true' + - name: Build project + run: pnpm run build:ci + - name: Run the 2026-07-28 requirements against the baseline, judged by the suite + run: node scripts/mcp-conformance.mjs --era 2026-07-28 --verbose + test-summary: name: Test Summary - needs: [build-and-test, windows-python-integration, lint, container-tests, cobol-host, dart-host, flutter-host, go-current, mcp-conformance] + needs: [build-and-test, windows-python-integration, lint, container-tests, cobol-host, dart-host, flutter-host, go-current, mcp-conformance, mcp-conformance-2026] runs-on: ubuntu-latest if: always() @@ -663,6 +695,7 @@ jobs: echo "flutter-host: ${{ needs.flutter-host.result }}" echo "go-current: ${{ needs.go-current.result }}" echo "mcp-conformance: ${{ needs.mcp-conformance.result }}" + echo "mcp-conformance-2026: ${{ needs.mcp-conformance-2026.result }}" if [ "${{ needs.build-and-test.result }}" != "success" ] \ || [ "${{ needs.windows-python-integration.result }}" != "success" ] \ || [ "${{ needs.lint.result }}" != "success" ] \ @@ -671,7 +704,8 @@ jobs: || [ "${{ needs.dart-host.result }}" != "success" ] \ || [ "${{ needs.flutter-host.result }}" != "success" ] \ || [ "${{ needs.go-current.result }}" != "success" ] \ - || [ "${{ needs.mcp-conformance.result }}" != "success" ]; then + || [ "${{ needs.mcp-conformance.result }}" != "success" ] \ + || [ "${{ needs.mcp-conformance-2026.result }}" != "success" ]; then echo "❌ One or more required jobs did not succeed" exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index cd2f68bf..61487fef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,7 @@ The codebase follows a **layered architecture with dependency injection** and ** - Entry point for MCP protocol communication - `src/server.ts` is the composition root and the shared core (`DebugMcpServer`: dependencies, lifecycle, session validation, breakpoint gating, the public facade methods, the `NotificationHub`); it implements `ToolContext` and passes itself to the handlers. It owns no SDK `Server`: `src/server/protocol-facade.ts` (`createProtocolServer(core, { era, deliver })`) builds one cheap SDK `Server` per connection or request over the core, and `src/server/notification-hub.ts` fans the coalesced `resources/updated` pings and `resources/list_changed` out to every facade's sink (issue #1025) - `src/server/` holds the tool schemas (`tool-schemas.ts`), argument coercion (`tool-arguments.ts`), the tools/call dispatch wrapper (`tool-dispatch.ts` over `handlers/index.ts`), argument validation (`tool-validation.ts`), result envelopes and the session-error catch helpers (`tool-result.ts`), one handler module per tool family (`handlers/*.ts`) over their shared helpers (`handlers/shared.ts`), and the resource (`output-resources.ts`) and prompt (`prompts.ts`) handlers - - Supports STDIO and Streamable HTTP transport modes. `stdio` is served through the SDK's `serveStdio(factory, { transport })` and speaks both protocol eras (2026-07-28 for a client that probes with `server/discover`, 2025-11-25 and earlier for one that opens with `initialize`); `http` speaks the 2025 era (the 2026 era follows in #1025 step 2, PR 4). One core per process in both: on `http` every MCP session is a facade over the same debug sessions (the id is the capability; one process per agent is the isolation), a debug session outlives the MCP session that created it and ends by its own close, its program ending, or the server exiting. The per-debug-session idle policy is the operator's opt-in (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport; sweep `MCP_DEBUG_SESSION_IDLE_SWEEP_MS`): when set, an abandoned session tree is closed unless a client is attending the process (an open SSE stream), `list_debug_sessions` rows show `idleMs`/`expiresInMs`, and a call naming a session the policy closed answers `closedBy: "idle-policy"`. A session outside the caller's owner scope is hidden from the call's lookups (`src/session/session-scope.ts`), so every tool answers it as an unknown id. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) reap abandoned MCP sessions only (issue #1025) + - Supports STDIO and Streamable HTTP transport modes, both in both protocol eras (issue #1025): `stdio` is served through the SDK's `serveStdio(factory, { transport })`, which pins a connection to the era its opening message chose (2026-07-28 for a `server/discover` probe, 2025-11-25 and earlier for `initialize`); `http` routes each request with the SDK's `isLegacyRequest` — a POST carrying the 2026 per-request envelope goes to a strict `createMcpHandler(factory, { legacy: 'reject' })` behind `toNodeHandler` (one facade per request, sessionless), everything 2025-shaped to the sessionful app as before. One core per process in both: on `http` every MCP session is a facade over the same debug sessions (the id is the capability; one process per agent is the isolation), a debug session outlives the MCP session that created it and ends by its own close, its program ending, or the server exiting. The per-debug-session idle policy is the operator's opt-in (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport; sweep `MCP_DEBUG_SESSION_IDLE_SWEEP_MS`): when set, an abandoned session tree is closed unless a client is attending the process (a 2025 session's open SSE stream, or a 2026-07-28 client's open `subscriptions/listen` stream), `list_debug_sessions` rows show `idleMs`/`expiresInMs`, and a call naming a session the policy closed answers `closedBy: "idle-policy"`. A session outside the caller's owner scope is hidden from the call's lookups (`src/session/session-scope.ts`), so every tool answers it as an unknown id. The HTTP reapers (`MCP_HTTP_STALE_SESSION_MS`, `MCP_HTTP_STREAM_LOST_SESSION_MS`) reap abandoned MCP sessions only (issue #1025) - Dynamically discovers available language adapters 2. **Adapter System** (NEW) diff --git a/README.md b/README.md index e569dfb3..ccf31e54 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ If your agent runs in a terminal, a pipeline, or a cloud sandbox — or needs to - 💥 **Crash-state debugging by default** – Launch sessions pause on uncaught exceptions with stack and locals live (`breakOnExceptions`; exception class/message surfaced via `lastStop`) - 🪞 **Read-only IDE mirror** – `expose_session` opens a loopback, token-gated DAP endpoint so a human's IDE can inspect the agent's live session without taking control - ☸️ **Kubernetes debugging** – port-forward attach for interpreted runtimes, `kubectl debug --target` + attach-by-PID for native processes ([recipe](docs/kubernetes.md), [turnkey manifests](examples/kubernetes/)) -- 🔌 **STDIO and Streamable HTTP transports** – Works with any MCP client. `stdio` speaks both protocol eras — the current 2026-07-28 revision (a probing client such as Claude Code's v2 runtime lands on it) and 2025-11-25 and earlier (a client that opens with `initialize`); `http` speaks 2025-11-25 and earlier, with 2026-07-28 next +- 🔌 **STDIO and Streamable HTTP transports** – Works with any MCP client. Both speak both protocol eras: the current 2026-07-28 revision (a probing client such as Claude Code's v2 runtime lands on it; sessionless on `http`) and 2025-11-25 and earlier (a client that opens with `initialize`) - 📦 **Zero-runtime dependencies** – Self-contained bundles via esbuild + tsup - ⚡ **npx ready** – Run directly with `npx @debugmcp/mcp-debugger` - no installation needed - 🐳 **Docker and npm packages** – Deploy anywhere @@ -224,6 +224,12 @@ and point the client at it: } ``` +The endpoint speaks both eras of the protocol: a client that probes with `server/discover` (Claude +Code's v2 runtime does) gets the current 2026-07-28 revision, sessionless — every request stands +alone, resource notifications flow over `subscriptions/listen` streams — and a client that opens +with `initialize` gets the 2025 sessionful era, `Mcp-Session-Id` and all, exactly as before. +`GET /health` counts the requests classified per era under `requests` (refused ones included). + One `http` process is one debugger: every MCP client of it sees the same debug sessions, and a session is addressable by whoever holds its id (the handle is the capability — run one process per agent if agents must not see each other's work). A debug session outlives the MCP session @@ -233,8 +239,12 @@ on a timer by default, so a quiet agent never loses a paused program. A server t long-lived, or that attaches to something that must not stay paused, can turn the idle policy on: with `MCP_DEBUG_SESSION_IDLE_MS` set, a session tree that no tool call has named for that long is closed (a launched program is ended, an attached one released), unless a client is attending the -process — while any client holds its SSE stream open, nothing expires. `list_debug_sessions` -shows each session's idle time, so a forgotten one is easy to find and close. Abandoned MCP +process — while any client holds a stream open (a 2025 session's SSE stream, a 2026-07-28 +client's `subscriptions/listen` stream), nothing expires. A 2026 client that only calls tools is +not attending: with the policy on, its sessions live by tool activity (and an in-flight +`wait_for_stop`) alone, so choose a window longer than such a client's pauses. +`list_debug_sessions` shows each session's idle time, so a forgotten one is easy to find and +close. Abandoned MCP sessions themselves are reaped separately (`MCP_HTTP_STREAM_LOST_SESSION_MS`, `MCP_HTTP_STALE_SESSION_MS`). `GET /health` lists the MCP sessions, the debug sessions with their idle time, and whether anyone is attending. diff --git a/changelog.d/1025-conformance-2026.added.md b/changelog.d/1025-conformance-2026.added.md new file mode 100644 index 00000000..641070dd --- /dev/null +++ b/changelog.d/1025-conformance-2026.added.md @@ -0,0 +1 @@ +**A second MCP conformance lane for the 2026-07-28 revision, judged by the suite itself.** `node scripts/mcp-conformance.mjs --era 2026-07-28` runs the protocol org's conformance suite on its 0.2 line (`@modelcontextprotocol/conformance@0.2.0-alpha.12`, the line the SDKs run) with `--requirements 2026-07-28` — everything the revision requires of a server — against a local `http` server, and hands the suite its own baseline with `--expected-failures`: the suite fails the run on a failure or warning that is not listed and on a listed entry that passes, and its exit code is the lane's verdict. `scripts/conformance/expected-failures-2026-07-28.yaml` lists what cannot be tested against this server — 18 checks that only drive the suite's reference fixtures (the "everything server" tools, resources and prompts), and 13 that need an optional feature this server does not declare (`completions`, input-required results) — each a single check with the fixture or feature named beside it, never a whole scenario, so every scenario's other checks (the schema validation of each message this server sends) stay enforced; none is a protocol divergence, and by the suite's own policy a missing prerequisite is a failure, not a skip (upstream discussion: modelcontextprotocol/conformance#516). CI runs it as `mcp-conformance-2026`, required in the test summary beside the 2025-11-25 lane, which is unchanged. Running the suite found two real gaps, both fixed in both eras: `resources/templates/list` answered "Method not found" (a server advertising the resources capability must answer it; it now answers an empty list), and the error for an unknown resource carried no `data` (it now names the requested `uri`, SEP-2164). diff --git a/changelog.d/1025-protocol-2026-http.added.md b/changelog.d/1025-protocol-2026-http.added.md new file mode 100644 index 00000000..a54d408f --- /dev/null +++ b/changelog.d/1025-protocol-2026-http.added.md @@ -0,0 +1 @@ +**`http` speaks the current MCP protocol revision, 2026-07-28, beside 2025-11-25 and earlier — on the same `/mcp` endpoint.** A request carrying the 2026 per-request envelope (`_meta` with `io.modelcontextprotocol/protocolVersion`) is served by the SDK's sessionless handler: `server/discover` answers the supported versions and capabilities, every request stands alone with no `Mcp-Session-Id`, results carry `resultType` and the cache fields, resource notifications flow over `subscriptions/listen` streams, and the 2026 header rules apply (`MCP-Protocol-Version` and `Mcp-Method` on every POST, `Mcp-Name` on the methods that name a tool, prompt or resource; a missing or mismatched one is refused with `-32020`, an unsupported claimed revision with `-32022` naming the supported ones). A 2025-shaped request — `initialize`, a request carrying `Mcp-Session-Id` and no envelope, a GET or DELETE, a claim-less POST — lands on the sessionful app exactly as before, status codes included, so every 2025 client keeps working. An open `subscriptions/listen` stream counts the process as attended for the idle policy, like a 2025 session's SSE stream; a 2026 client that only calls tools is not attending, so with the idle policy turned on its sessions live by tool activity alone. A probing client such as Claude Code's v2 runtime therefore lands on 2026-07-28 against `http` too. `GET /health` reports `listenStreams` and the requests classified per era (`requests.legacy` / `requests.modern`). Browser clients: CORS now allows `Mcp-Method`, `Mcp-Name` and echoes a preflight's requested headers. In the log, each 2026-07-28 client is named once (`[MCP Server] Client identified`), a request the handler refuses is a warning rather than an error, and a body too large to classify answers 413 like any other oversized body. Step 2 of #1025 completes the protocol move on both transports. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 405f6960..513e497b 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -55,7 +55,7 @@ graph TB - **Purpose**: Entry point for MCP protocol communication - **Key Files**: - `src/server.ts` - `DebugMcpServer`, the composition root and the shared core: dependencies, lifecycle, session validation, breakpoint gating, the public facade methods and the `NotificationHub`; implements `ToolContext`. It owns no SDK `Server` - - `src/server/protocol-facade.ts` - `createProtocolServer(core, { era, deliver })`: one cheap SDK `Server` per connection or request over the core (the eight MCP handlers, the error and initialized hooks, one notification sink that leaves with the facade); `src/server/notification-hub.ts` - the process-wide fan-out of coalesced `resources/updated` pings and `resources/list_changed` to every facade's sink (issue #1025) + - `src/server/protocol-facade.ts` - `createProtocolServer(core, { era, deliver })`: one cheap SDK `Server` per connection or request over the core (the nine MCP handlers, the error and initialized hooks, one notification sink that leaves with the facade); `src/server/notification-hub.ts` - the process-wide fan-out of coalesced `resources/updated` pings and `resources/list_changed` to every facade's sink (issue #1025) - `src/server/` - tool schemas (`tool-schemas.ts`), argument coercion (`tool-arguments.ts`), the tools/call dispatch wrapper (`tool-dispatch.ts` over `handlers/index.ts`), argument validation (`tool-validation.ts`), result envelopes and the session-error catch helpers (`tool-result.ts`), one handler module per tool family (`handlers/*.ts`) over their shared helpers (`handlers/shared.ts`), and the resource (`output-resources.ts`) and prompt (`prompts.ts`) handlers - `src/index.ts` - the `dist/index.js` entry: the prologue (`src/cli/entry-prologue.ts`, console silencing and argv cleanup, evaluated before anything else), then `main()` from `src/main.ts` — the side-effect-free module the npm bundle's `cli-entry.ts` imports directly (#732); the four subcommands are wired in `src/cli/setup.ts`: `stdio` (default), `http` (Streamable HTTP, recommended, `-p/--port`, default 3001), `doctor` (toolchain self-check), `check-rust-binary` - `src/server/output-resources.ts` - the MCP resource handlers: `debug://sessions/{id}/output` (subscribable plain-text transcript of captured debuggee output, coalesced `resources/updated` notifications) and `debug://sessions/{id}/proxy-log` (a sanitized, bounded tail of the session's proxy log — 64 KiB / 80 lines, on-demand snapshot, not subscribable, and listed only once the session has a log directory) diff --git a/docs/architecture/testing-architecture.md b/docs/architecture/testing-architecture.md index 4c30995c..a1747f4e 100644 --- a/docs/architecture/testing-architecture.md +++ b/docs/architecture/testing-architecture.md @@ -270,11 +270,11 @@ Protocol-level correctness checks. `breakpoint-messages/` contains Python script ### MCP Spec Contract and Conformance -**Location:** `tests/integration/cli/http-spec-contract.test.ts`, `scripts/mcp-conformance.mjs` (issue #1023) +**Location:** `tests/integration/cli/http-spec-contract.test.ts` (2025-11-25), `tests/integration/cli/http-spec-contract-2026.test.ts` (2026-07-28), `tests/integration/cli/http-era-negotiation.test.ts` and `stdio-era-negotiation.test.ts`, `scripts/mcp-conformance.mjs` (issues #1023, #1025) -Two tiers, split along the seam between this server's own code and the SDK transport. The clauses of the Streamable HTTP transport (spec 2025-11-25, the revision the pinned SDK speaks) that `createHttpApp` decides before it hands a request to `transport.handleRequest` — session routing, the Host/Origin allowlist, the stale-session reaper — are pinned one case per clause against the real server over raw HTTP: a missing `Mcp-Session-Id` is 400; an unknown, DELETEd or reaped one is 404 `Session not found`; an invalid `Origin` is 403; an unsupported `MCP-Protocol-Version` is 400. Raw HTTP because the SDK client folds every non-2xx answer into a thrown error, and a case about a status code has to read it. The 404 cases are the ones #1017 fixed; nothing in the suite could have caught the 400 they replaced. +Two tiers, split along the seam between this server's own code and the SDK, and two eras, served side by side on the same endpoint (issue #1025, step 2). The 2025-11-25 clauses of the Streamable HTTP transport that `createHttpApp` decides before it hands a request to `transport.handleRequest` — session routing, the Host/Origin allowlist, the stale-session reaper — are pinned one case per clause against the real server over raw HTTP: a missing `Mcp-Session-Id` is 400; an unknown, DELETEd or reaped one is 404 `Session not found`; an invalid `Origin` is 403; an unsupported `MCP-Protocol-Version` is 400. Raw HTTP because the SDK client folds every non-2xx answer into a thrown error, and a case about a status code has to read it. The 404 cases are the ones #1017 fixed; nothing in the suite could have caught the 400 they replaced. The 2026-07-28 file pins the same way what the SDK's sessionless handler answers once our router (`isLegacyRequest`) has sent a modern request to it: `server/discover` with no session minted, `tools/list` with its cache fields and a stable order, a stale `Mcp-Session-Id` ignored, the `-32602` / `-32020` / `-32022` refusals, a 2025 `initialize` still opening a session on the same endpoint, a claim-less POST still landing on the sessionful app's 400, and an open `subscriptions/listen` stream counting the process as attended. The two negotiation files drive the v2 SDK client in `auto`, pinned and `legacy` mode through a full mock debug session on each transport, so what a real client experiences on either era is pinned too. -Everything after the hand-off is the SDK's conformance, and `scripts/mcp-conformance.mjs` (`pnpm test:conformance`) runs the protocol org's `@modelcontextprotocol/conformance` suite for the scenarios that apply to a server without its reference fixtures — initialize, ping, the three listings, multiple SSE streams, DNS-rebinding protection — against a local `http` server, as the `mcp-conformance` CI lane on Linux. The suite has no session-lifecycle scenario, which is why the first tier exists; its CLI is pinned, and on Windows its exit code is unusable (it aborts at exit with a libuv assertion even after a pass), so the script reads the printed summary instead. The 2026-07-28 revision removes protocol-level sessions altogether; the legacy era this file pins stays supported under the spec's feature lifecycle policy. +Everything after the hand-off is the SDK's conformance, and `scripts/mcp-conformance.mjs` (`pnpm test:conformance` for the 2025-11-25 lane, `pnpm test:conformance:2026` for the 2026-07-28 one) runs the protocol org's `@modelcontextprotocol/conformance` suite against a local `http` server in two lanes on Linux. The legacy lane runs the pinned 0.1.16 CLI for the scenarios that apply to a server without its reference fixtures — initialize, ping, the three listings, multiple SSE streams, DNS-rebinding protection. The 2026-07-28 lane (`--era 2026-07-28`, CI job `mcp-conformance-2026`) runs the pinned 0.2 alpha's `--requirements 2026-07-28` set — everything the revision requires of a server — and lets the suite judge it: the suite reads the baseline (`scripts/conformance/expected-failures-2026-07-28.yaml`) with its own `--expected-failures`, fails on a failure or warning that is not listed and on a listed entry that passes, and its exit code is the verdict; the script adds no judging of its own. The baseline lists the checks that cannot be tested without the suite's reference fixtures (one in each `tools-call-*`, `prompts-get-*` and `resources-read-*` scenario, four in `server-stateless`) or without an optional feature this server does not declare (`completion-complete`, the `input-required-result-*` scenarios), each a single `scenario:check-id` with its reason — never a whole scenario, so the other checks of those scenarios, the schema validation of every message this server sends among them, stay enforced; by the suite's policy a missing prerequisite is a failure, not a skip, and a baseline is its named mechanism for that. The suite has no session-lifecycle scenario, which is why the first tier exists; both CLIs are pinned. One caveat for local runs: Node 23 to 24.19 and 26.0 to 26.6 abort inside `process.exit` on Windows (nodejs/node#56645, fixed in 24.20.0 and 26.7.0) after the suite has printed everything, so there the legacy lane reads the printed summaries and the 2026 lane reads the suite's closing line when that abort is in the output; CI runs Linux and takes the exit code. ## Coverage Strategy diff --git a/docs/getting-started.md b/docs/getting-started.md index 13e0c750..42cad5ec 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -99,12 +99,13 @@ Because `stdio` is the default subcommand, bare `mcp-debugger` starts in stdio m explicitly in a config file is still worth doing: it documents the intent. Console output is silenced in every mode so nothing can corrupt the JSON-RPC stream. -Both eras of the MCP protocol are served on `stdio`: a client that probes with `server/discover` -(Claude Code's v2 runtime does) gets the current 2026-07-28 revision, and a -client that opens with `initialize` gets 2025-11-25 or earlier, as before. Claude Code may keep a -stdio server it once saw answer as legacy pinned that way for up to seven days; restarting Claude -Code after upgrading the server, or waiting it out, lets it probe again. `http` speaks 2025-11-25 -and earlier today; the 2026-07-28 era follows there. +Both transports serve both eras of the MCP protocol: a client that probes with `server/discover` +(Claude Code's v2 runtime does, unless it has a cached verdict for that server) gets the current +2026-07-28 revision — sessionless on `http`, every request standing alone — and a client that +opens with `initialize` gets 2025-11-25 or earlier, as before. Claude Code may keep a stdio server +it once saw answer as legacy pinned that way for up to seven days (its own per-server MCP log +records the era it chose); restarting Claude Code after upgrading the server, or waiting it out, +lets it probe again. ### Claude Desktop, or any client with a JSON config diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 5f4ebc2f..5429fbb5 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -87,7 +87,7 @@ Creates a new debugging session. **Notes:** - Session IDs are UUIDs in the format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` - Sessions start in `"created"` state -- **One debugger core per process; the id is the capability (issue #1025).** On Streamable HTTP every MCP client of a process sees the same debug sessions: `list_debug_sessions` is the whole process's list, and any client holding an id can drive or close that session — run one `http` process per agent where agents must not see each other's work. A debug session outlives the MCP session that created it, so a one-shot script that opens a fresh MCP session per call reaches the sessions its previous run created, and a client that reconnects can resume or close what it left behind. A session ends by `close_debug_session`, by its program ending, or with the server: nothing expires on a timer by default. An operator can turn on the **idle policy** (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport): a session tree that no tool call has named for that long is then closed through the normal close cascade (a launched program is ended, an attached one released), unless a client is attending the process — while any client holds its SSE stream open, nothing expires; an in-flight `wait_for_stop` holds its tree; children never expire on a clock of their own. `list_debug_sessions` shows each session's `idleMs` and, with a policy on, `expiresInMs`; a later call naming a session the policy closed answers what happened, with `closedBy: "idle-policy"`. The server is built to scope sessions per principal once it verifies callers itself (no authenticated `http` mode ships yet; an authenticating reverse proxy does not identify callers to the server), so today every caller is in the one shared scope +- **One debugger core per process; the id is the capability (issue #1025).** On Streamable HTTP every MCP client of a process sees the same debug sessions: `list_debug_sessions` is the whole process's list, and any client holding an id can drive or close that session — run one `http` process per agent where agents must not see each other's work. A debug session outlives the MCP session that created it, so a one-shot script that opens a fresh MCP session per call reaches the sessions its previous run created, and a client that reconnects can resume or close what it left behind. A session ends by `close_debug_session`, by its program ending, or with the server: nothing expires on a timer by default. An operator can turn on the **idle policy** (`MCP_DEBUG_SESSION_IDLE_MS`, off by default on every transport): a session tree that no tool call has named for that long is then closed through the normal close cascade (a launched program is ended, an attached one released), unless a client is attending the process — while any client holds a stream open (a 2025 session's SSE stream, a 2026-07-28 client's `subscriptions/listen` stream), nothing expires; an in-flight `wait_for_stop` holds its tree; children never expire on a clock of their own. `list_debug_sessions` shows each session's `idleMs` and, with a policy on, `expiresInMs`; a later call naming a session the policy closed answers what happened, with `closedBy: "idle-policy"`. The server is built to scope sessions per principal once it verifies callers itself (no authenticated `http` mode ships yet; an authenticating reverse proxy does not identify callers to the server), so today every caller is in the one shared scope - When `port`, `processId` or an `adapterConfig` URI/info-file key is provided in `create_debug_session`, the server performs an inline attach (creating the session and immediately attaching). The session exists before the addressing gate can refuse the form (a `port` on C/C++, say), so such a refusal answers `success: false` with a usable `sessionId`. The response then mirrors `attach_to_process`: alongside `sessionId` it carries `state`, the attach `data` payload, an optional `warning`, and — when a requested post-attach pause has not landed yet — `pending: true`, with `message` saying so (`…; post-attach pause pending — the target stops when it next executes code; call wait_for_stop to block until it does (pass stopOnEntry: false to attach without pausing)`) --- diff --git a/package.json b/package.json index e2b08bb3..ca1974cc 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "bench:mem:docker": "node scripts/mem-bench.mjs --target docker --scenario all", "test:integration": "vitest run --project integration", "test:conformance": "node scripts/mcp-conformance.mjs", + "test:conformance:2026": "node scripts/mcp-conformance.mjs --era 2026-07-28", "posttest:integration": "node scripts/cleanup-test-processes.js", "test:e2e": "pnpm run pretest:docker && vitest run --project e2e", "pretest:e2e": "pnpm run build", diff --git a/scripts/conformance/expected-failures-2026-07-28.yaml b/scripts/conformance/expected-failures-2026-07-28.yaml new file mode 100644 index 00000000..650759c4 --- /dev/null +++ b/scripts/conformance/expected-failures-2026-07-28.yaml @@ -0,0 +1,64 @@ +# Not testable against mcp-debugger: the checks of +# `@modelcontextprotocol/conformance server --requirements 2026-07-28` that need something +# this server does not host. Nothing in this file is a protocol divergence of ours. +# +# The suite is built to test SDKs against a reference "everything server", and by its own +# policy a missing prerequisite is a failure, not a skip (suite AGENTS.md; issues #248, #372); +# a baseline like this one is the mechanism it names for a server without the fixtures. +# The suite reads this file itself (`--expected-failures`, scripts/mcp-conformance.mjs +# --era 2026-07-28) and fails the lane on a failure or warning that is NOT listed, and on a +# listed check that starts passing, so the file can only shrink honestly. +# +# Every entry is `scenario:check-id` — one check, never a whole scenario. A whole-scenario +# entry would excuse every check in it; this way each scenario's other checks stay enforced, +# above all `wire-schema-valid` (every message this server sent, its error answers included, +# is valid per the spec's JSON schema). One limit of the mechanism: the suite ignores an entry +# for a scenario it does not run or score, so a mistyped entry is silent — the failure it +# was meant to cover then shows as unexpected, which is what fails the lane. +# +# Measured 2026-11-10 against 0.2.0-alpha.12: 37 scored scenarios; the fixture names below +# are read from the suite's source. Upstream discussion of reporting these as not applicable: +# modelcontextprotocol/conformance#516. +server: + # --- Needs only a reference fixture this server does not have (18) --- + - tools-call-simple-text:tools-call-simple-text # tool test_simple_text + - tools-call-image:tools-call-image # tool test_image_content + - tools-call-audio:tools-call-audio # tool test_audio_content + - tools-call-embedded-resource:tools-call-embedded-resource # tool test_embedded_resource + - tools-call-mixed-content:tools-call-mixed-content # tool test_multiple_content_types + - tools-call-error:tools-call-error # tool test_error_handling + - tools-call-with-progress:tools-call-with-progress # tool test_tool_with_progress + - resources-read-text:resources-read-text # resource test://static-text + - resources-read-binary:resources-read-binary # resource test://static-binary + - resources-templates-read:resources-templates-read # resource template test://template/{id}/data + - prompts-get-simple:prompts-get-simple # prompt test_simple_prompt + - prompts-get-with-args:prompts-get-with-args # prompt test_prompt_with_arguments + - prompts-get-embedded-resource:prompts-get-embedded-resource # prompt test_prompt_with_embedded_resource + - prompts-get-with-image:prompts-get-with-image # prompt test_prompt_with_image + # Four checks of the core 2026 scenario report "Not testable: server does not list the + # diagnostic tool". The MUSTs behind them hold vacuously here: this server never requires a + # client capability and never sends a server-to-client request or a log notification. + - server-stateless:sep-2575-server-rejects-undeclared-capability # tool test_missing_capability + - server-stateless:sep-2575-missing-capability-http-400 # tool test_missing_capability + - server-stateless:sep-2575-http-server-no-independent-requests-on-stream # tool test_streaming_elicitation + - server-stateless:sep-2575-server-no-log-without-loglevel # tool test_logging_tool + + # --- Needs an optional feature this server does not declare (13) --- + # `completions` is not declared; the scenario also asks about the reference prompt. + - completion-complete:completion-complete # completion/complete on prompt test_prompt_with_arguments + # Input-required results (SEP-2322): a tool that answers by asking the client for + # elicitation, sampling or roots. Each scenario drives a reference tool or prompt. + - input-required-result-basic-elicitation:sep-2322-elicitation-incomplete # tool test_input_required_result_elicitation + - input-required-result-basic-sampling:sep-2322-sampling-incomplete # tool test_input_required_result_sampling + - input-required-result-basic-list-roots:sep-2322-list-roots-incomplete # tool test_input_required_result_list_roots + - input-required-result-request-state:sep-2322-request-state-incomplete # tool test_input_required_result_request_state + - input-required-result-multiple-input-requests:sep-2322-multiple-inputs-incomplete # tool test_input_required_result_multiple_inputs + - input-required-result-multi-round:sep-2322-multi-round-r1 # tool test_input_required_result_multi_round + - input-required-result-non-tool-request:sep-2322-non-tool-incomplete # prompt test_input_required_result_prompt + - input-required-result-result-type:sep-2322-result-type-included # tool test_input_required_result_elicitation + - input-required-result-tampered-state:sep-2322-reject-tampered-state # tool test_input_required_result_tampered_state + - input-required-result-capability-check:sep-2322-respect-client-capabilities # tools test_input_required_result_capabilities, _elicitation + # These two end in a WARNING, not a failure (the unknown tool answers with an error where + # the suite recommends another input request); the suite's baseline counts a warning. + - input-required-result-missing-input-response:sep-2322-missing-response-rerequests # tool test_input_required_result_elicitation, prompt _prompt + - input-required-result-ignore-extra-params:sep-2322-ignore-unexpected-params # tool test_input_required_result_elicitation diff --git a/scripts/mcp-conformance.d.mts b/scripts/mcp-conformance.d.mts index aad7f25b..13623928 100644 --- a/scripts/mcp-conformance.d.mts +++ b/scripts/mcp-conformance.d.mts @@ -19,3 +19,24 @@ export function runConformance(options?: { verbose?: boolean; log?: (line: string) => void; }): Promise; + +// The 2026-07-28 requirements lane (issue #1025, step 2): judged by the suite itself. +export const CONFORMANCE_CLI_2026: string; +export const REQUIREMENTS_2026: string; +/** The suite's verdict on a `--requirements … --expected-failures` run. */ +export interface BaselineVerdict { + ok: boolean; + /** Node's Windows exit abort hid the exit code; the suite's closing line was read instead. */ + abortedAtExit: boolean; + /** What the suite said was wrong, colour stripped; empty on a pass. */ + report: string; +} +export function requirementsArgs(revision: string, port: number): string[]; +export function baselineReport(output: string): string; +export function judgeBaselineRun(run: { code: number | null; output: string }): BaselineVerdict; +export function runRequirements(options?: { + root?: string; + revision?: string; + verbose?: boolean; + log?: (line: string) => void; +}): Promise; diff --git a/scripts/mcp-conformance.mjs b/scripts/mcp-conformance.mjs index 3bbeffe2..a692e0ed 100644 --- a/scripts/mcp-conformance.mjs +++ b/scripts/mcp-conformance.mjs @@ -3,17 +3,35 @@ * mcp-debugger `http` server and fail on any failed check. * * The suite (`@modelcontextprotocol/conformance`, pinned) is the protocol org's own client-side - * check of a server; each scenario cites the spec clause it covers. Most of its scenarios drive - * the reference "everything" server's fixtures (`test_simple_text`, `test://static-text`, - * `logging/setLevel`, …) that this server does not have, so they are not run: the list below is - * the transport- and listing-level set that applies to any server. What the suite does NOT - * cover — the session-lifecycle clauses this server answers with its own code — is pinned by - * `tests/integration/cli/http-spec-contract.test.ts` instead. + * check of a server; each scenario cites the spec clause it covers. It is built to test SDKs + * against a reference "everything" server, so most of its scenarios drive that server's + * fixtures (`test_simple_text`, `test://static-text`, `logging/setLevel`, …), which this server + * does not have. * - * The CLI's exit code is not trusted: on Windows it aborts at exit with a libuv assertion even - * when every check passed, so the printed `Passed: n/m, k failed` summary is what counts. + * Two lanes: + * + * - The 2025-11-25 lane (the default) runs the list below — the transport- and listing-level + * scenarios that apply to any server — one `--scenario` at a time on the 0.1 line, and reads + * each scenario's printed `Passed: n/m, k failed` summary. What the suite does NOT cover — the + * session-lifecycle clauses this server answers with its own code — is pinned by + * `tests/integration/cli/http-spec-contract.test.ts` instead. + * - The 2026-07-28 lane (`--era 2026-07-28`; issue #1025, step 2) runs everything the revision + * requires of a server (`--requirements 2026-07-28`) on the suite's 0.2 line, which the SDKs + * themselves run, and lets the suite judge it: the suite reads the baseline + * (`scripts/conformance/expected-failures-2026-07-28.yaml`, the scenarios and checks that + * cannot be tested without its reference fixtures) with `--expected-failures`, fails on a + * failure or warning that is not listed and on a listed entry that passes, and its exit code + * is the verdict. This script adds no judging of its own. + * + * One exception to trusting an exit code, on both lanes: Node 23 to 24.19 and 26.0 to 26.6 abort + * inside `process.exit` on Windows with a libuv assertion (nodejs/node#56645, fixed in 24.20.0 + * and 26.7.0), after the suite has printed everything. CI runs Linux and never sees it. On an + * affected Windows Node the 2025 lane's printed summary already is the verdict, and the 2026 + * lane reads the suite's own closing line ("Baseline check passed: …") — only when that + * assertion is in the output. * * Usage: node scripts/mcp-conformance.mjs [scenario ...] (default: the list below) + * node scripts/mcp-conformance.mjs --era 2026-07-28 [--verbose] */ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; @@ -23,6 +41,9 @@ import { fileURLToPath } from 'node:url'; import { isMain } from './lib/is-main.mjs'; export const CONFORMANCE_CLI = '@modelcontextprotocol/conformance@0.1.16'; +/** The 0.2 line, where the 2026-07-28 scenarios live; pinned to an exact prerelease. */ +export const CONFORMANCE_CLI_2026 = '@modelcontextprotocol/conformance@0.2.0-alpha.12'; +export const REQUIREMENTS_2026 = '2026-07-28'; /** The scenarios that apply to a server without the reference fixtures (measured 2026-10-27: all pass). */ export const APPLICABLE_SCENARIOS = [ @@ -51,6 +72,44 @@ export function judgeScenario(name, output) { return { name, ok: true, summary }; } +/** The arguments of a `--requirements` run judged against the baseline; the path is relative to the repository root. */ +export function requirementsArgs(revision, port) { + return [ + 'server', + '--url', `http://127.0.0.1:${port}/mcp`, + '--requirements', revision, + '--expected-failures', `scripts/conformance/expected-failures-${revision}.yaml`, + ]; +} + +/** The suite's closing line when every failure and warning is in the baseline and no entry is stale. */ +const BASELINE_PASSED = 'Baseline check passed: all failures are expected.'; +/** Node's abort inside process.exit on Windows (nodejs/node#56645; fixed in 24.20.0 and 26.7.0). */ +const EXIT_ABORT = /Assertion failed: !\(handle->flags & UV_HANDLE_CLOSING\)/; + +/** + * What the suite said was wrong, in its own words: from its first finding list (stale entries, + * unexpected failures) to the end, colour stripped; the tail of the output when it never got + * that far. + */ +export function baselineReport(output) { + // eslint-disable-next-line no-control-regex -- stripping the CLI's ANSI colour codes + const text = output.replace(/\u001b\[[0-9;]*m/g, ''); + const at = text.search(/^(Stale baseline entries|Unexpected failures \(not in baseline\))/m); + return (at >= 0 ? text.slice(at) : text.split(/\r?\n/).slice(-40).join('\n')).trim(); +} + +/** + * The verdict of a `--requirements … --expected-failures` run: the suite's exit code. Only when + * the process died of Node's Windows exit abort — after the suite printed everything — is the + * suite's own closing line read instead. + */ +export function judgeBaselineRun({ code, output }) { + const abortedAtExit = code !== 0 && EXIT_ABORT.test(output); + const ok = code === 0 || (abortedAtExit && output.includes(BASELINE_PASSED)); + return { ok, abortedAtExit, report: ok ? '' : baselineReport(output) }; +} + function freePort() { return new Promise((resolve, reject) => { const probe = createServer(); @@ -76,24 +135,26 @@ async function waitForHealth(port, timeoutMs) { return false; } -/** Every argument the CLI is given is one of ours (a package spec, a loopback URL, a scenario name). */ -const SAFE_ARGUMENT = /^[A-Za-z0-9@./:_-]+$/; +/** Every argument the CLI is given is one of ours (a package spec, a loopback URL, a scenario name, a repository-relative path). */ +const SAFE_ARGUMENT = /^[A-Za-z0-9@./:_\\-]+$/; -function runCli(args, cwd) { +function runCli(args, cwd, cli = CONFORMANCE_CLI) { return new Promise((resolve) => { - const argv = ['--yes', CONFORMANCE_CLI, ...args]; + const argv = ['--yes', cli, ...args]; const unsafe = argv.find((arg) => !SAFE_ARGUMENT.test(arg)); if (unsafe !== undefined) throw new Error(`refusing to pass "${unsafe}" to the conformance CLI`); // Windows: `npx` is a .cmd shim, which Node spawns only through a shell. The argv is checked // above, so one command string (no args array) keeps the shell out of DEP0190's warning. + // No colour: the summary line is parsed, and FORCE_COLOR would wrap it in escapes. + const env = { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }; const child = process.platform === 'win32' - ? spawn(['npx', ...argv].join(' '), { cwd, shell: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) - : spawn('npx', argv, { cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + ? spawn(['npx', ...argv].join(' '), { cwd, env, shell: true, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + : spawn('npx', argv, { cwd, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stderr.on('data', (chunk) => { output += chunk.toString(); }); - child.on('close', () => resolve(output)); - child.on('error', (error) => resolve(`${output}\n${error.message}`)); + child.on('close', (code) => resolve({ output, code })); + child.on('error', (error) => resolve({ output: `${output}\n${error.message}`, code: null })); }); } @@ -121,7 +182,7 @@ export async function runConformance(options = {}) { } const verdicts = []; for (const name of scenarios) { - const output = await runCli(['server', '--url', `http://127.0.0.1:${port}/mcp`, '--scenario', name], root); + const { output } = await runCli(['server', '--url', `http://127.0.0.1:${port}/mcp`, '--scenario', name], root); const verdict = judgeScenario(name, output); verdicts.push(verdict); const counts = verdict.summary ? `${verdict.summary.passed}/${verdict.summary.total}` : '-'; @@ -134,21 +195,80 @@ export async function runConformance(options = {}) { } } +/** + * The 2026-07-28 lane: one `--requirements` run of the 0.2 CLI against a local `http` server, + * judged by the suite against the baseline. Answers `{ ok, abortedAtExit, report, output }`. + */ +export async function runRequirements(options = {}) { + const root = options.root ?? fileURLToPath(new URL('../', import.meta.url)); + const entry = path.join(root, 'dist', 'index.js'); + if (!existsSync(entry)) throw new Error(`${entry} not found: run the build first`); + const revision = options.revision ?? REQUIREMENTS_2026; + const log = options.log ?? console.log; + + const port = await freePort(); + const server = spawn(process.execPath, [entry, 'http', '-p', String(port)], { + cwd: root, + env: { ...process.env, MCP_SKIP_ORPHAN_REAPERS: '1' }, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let serverOutput = ''; + server.stdout.on('data', (chunk) => { serverOutput += chunk.toString(); }); + server.stderr.on('data', (chunk) => { serverOutput += chunk.toString(); }); + + try { + if (!(await waitForHealth(port, 20_000))) { + throw new Error(`the server never answered /health on port ${port}:\n${serverOutput}`); + } + // cwd is the repository root, so the baseline's relative path resolves and no temp or + // home path — which may hold a space — ever reaches the shell. + const { output, code } = await runCli(requirementsArgs(revision, port), root, CONFORMANCE_CLI_2026); + const verdict = judgeBaselineRun({ code, output }); + if (options.verbose) log(output); + else if (!verdict.ok) log(verdict.report); + return { ...verdict, output }; + } finally { + server.kill(); + } +} + if (isMain(import.meta.url)) { const verbose = process.argv.includes('--verbose'); - const scenarios = process.argv.slice(2).filter((arg) => !arg.startsWith('--')); - runConformance({ scenarios, verbose }) - .then((verdicts) => { - const failed = verdicts.filter((verdict) => !verdict.ok); - if (failed.length) { - console.error(`MCP conformance: ${failed.length} of ${verdicts.length} scenario(s) failed`); + const eraIndex = process.argv.indexOf('--era'); + if (eraIndex !== -1) { + const revision = process.argv[eraIndex + 1]; + runRequirements({ revision, verbose }) + .then(({ ok, abortedAtExit }) => { + const how = abortedAtExit + ? ' (read from the suite\'s closing line: this Node build aborts at exit on Windows — nodejs/node#56645, fixed in 24.20.0 and 26.7.0)' + : ''; + if (ok) { + console.log(`MCP conformance ${revision}: the suite's baseline check passed against ${CONFORMANCE_CLI_2026}${how}`); + } else { + console.error(`MCP conformance ${revision}: the suite's baseline check failed against ${CONFORMANCE_CLI_2026}${how}`); + process.exitCode = 1; + } + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; - } else { - console.log(`MCP conformance: ${verdicts.length} scenario(s) passed against ${CONFORMANCE_CLI}`); - } - }) - .catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); + }); + } else { + const scenarios = process.argv.slice(2).filter((arg) => !arg.startsWith('--')); + runConformance({ scenarios, verbose }) + .then((verdicts) => { + const failed = verdicts.filter((verdict) => !verdict.ok); + if (failed.length) { + console.error(`MCP conformance: ${failed.length} of ${verdicts.length} scenario(s) failed`); + process.exitCode = 1; + } else { + console.log(`MCP conformance: ${verdicts.length} scenario(s) passed against ${CONFORMANCE_CLI}`); + } + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); + } } diff --git a/src/cli/http-command.ts b/src/cli/http-command.ts index 154a9056..e5e26212 100644 --- a/src/cli/http-command.ts +++ b/src/cli/http-command.ts @@ -3,10 +3,11 @@ import type { Express, Request, Response, NextFunction } from 'express'; import express from 'express'; import { randomUUID } from 'crypto'; import { IncomingMessage, ServerResponse } from 'http'; -import { isInitializeRequest, ProtocolErrorCode } from '@modelcontextprotocol/server'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { createMcpHandler, isInitializeRequest, isLegacyRequest, ProtocolErrorCode, ProtocolError } from '@modelcontextprotocol/server'; +import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node'; import { DebugMcpServer, type DebugMcpServerOptions } from '../server.js'; import { createProtocolServer } from '../server/protocol-facade.js'; +import { handlerSink } from '../server/notification-hub.js'; import { attachSharedFileTransport } from '../utils/logger.js'; import type { HttpOptions } from './setup.js'; import { @@ -68,6 +69,40 @@ interface SessionData { /** Largest JSON-RPC request body accepted; also named in the 413 the parser answers with (issue #670). */ const JSON_BODY_LIMIT = '10mb'; +/** + * What the SDK's modern handler says when it refuses a request it was sent (as + * opposed to failing on its own): the messages it builds at its rejection + * points. Matched by their openings because the SDK reports them as plain + * Errors; a wording it changes falls back to the error level, never the reverse. + */ +const SDK_REFUSAL = /^(Rejected |Unsupported Media Type)/; + +/** + * Whether an error the modern handler reported is a refusal of the caller's + * request rather than a fault. Besides the plain-Error refusals above, the SDK + * reports some as typed protocol errors — an unsupported protocol revision + * (-32022), a missing client capability — which are the caller's doing by + * definition; only an internal error is the server's. + */ +function isRefusedRequest(error: Error): boolean { + if (error instanceof ProtocolError) { + return error.code !== ProtocolErrorCode.InternalError; + } + return SDK_REFUSAL.test(error.message); +} + +/** The Node adapter's body-limit error (its class is not exported): the client sent too much. */ +function isBodyTooLarge(error: unknown): error is Error & { status: 413 } { + return error instanceof Error && (error as { status?: unknown }).status === 413; +} +/** The same limit in bytes, for the SDK's classifier and modern handler (their default is 4 MiB). */ +const JSON_BODY_LIMIT_BYTES = 10 * 1024 * 1024; + +/** A modern-era `subscriptions/listen` request: its response is a long-lived stream, so the process is attended while it is open. */ +function isListenRequest(body: unknown): boolean { + return typeof body === 'object' && body !== null && (body as { method?: unknown }).method === 'subscriptions/listen'; +} + /** Idle window before a streamless HTTP session is reaped (issue #337). */ const DEFAULT_STALE_SESSION_MS = 30 * 60 * 1000; /** @@ -203,10 +238,52 @@ export function createHttpApp( logLevel: options.logLevel, logFile: options.logFile, }); - // While any client holds an open SSE stream the process is attended and the - // idle policy closes nothing; the modern era's listen streams join this - // count when they are served. One predicate for the supplier and /health. + // The 2026-07-28 leg (issue #1025, step 2): the SDK's sessionless handler, + // strict — a 2025-era request that reaches it is refused with the supported + // versions — behind the request classifier in handleMcpRequest, which sends + // 2025-shaped traffic to the sessionful app below. One facade per request, + // delivering nothing itself: the handler's notifier is this leg's hub sink, + // routing to the open subscriptions/listen streams. Host/Origin stay ours + // (the allowlist middleware runs first); the body limit matches the parser's. + const modern = createMcpHandler( + ({ era }) => protocolServerFactory(core, { era, deliver: 'external' }), + { + legacy: 'reject', + maxRequestBodySize: JSON_BODY_LIMIT_BYTES, + onerror: (error: Error) => { + // The SDK reports every request it refuses through the same hook as its + // own faults, as a plain Error. A refusal is the caller's doing — a junk + // body, a missing header, a wrong content type — and anyone who can reach + // the port can cause one, so it is a warning; only a real fault is an error. + if (isRefusedRequest(error)) { + logger.warn('Modern-era MCP request refused', { reason: error.message }); + } else { + logger.error('Modern-era MCP handler error', { error }); + } + }, + } + ); + const detachModernSink = core.notifications.addSink(handlerSink(modern.notify)); + const modernNode = toNodeHandler(modern, { + // The same limit as express.json, the classifier and the handler: a body between two + // different limits would pass one entry and die in the next. + maxRequestBodySize: JSON_BODY_LIMIT_BYTES, + onerror: (error: Error) => { + logger.error('Modern-era MCP request failed', { error }); + }, + }); + /** Requests served per era since start, for /health and the #1025 telemetry. */ + const requests = { legacy: 0, modern: 0 }; + /** Open modern subscriptions/listen streams (their responses are long-lived). */ + let modernListenStreams = 0; + + // While any client holds an open SSE stream — a 2025 session's GET stream or + // a 2026 subscriptions/listen stream — the process is attended and the idle + // policy closes nothing. One predicate for the supplier and /health. const attended = (): boolean => { + if (modernListenStreams > 0) { + return true; + } for (const session of httpSessions.values()) { if (session.openStreams > 0) { return true; @@ -264,7 +341,10 @@ export function createHttpApp( (app as any).staleSweepTimer = staleSweepTimer; // CORS — Mcp-Session-Id and last-event-id must be exposed for the MCP Inspector - // and for clients to read the session ID from the Initialize response. + // and for clients to read the session ID from the Initialize response. The + // 2026-07-28 revision requires Mcp-Method and Mcp-Name on every POST and + // mirrors tool parameters into Mcp-Param-* headers, so a browser preflight's + // own requested headers are echoed back as allowed. app.use((req: Request, res: Response, next: NextFunction) => { // A request carrying an Origin has already passed the allowlist above: // echo that origin (and Vary on it) instead of granting every origin. @@ -274,9 +354,12 @@ export function createHttpApp( res.header('Vary', 'Origin'); } res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + const requestedHeaders = req.headers['access-control-request-headers']; + const requested = Array.isArray(requestedHeaders) ? requestedHeaders.join(', ') : requestedHeaders; res.header( 'Access-Control-Allow-Headers', - 'Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-Id' + 'Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-Id' + + (requested ? `, ${requested}` : '') ); res.header( 'Access-Control-Expose-Headers', @@ -300,6 +383,27 @@ export function createHttpApp( const sessionIdHeader = req.headers['mcp-session-id']; const sessionId = Array.isArray(sessionIdHeader) ? sessionIdHeader[0] : sessionIdHeader; + // Which era is this request? The SDK's own classifier decides (issue + // #1025, step 2): a POST carrying the 2026 per-request envelope — or a + // modern protocol header the strict handler must refuse — is the modern + // leg's; anything 2025-shaped (initialize, a session id, GET, DELETE, a + // claim-less POST) stays with the sessionful app below, unchanged. The + // body Express already parsed is handed on so nothing re-reads the stream. + const body = req.method === 'POST' ? req.body : undefined; + const probe = await toWebRequest(req, body, { maxRequestBodySize: JSON_BODY_LIMIT_BYTES }); + if (!(await isLegacyRequest(probe, body, { maxRequestBodySize: JSON_BODY_LIMIT_BYTES }))) { + requests.modern++; + if (isListenRequest(body)) { + modernListenStreams++; + res.once('close', () => { + modernListenStreams = Math.max(0, modernListenStreams - 1); + }); + } + await modernNode(req, res, body); + return; + } + requests.legacy++; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && httpSessions.has(sessionId)) { @@ -387,6 +491,17 @@ export function createHttpApp( await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body); } catch (error) { + if (isBodyTooLarge(error)) { + // Classifying reads a body Express did not parse (a non-JSON content + // type). Over the limit is the caller's error, answered as the JSON + // parser answers it — not an "Internal error" and not an error log. + if (!res.headersSent) { + res.status(413).json( + jsonRpcErrorBody(ProtocolErrorCode.InvalidRequest, `Request body too large (limit ${JSON_BODY_LIMIT})`, error.message) + ); + } + return; + } logger.error('Error handling MCP request', { error }); if (!res.headersSent) { res.status(500).json( @@ -428,7 +543,8 @@ export function createHttpApp( listening: { ...listening }, connections: httpSessions.size, attended: attended(), - listenStreams: 0, + listenStreams: modernListenStreams, + requests: { ...requests }, sessions: Array.from(httpSessions.keys()), details: sessions, debugSessions: core.sessionManager.getAllSessions().map((debugSession) => { @@ -468,6 +584,15 @@ export function createHttpApp( // eslint-disable-next-line @typescript-eslint/no-explicit-any (app as any).core = core; // eslint-disable-next-line @typescript-eslint/no-explicit-any + (app as any).modernHandler = { + close: async (): Promise => { + // The sink first: the core closes its sessions after this, and each close + // fires a resource change that a closed handler has no stream for. + detachModernSink(); + await modern.close(); + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (app as any).bindAddress = bind; // eslint-disable-next-line @typescript-eslint/no-explicit-any (app as any).listening = listening; @@ -525,6 +650,8 @@ export async function handleHttpCommand( const httpSessions = (app as any).httpSessions as Map; // eslint-disable-next-line @typescript-eslint/no-explicit-any const core = (app as any).core as DebugMcpServer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const modernHandler = (app as any).modernHandler as { close: () => Promise }; // The one core (issue #1025, step 2): started once, before anything can // reach it; its idle sweep is armed here. await core.start(); @@ -581,10 +708,16 @@ export async function handleHttpCommand( }, 15000); hardExit.unref?.(); - // Close every active transport, then stop the one core (every debug - // session with it), bounded so one wedged proxy cannot stall the whole - // shutdown. Transports first: no request lands on a core that is closing. + // Close the modern handler (its listen streams end gracefully), then every + // active transport, then the one core (every debug session with it), + // bounded so one wedged proxy cannot stall the whole shutdown. Transports + // before the core: no request lands on a core that is closing. const stopWork = (async () => { + try { + await modernHandler.close(); + } catch (err) { + logger.error('Error closing the modern-era handler during shutdown', { error: err }); + } for (const { transport } of httpSessions.values()) { try { await transport.close(); diff --git a/src/server/notification-hub.ts b/src/server/notification-hub.ts index 7bd30e9e..b1b4f3d2 100644 --- a/src/server/notification-hub.ts +++ b/src/server/notification-hub.ts @@ -118,6 +118,25 @@ export class NotificationHub { } } +/** The slice of the SDK's `ServerNotifier` (an HTTP handler's `notify`) the hub feeds. */ +export interface HandlerNotifier { + resourceUpdated(uri: string): void | Promise; + resourcesChanged(): void | Promise; +} + +/** + * The sink for the 2026-07-28 HTTP leg: its facades serve one request each and + * cannot send anything afterwards, so the hub feeds the handler's own notifier, + * which routes to the `subscriptions/listen` streams and filters by what each + * listen named. Nothing per URI to forget. + */ +export function handlerSink(notify: HandlerNotifier): NotificationSink { + return { + resourceUpdated: (uri: string) => notify.resourceUpdated(uri), + resourcesChanged: () => notify.resourcesChanged() + }; +} + /** * The sink a protocol facade attaches for its own SDK Server. With a filter * (a legacy-era facade) only the URIs that facade subscribed to through diff --git a/src/server/output-resources.ts b/src/server/output-resources.ts index 656ef574..3f28f517 100644 --- a/src/server/output-resources.ts +++ b/src/server/output-resources.ts @@ -64,6 +64,16 @@ export class FacadeSubscriptions implements ResourceSubscriptions { } } +/** + * The one refusal for a resource this caller cannot have: unknown, gone, or + * outside its scope — all the same answer. The error's data names the URI + * that was asked for (SEP-2164's SHOULD), so a client can tell which of its + * requests this was without parsing the message. + */ +function unknownResource(uri: string): WireMcpError { + return new WireMcpError(ProtocolErrorCode.InvalidParams, `Unknown resource: ${uri}`, { uri }); +} + /** * Registers the MCP resource handlers. Only debuggee output is subscribable; * the bounded proxy-log tail is an on-demand diagnostic snapshot. @@ -98,6 +108,11 @@ export function registerResourceHandlers( }; }); + // This server exposes no resource templates, but a server advertising the + // resources capability must answer the method — the 2026-07-28 conformance + // suite's caching scenario asks it (issue #1025); the SDK stamps the cache fields. + server.setRequestHandler('resources/templates/list', async () => ({ resourceTemplates: [] })); + server.setRequestHandler('resources/read', async (request, callCtx) => { const uri = request.params.uri; const outputSessionId = parseOutputResourceUri(uri); @@ -108,11 +123,11 @@ export function registerResourceHandlers( ? sessionManager.getSession(sessionId) : undefined; if (!session) { - throw new WireMcpError(ProtocolErrorCode.InvalidParams, `Unknown resource: ${uri}`); + throw unknownResource(uri); } if (proxyLogSessionId) { if (!session.logDir) { - throw new WireMcpError(ProtocolErrorCode.InvalidParams, `Unknown resource: ${uri}`); + throw unknownResource(uri); } const text = await readProxyLogTail( fileSystem, @@ -142,7 +157,7 @@ export function registerResourceHandlers( const uri = request.params.uri; const sessionId = parseOutputResourceUri(uri); if (!sessionId || !sessionManager.isSessionVisibleTo(sessionId, ownerKeyOf(callCtx))) { - throw new WireMcpError(ProtocolErrorCode.InvalidParams, `Unknown resource: ${uri}`); + throw unknownResource(uri); } subscriptions.subscribe(uri); return {}; diff --git a/src/server/protocol-facade.ts b/src/server/protocol-facade.ts index 421e6705..629de9c5 100644 --- a/src/server/protocol-facade.ts +++ b/src/server/protocol-facade.ts @@ -4,7 +4,7 @@ * * The 2026-07-28 serving entries build a Server per HTTP request * (`createMcpHandler`) or per stdio connection plus a discarded probe instance - * (`serveStdio`), so a facade has to be cheap: the eight MCP handlers closing + * (`serveStdio`), so a facade has to be cheap: the nine MCP handlers closing * over the core, the error and initialized hooks, and one notification sink * that lives exactly as long as the facade does. No dependency container, no * SessionManager, no logger of its own. diff --git a/tests/core/unit/server/mcp-wire-errors.test.ts b/tests/core/unit/server/mcp-wire-errors.test.ts index be4b438f..fa8c43ce 100644 --- a/tests/core/unit/server/mcp-wire-errors.test.ts +++ b/tests/core/unit/server/mcp-wire-errors.test.ts @@ -132,6 +132,9 @@ describe('protocol errors reach SDK clients as the bare reason, with the code be const error = await caught(request); expect(error.code).toBe(ProtocolErrorCode.InvalidParams); expect(error.message).toBe(`Unknown resource: ${uri}`); + // SEP-2164: the error names the URI that was asked for, in its data + // (a SHOULD; the conformance suite's sep-2164-data-uri check). + expect(error.data).toEqual({ uri }); } }); }); diff --git a/tests/core/unit/server/notification-hub.test.ts b/tests/core/unit/server/notification-hub.test.ts index 62e36772..77766a3d 100644 --- a/tests/core/unit/server/notification-hub.test.ts +++ b/tests/core/unit/server/notification-hub.test.ts @@ -11,6 +11,7 @@ import { NotificationHub, OUTPUT_UPDATE_DEBOUNCE_MS, facadeSink, + handlerSink, type NotificationSink } from '../../../../src/server/notification-hub.js'; import { FacadeSubscriptions } from '../../../../src/server/output-resources.js'; @@ -179,6 +180,20 @@ describe('NotificationHub forget guard', () => { }); }); +describe('handlerSink', () => { + it('forwards every ping and list change to the HTTP handler notifier, which filters by its listen streams', async () => { + const notify = { resourceUpdated: vi.fn(), resourcesChanged: vi.fn(), toolsChanged: vi.fn(), promptsChanged: vi.fn() }; + const sink = handlerSink(notify); + + await sink.resourceUpdated(URI); + await sink.resourcesChanged(); + + expect(notify.resourceUpdated).toHaveBeenCalledWith(URI); + expect(notify.resourcesChanged).toHaveBeenCalledTimes(1); + expect(sink.forget).toBeUndefined(); + }); +}); + describe('facadeSink', () => { const server = () => ({ sendResourceUpdated: vi.fn().mockResolvedValue(undefined), diff --git a/tests/core/unit/server/protocol-facade-transport.test.ts b/tests/core/unit/server/protocol-facade-transport.test.ts index 30aea085..64db5130 100644 --- a/tests/core/unit/server/protocol-facade-transport.test.ts +++ b/tests/core/unit/server/protocol-facade-transport.test.ts @@ -8,9 +8,9 @@ * connection's Server be collected. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { InMemoryTransport } from '@modelcontextprotocol/server'; +import { createMcpHandler, InMemoryTransport } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; -import { Client } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { DebugMcpServer } from '../../../../src/server.js'; import { createProtocolServer } from '../../../../src/server/protocol-facade.js'; import { SessionManager } from '../../../../src/session/session-manager.js'; @@ -137,6 +137,25 @@ describe('a 2026-07-28 client is identified in the log', () => { expect(identified().map(([, meta]) => (meta as { client: string }).client)).toEqual(['era-client 1.2.3', 'other-client 1.2.3']); }); + it('logs an http client once, however many per-request facades serve it', async () => { + // The http leg as the command builds it: a strict modern handler over a + // facade per request, each of which delivers nothing itself. + const handler = createMcpHandler(({ era }) => createProtocolServer(core, { era, deliver: 'external' }), { legacy: 'reject' }); + const client = new Client({ name: 'http-client', version: '2.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + await client.connect(new StreamableHTTPClientTransport(new URL('http://mcp.test/mcp'), { + fetch: (input, init) => handler.fetch(new Request(input, init)) + })); + closers.push(() => client.close(), () => handler.close()); + + await client.listPrompts(); + await client.listPrompts(); + await client.listResources(); + + expect(identified()).toEqual([ + ['[MCP Server] Client identified', { era: 'modern', protocolVersion: '2026-07-28', client: 'http-client 2.0.0' }] + ]); + }); + it('leaves a 2025 client to its Client initialized line', async () => { await (await connect('legacy-client', 'legacy')).listPrompts(); diff --git a/tests/core/unit/server/protocol-facade.test.ts b/tests/core/unit/server/protocol-facade.test.ts index c610924f..76c03eab 100644 --- a/tests/core/unit/server/protocol-facade.test.ts +++ b/tests/core/unit/server/protocol-facade.test.ts @@ -1,7 +1,7 @@ /** * The protocol facade (issue #1025, step 2): one cheap SDK `Server` per * connection or request over the one shared DebugMcpServer core. It carries - * the eight MCP handlers, the error and initialized hooks, and one + * the nine MCP handlers, the error and initialized hooks, and one * notification sink that lives exactly as long as the facade does. The core * never constructs a Server itself any more. */ @@ -119,7 +119,7 @@ describe('createProtocolServer', () => { expect(b.sendResourceUpdated).toHaveBeenCalledWith({ uri: 'debug://sessions/sess-2/output' }); }); - it('registers exactly the eight MCP handlers', () => { + it('registers exactly the nine MCP handlers', () => { legacy(); const [server] = mockServers; @@ -130,6 +130,7 @@ describe('createProtocolServer', () => { 'resources/list', 'resources/read', 'resources/subscribe', + 'resources/templates/list', 'resources/unsubscribe', 'tools/call', 'tools/list' diff --git a/tests/core/unit/server/server-initialization.test.ts b/tests/core/unit/server/server-initialization.test.ts index 0487fc17..8964e0ff 100644 --- a/tests/core/unit/server/server-initialization.test.ts +++ b/tests/core/unit/server/server-initialization.test.ts @@ -97,9 +97,10 @@ describe('Server Initialization Tests', () => { it('a facade over the core registers the tool, resource and prompt handlers', () => { debugServer = createDebugServerWithFacade(); - // ListTools + CallTool, ListResources/ReadResource/Subscribe/Unsubscribe (issue #218), + // ListTools + CallTool, ListResources/ReadResource/Subscribe/Unsubscribe (issue #218) + // + ListResourceTemplates (empty; the resources capability must answer it, issue #1025), // plus ListPrompts/GetPrompt for the debugging-workflow prompt - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(8); + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(9); }); it('a facade routes SDK errors to the core logger', () => { diff --git a/tests/core/unit/server/server-resources.test.ts b/tests/core/unit/server/server-resources.test.ts index 263ef4d9..58312379 100644 --- a/tests/core/unit/server/server-resources.test.ts +++ b/tests/core/unit/server/server-resources.test.ts @@ -185,6 +185,16 @@ describe('Server Output Resources Tests', () => { }); }); + describe('resources/templates/list', () => { + it('answers an empty list: this server exposes no resource templates, and a resources capability must answer the method (issue #1025)', async () => { + const { listResourceTemplatesHandler } = getResourceHandlers(mockServer); + + const result = await listResourceTemplatesHandler({ method: 'resources/templates/list', params: {} }); + + expect(result).toEqual({ resourceTemplates: [] }); + }); + }); + describe('resources/read', () => { it('returns the verbatim transcript', async () => { const session = mockSession(); diff --git a/tests/core/unit/server/server-test-helpers.ts b/tests/core/unit/server/server-test-helpers.ts index 34f2f6fd..5fd5d5f0 100644 --- a/tests/core/unit/server/server-test-helpers.ts +++ b/tests/core/unit/server/server-test-helpers.ts @@ -276,7 +276,9 @@ export function getResourceHandlers(mockServer: MockServer) { subscribeHandler: findHandler(mockServer, 'resources/subscribe') as (request: TestRequest) => Promise, unsubscribeHandler: findHandler(mockServer, 'resources/unsubscribe') as - (request: TestRequest) => Promise + (request: TestRequest) => Promise, + listResourceTemplatesHandler: findHandler(mockServer, 'resources/templates/list') as + (request?: TestRequest) => Promise<{ resourceTemplates: unknown[] }> }; } diff --git a/tests/e2e/mcp-server-smoke-http.test.ts b/tests/e2e/mcp-server-smoke-http.test.ts index 8c51e5ad..85e1dab1 100644 --- a/tests/e2e/mcp-server-smoke-http.test.ts +++ b/tests/e2e/mcp-server-smoke-http.test.ts @@ -318,6 +318,37 @@ describe('MCP Server E2E HTTP Smoke Test', () => { } }, TEST_TIMEOUT); + it('debugs fibonacci.py over the 2026-07-28 era too: a client pinned to it runs sessionless (issue #1025)', async () => { + let debugSessionId: string | undefined; + + try { + serverPort = await startHttpServer(); + expect(await waitForHealthEndpoint(serverPort, TEST_TIMEOUT)).toBe(true); + + // The pin never falls back: this connection is modern or the test fails. + mcpSdkClient = new Client( + { name: 'e2e-http-smoke-test-client-2026', version: '0.1.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); + await mcpSdkClient.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${serverPort}/mcp`))); + expect(mcpSdkClient.getProtocolEra()).toBe('modern'); + + const fibonacciPath = path.join(projectRoot, 'examples', 'python', 'fibonacci.py'); + const result = await executeDebugSequence(mcpSdkClient, fibonacciPath, 'E2E HTTP Smoke Test Session (2026-07-28)'); + expect(result.success).toBe(true); + debugSessionId = result.sessionId; + + // No MCP session exists for this client; the debug session is the process's. + const health = await (await fetch(`http://127.0.0.1:${serverPort}/health`)).json() as { sessions: string[]; requests: { modern: number } }; + expect(health.sessions).toEqual([]); + expect(health.requests.modern).toBeGreaterThan(0); + } finally { + if (debugSessionId && mcpSdkClient) { + await mcpSdkClient.callTool({ name: 'close_debug_session', arguments: { sessionId: debugSessionId } }).catch(() => undefined); + } + } + }, TEST_TIMEOUT); + // Test spawning the server from a different working directory it('should work when the HTTP server is spawned from a different working directory', async () => { const tempDir = os.tmpdir(); diff --git a/tests/integration/cli/http-era-negotiation.test.ts b/tests/integration/cli/http-era-negotiation.test.ts new file mode 100644 index 00000000..3ab6b53b --- /dev/null +++ b/tests/integration/cli/http-era-negotiation.test.ts @@ -0,0 +1,147 @@ +/** + * The http transport speaks both protocol eras (issue #1025, step 2): a + * probing v2 client lands on 2026-07-28 and drives a full debug session with + * no MCP session at all; a client that opens with initialize gets the 2025 + * sessionful era as before. Real server (`dist/index.js http`), the v2 SDK + * client in each negotiation mode, the mock adapter — no toolchain. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import net, { type AddressInfo } from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { serverEnvironment } from '../../test-utils/helpers/mcp-stdio-client.js'; +import { waitForHealthEndpoint } from '../../e2e/smoke-test-utils.js'; +import { TOOL_NAMES } from '../../../src/server/tool-schemas.js'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const entry = path.join(root, 'dist/index.js'); +const MOCK_SCRIPT = path.join(root, 'examples/python/simple_test.py'); + +type Mode = 'auto' | 'legacy' | { pin: string }; + +interface Answer { + success: boolean; + sessionId?: string; + state?: string; + message?: string; + error?: string; +} + +let server: ChildProcess | undefined; +let serverOutput = ''; +const clients: Client[] = []; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.on('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as AddressInfo; + probe.close(() => resolve(port)); + }); + }); +} + +async function startServer(): Promise { + const port = await freePort(); + server = spawn(process.execPath, [entry, 'http', '-p', String(port)], { + cwd: root, + env: serverEnvironment({ scrub: ['MCP_HTTP_BIND'], exitOnStdinClose: false }), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + server.stdout!.on('data', (chunk: Buffer) => { serverOutput += chunk.toString(); }); + server.stderr!.on('data', (chunk: Buffer) => { serverOutput += chunk.toString(); }); + expect(await waitForHealthEndpoint(port, 20_000), serverOutput).toBe(true); + return port; +} + +async function connect(port: number, mode: Mode, name: string): Promise { + const client = new Client({ name, version: '1.0.0' }, { versionNegotiation: { mode } }); + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`))); + clients.push(client); + return client; +} + +async function call(client: Client, name: string, args: Record = {}): Promise { + const raw = await client.callTool({ name, arguments: args }, { timeout: 30_000 }); + const text = (raw.content as Array<{ type: string; text?: string }>).find((item) => item.type === 'text')?.text; + expect(text, JSON.stringify(raw)).toBeDefined(); + return JSON.parse(text!) as Answer; +} + +async function health(port: number): Promise<{ sessions: string[]; debugSessions: Array<{ id: string }>; requests: { legacy: number; modern: number } }> { + const response = await fetch(`http://127.0.0.1:${port}/health`); + return (await response.json()) as { sessions: string[]; debugSessions: Array<{ id: string }>; requests: { legacy: number; modern: number } }; +} + +afterEach(async () => { + for (const client of clients.splice(0)) { + await client.close().catch(() => {}); + } + if (server && server.exitCode === null) { + const exited = new Promise((resolve) => server!.once('exit', () => resolve())); + server.kill(); + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 5_000))]); + } + server = undefined; + serverOutput = ''; +}); + +describe('http serves both protocol eras (issue #1025, step 2)', () => { + it('a probing client lands on 2026-07-28 with no MCP session, and the server counts it as modern traffic', async () => { + const port = await startServer(); + const client = await connect(port, 'auto', 'era-auto'); + + expect(client.getProtocolEra()).toBe('modern'); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + expect(client.getServerVersion()?.name).toBe('debug-mcp-server'); + expect((await client.listTools()).tools.length).toBe(TOOL_NAMES.length); + const h = await health(port); + expect(h.sessions).toEqual([]); + expect(h.requests.modern).toBeGreaterThan(0); + }, 60_000); + + it('a client pinned to 2026-07-28 drives a full debug session sessionlessly and hears list changes', async () => { + const port = await startServer(); + const client = await connect(port, { pin: '2026-07-28' }, 'era-pin'); + expect(client.getProtocolEra()).toBe('modern'); + + const changes: string[] = []; + client.setNotificationHandler('notifications/resources/list_changed', () => { changes.push('list_changed'); }); + const subscription = await client.listen({ resourcesListChanged: true }); + expect(subscription.honoredFilter.resourcesListChanged).toBe(true); + + const created = await call(client, 'create_debug_session', { language: 'mock', name: 'era-pin' }); + expect(created.success, JSON.stringify(created)).toBe(true); + const sessionId = created.sessionId!; + await expect.poll(() => changes.length, { timeout: 10_000 }).toBeGreaterThanOrEqual(1); + expect((await health(port)).debugSessions.map((s) => s.id)).toContain(sessionId); + + const bp = await call(client, 'set_breakpoint', { sessionId, file: MOCK_SCRIPT, line: 32 }); + expect(bp.success, JSON.stringify(bp)).toBe(true); + const started = await call(client, 'start_debugging', { sessionId, scriptPath: MOCK_SCRIPT, dapLaunchArgs: { stopOnEntry: true } }); + expect(started.success, JSON.stringify(started)).toBe(true); + const waited = await call(client, 'wait_for_stop', { sessionId, timeout: 15_000 }); + expect(waited.success, JSON.stringify(waited)).toBe(true); + expect(waited.state).toBe('paused'); + expect((await call(client, 'close_debug_session', { sessionId })).success).toBe(true); + + await subscription.close(); + }, 90_000); + + it('a client that opens with initialize gets the 2025-11-25 era and an MCP session, as before', async () => { + const port = await startServer(); + const client = await connect(port, 'legacy', 'era-legacy'); + + expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + expect((await health(port)).sessions).toHaveLength(1); + const created = await call(client, 'create_debug_session', { language: 'mock', name: 'era-legacy' }); + expect(created.success).toBe(true); + await client.subscribeResource({ uri: `debug://sessions/${created.sessionId}/output` }); + expect((await call(client, 'close_debug_session', { sessionId: created.sessionId! })).success).toBe(true); + }, 60_000); +}); diff --git a/tests/integration/cli/http-host-allowlist.test.ts b/tests/integration/cli/http-host-allowlist.test.ts index 1a122108..2a7b480b 100644 --- a/tests/integration/cli/http-host-allowlist.test.ts +++ b/tests/integration/cli/http-host-allowlist.test.ts @@ -36,6 +36,7 @@ async function listen(options: { allowedHost?: string[] } = {}, env: Record () => undefined }, idlePolicy: { idleMs: 0, sweepMs: 60_000 }, sessionManager: { getAllSessions: () => [], getSession: () => undefined, idleMsOf: () => undefined } }; diff --git a/tests/integration/cli/http-spec-contract-2026.test.ts b/tests/integration/cli/http-spec-contract-2026.test.ts new file mode 100644 index 00000000..fb029070 --- /dev/null +++ b/tests/integration/cli/http-spec-contract-2026.test.ts @@ -0,0 +1,306 @@ +/** + * Streamable HTTP contract for the 2026-07-28 revision (issue #1025, step 2): + * the modern, sessionless era served beside the 2025 sessionful app on the + * same /mcp endpoint. One case per clause the SDK's modern handler answers on + * our behalf — a raw request per case, because the SDK client hides status + * codes and error bodies — plus the two routing facts this server decides + * itself: a 2025-shaped request still lands on the sessionful app, and the + * modern listen stream counts the process as attended. The 2025 clauses stay + * pinned by http-spec-contract.test.ts; both eras are served until the legacy + * one is retired. + * + * Spec: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as http from 'node:http'; +import net, { type AddressInfo } from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { serverEnvironment } from '../../test-utils/helpers/mcp-stdio-client.js'; +import { waitForHealthEndpoint } from '../../e2e/smoke-test-utils.js'; +import { TOOL_NAMES } from '../../../src/server/tool-schemas.js'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const entry = path.join(root, 'dist/index.js'); + +const VERSION = '2026-07-28'; +const META = { + 'io.modelcontextprotocol/protocolVersion': VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'spec-contract-2026', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +interface Reply { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +interface RunningServer { + port: number; + child: ChildProcess; + output: () => string; +} + +interface Health { + attended: boolean; + listenStreams: number; + sessions: string[]; + requests?: { legacy: number; modern: number }; +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.on('error', reject); + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as AddressInfo; + probe.close(() => resolve(port)); + }); + }); +} + +async function startServer(): Promise { + const port = await freePort(); + let output = ''; + const child = spawn(process.execPath, [entry, 'http', '-p', String(port)], { + cwd: root, + env: serverEnvironment({ scrub: ['MCP_HTTP_BIND', 'MCP_HTTP_ALLOWED_HOSTS'], exitOnStdinClose: false }), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + child.stdout!.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr!.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + // A server that never answers /health must not outlive the failed assertion. + const healthy = await waitForHealthEndpoint(port, 20_000); + if (!healthy) child.kill(); + expect(healthy, output).toBe(true); + return { port, child, output: () => output }; +} + +async function stopServer(server: RunningServer | undefined): Promise { + if (!server || server.child.exitCode !== null) return; + const exited = new Promise((resolve) => server.child.once('exit', () => resolve())); + server.child.kill(); + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 5_000))]); +} + +/** A modern-era request body: the method's params plus the per-request `_meta` envelope. */ +function envelope(id: number, method: string, params: Record = {}, meta: Record = META) { + return { jsonrpc: '2.0', id, method, params: { ...params, _meta: meta } }; +} + +/** The headers the 2026-07-28 revision requires on a POST, derived from the body unless overridden. */ +function modernHeaders( + body: { method: string; params?: { name?: string; uri?: string; [key: string]: unknown } }, + overrides: Record = {} +): Record { + const headers: Record = { + 'MCP-Protocol-Version': VERSION, + 'Mcp-Method': body.method, + ...(body.params?.name !== undefined ? { 'Mcp-Name': body.params.name } : {}), + ...(body.params?.uri !== undefined ? { 'Mcp-Name': body.params.uri } : {}), + ...overrides, + }; + return Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => entry[1] !== undefined)); +} + +function request( + port: number, + options: { method?: string; body?: unknown; headers?: Record; path?: string } = {} +): Promise { + const method = options.method ?? 'POST'; + const payload = options.body === undefined ? undefined : JSON.stringify(options.body); + const headers: Record = { + Accept: method === 'GET' ? 'text/event-stream' : 'application/json, text/event-stream', + ...(payload !== undefined ? { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(payload)) } : {}), + ...(options.headers ?? {}), + }; + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: options.path ?? '/mcp', method, headers }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body })); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +/** The JSON-RPC message with `id` out of a JSON or SSE-framed reply. */ +function messageOf(reply: Reply, id: number): Record { + const contentType = String(reply.headers['content-type'] ?? ''); + if (contentType.startsWith('text/event-stream')) { + for (const line of reply.body.split('\n')) { + if (!line.startsWith('data:')) continue; + const parsed = JSON.parse(line.slice(5).trim()) as Record; + if (parsed.id === id) return parsed; + } + throw new Error(`no message with id ${id} in the SSE reply:\n${reply.body}`); + } + const parsed = JSON.parse(reply.body) as Record; + if (Array.isArray(parsed)) { + const found = parsed.find((message: { id?: number }) => message.id === id); + if (found) return found; + throw new Error(`no message with id ${id} in the batch reply:\n${reply.body}`); + } + return parsed; +} + +async function health(port: number): Promise { + const reply = await request(port, { method: 'GET', path: '/health', headers: { Accept: 'application/json' } }); + return JSON.parse(reply.body) as Health; +} + +async function healthUntil(port: number, predicate: (h: Health) => boolean, deadlineMs = 10_000): Promise { + const deadline = Date.now() + deadlineMs; + let last = await health(port); + while (!predicate(last) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + last = await health(port); + } + return last; +} + +describe('Streamable HTTP contract (spec 2026-07-28, served beside 2025-11-25)', () => { + let server: RunningServer | undefined; + + beforeAll(async () => { + server = await startServer(); + }, 30_000); + + afterAll(async () => { + await stopServer(server); + }); + + it('server/discover answers the supported versions, the capabilities and the server identity, with no session minted', async () => { + const body = envelope(1, 'server/discover'); + const reply = await request(server!.port, { body, headers: modernHeaders(body) }); + + expect(reply.status, reply.body).toBe(200); + expect(reply.headers['mcp-session-id']).toBeUndefined(); + const message = messageOf(reply, 1); + expect(message.result.supportedVersions).toContain(VERSION); + expect(message.result.resultType).toBe('complete'); + expect(message.result.capabilities).toMatchObject({ tools: {}, resources: { subscribe: true, listChanged: true }, prompts: {} }); + expect(message.result._meta?.['io.modelcontextprotocol/serverInfo']?.name).toBe('debug-mcp-server'); + }); + + it('tools/list carries the cache fields and a deterministic order', async () => { + const body = envelope(2, 'tools/list'); + const first = messageOf(await request(server!.port, { body, headers: modernHeaders(body) }), 2); + const second = messageOf(await request(server!.port, { body, headers: modernHeaders(body) }), 2); + + expect(first.result.tools).toHaveLength(TOOL_NAMES.length); + expect(typeof first.result.ttlMs).toBe('number'); + expect(['public', 'private']).toContain(first.result.cacheScope); + expect(first.result.resultType).toBe('complete'); + expect(second.result.tools.map((tool: { name: string }) => tool.name)).toEqual(first.result.tools.map((tool: { name: string }) => tool.name)); + }); + + it('a modern request carrying a stale Mcp-Session-Id is served statelessly, the header ignored', async () => { + const body = envelope(3, 'tools/list'); + const reply = await request(server!.port, { body, headers: { ...modernHeaders(body), 'Mcp-Session-Id': 'stale-from-another-life' } }); + + expect(reply.status, reply.body).toBe(200); + expect(messageOf(reply, 3).result.tools).toHaveLength(TOOL_NAMES.length); + expect(reply.headers['mcp-session-id']).toBeUndefined(); + }); + + it('a modern protocol header without the per-request envelope is refused with -32602', async () => { + const body = { jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} }; + const reply = await request(server!.port, { body, headers: modernHeaders(body) }); + + expect(reply.status, reply.body).toBe(400); + expect(JSON.parse(reply.body).error.code).toBe(-32602); + }); + + it('a modern request missing the Mcp-Method header is refused with -32020 (HeaderMismatch)', async () => { + const body = envelope(5, 'tools/list'); + const reply = await request(server!.port, { body, headers: modernHeaders(body, { 'Mcp-Method': undefined }) }); + + expect(reply.status, reply.body).toBe(400); + expect(JSON.parse(reply.body).error.code).toBe(-32020); + }); + + it('an unsupported claimed revision is refused with -32022 naming the supported ones', async () => { + const body = envelope(6, 'tools/list', {}, { ...META, 'io.modelcontextprotocol/protocolVersion': '2099-01-01' }); + const reply = await request(server!.port, { body, headers: modernHeaders(body, { 'MCP-Protocol-Version': '2099-01-01' }) }); + + expect(reply.status, reply.body).toBe(400); + const error = JSON.parse(reply.body).error; + expect(error.code).toBe(-32022); + expect(error.data?.supported).toContain(VERSION); + }); + + it('a 2025 initialize on the same endpoint still opens a sessionful MCP session (both eras served)', async () => { + const reply = await request(server!.port, { + body: { + jsonrpc: '2.0', + id: 7, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'spec-contract-2026', version: '1.0.0' } }, + }, + }); + + expect(reply.status, reply.body).toBe(200); + const sessionId = reply.headers['mcp-session-id']; + expect(typeof sessionId).toBe('string'); + expect((await health(server!.port)).sessions).toContain(sessionId); + await request(server!.port, { method: 'DELETE', headers: { 'Mcp-Session-Id': sessionId as string } }); + }); + + it('a session-less 2025-shaped request still lands on the sessionful app and its 400 (unchanged)', async () => { + const reply = await request(server!.port, { body: { jsonrpc: '2.0', id: 8, method: 'tools/list' } }); + + expect(reply.status, reply.body).toBe(400); + expect(JSON.parse(reply.body).error.code).toBe(-32600); + const get = await request(server!.port, { method: 'GET' }); + expect(get.status).toBe(400); + }); + + it('an open subscriptions/listen stream counts the process as attended, and its close as unattended', async () => { + const before = await health(server!.port); + expect(before.listenStreams).toBe(0); + + const body = envelope(9, 'subscriptions/listen', { notifications: { resourcesListChanged: true } }); + const payload = JSON.stringify(body); + const headers = { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(payload)), + ...modernHeaders(body), + }; + const firstEvent = new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port: server!.port, path: '/mcp', method: 'POST', headers }, (res) => { + expect(res.statusCode, 'listen must open a stream').toBe(200); + res.setEncoding('utf8'); + let buffer = ''; + res.on('data', (chunk: string) => { + buffer += chunk; + if (buffer.includes('\n\n')) { + resolve(buffer); + // Hold the stream open a moment longer, then drop it: cancellation. + setTimeout(() => req.destroy(), 1_500); + } + }); + }); + req.on('error', reject); + req.write(payload); + req.end(); + }); + + const opened = await firstEvent; + expect(opened).toContain('notifications/subscriptions/acknowledged'); + const during = await healthUntil(server!.port, (h) => h.listenStreams === 1); + expect(during.listenStreams, JSON.stringify(during)).toBe(1); + expect(during.attended).toBe(true); + + const after = await healthUntil(server!.port, (h) => h.listenStreams === 0); + expect(after.listenStreams, JSON.stringify(after)).toBe(0); + expect(after.attended).toBe(false); + }, 30_000); +}); diff --git a/tests/unit/cli/http-command.test.ts b/tests/unit/cli/http-command.test.ts index 321e2e6e..5ee8be4c 100644 --- a/tests/unit/cli/http-command.test.ts +++ b/tests/unit/cli/http-command.test.ts @@ -11,6 +11,14 @@ import type { createProtocolServer } from '../../../src/server/protocol-facade.j vi.mock('../../../src/server.js'); vi.mock('@modelcontextprotocol/node'); +// The 2026-07-28 leg (issue #1025, step 2): the SDK's modern handler and its +// classifier are doubles here; the handler's own behaviour is pinned over the +// wire by tests/integration/cli/http-spec-contract-2026.test.ts. +vi.mock('@modelcontextprotocol/server', async (importOriginal) => ({ + ...(await importOriginal()), + createMcpHandler: vi.fn(), + isLegacyRequest: vi.fn(), +})); // The app is built with express() directly (issues #667/#670): the default // export hands back the per-test mockApp, and express.json() returns one @@ -24,9 +32,14 @@ const expressMock = vi.hoisted(() => { }); vi.mock('express', () => ({ default: expressMock.express })); -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node'; +import { createMcpHandler, isLegacyRequest, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server'; const MockedStreamableHTTPServerTransport = vi.mocked(NodeStreamableHTTPServerTransport); +const mockCreateMcpHandler = vi.mocked(createMcpHandler); +const mockIsLegacyRequest = vi.mocked(isLegacyRequest); +const mockToNodeHandler = vi.mocked(toNodeHandler); +const mockToWebRequest = vi.mocked(toWebRequest); /** Drive a Host-checking middleware with one request; returns what it did. */ function runHost(middleware: (req: any, res: any, next: any) => void, host: string) { @@ -41,6 +54,8 @@ describe('HTTP Command Handler', () => { let mockTransport: any; let mockFacade: { connect: Mock<() => Promise> }; let mockProtocolServerFactory: Mock; + let mockModernHandler: { fetch: Mock; close: Mock<() => Promise>; notify: { resourceUpdated: Mock; resourcesChanged: Mock } }; + let mockModernNode: Mock<(req: unknown, res: unknown, body?: unknown) => Promise>; let fakeProc: FakeCurrentProcess; let mockApp: any; @@ -67,6 +82,7 @@ describe('HTTP Command Handler', () => { start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), setAttendedSupplier: vi.fn(), + notifications: { addSink: vi.fn(() => () => undefined) }, idlePolicy: { idleMs: 1_800_000, sweepMs: 60_000 }, sessionManager: { getAllSessions: vi.fn().mockReturnValue([]), @@ -76,6 +92,22 @@ describe('HTTP Command Handler', () => { } as any; mockFacade = { connect: vi.fn<() => Promise>().mockResolvedValue(undefined) }; mockProtocolServerFactory = vi.fn(() => mockFacade as any); + // The modern leg's doubles. Every request classifies as legacy unless a + // test says otherwise, so the sessionful cases below run exactly as before. + mockModernHandler = { + fetch: vi.fn(), + close: vi.fn<() => Promise>().mockResolvedValue(undefined), + notify: { resourceUpdated: vi.fn(), resourcesChanged: vi.fn() }, + }; + mockModernNode = vi.fn<(req: unknown, res: unknown, body?: unknown) => Promise>().mockResolvedValue(undefined); + mockCreateMcpHandler.mockReset(); + mockCreateMcpHandler.mockReturnValue(mockModernHandler as any); + mockToNodeHandler.mockReset(); + mockToNodeHandler.mockReturnValue(mockModernNode as any); + mockToWebRequest.mockReset(); + mockToWebRequest.mockResolvedValue({} as Request); + mockIsLegacyRequest.mockReset(); + mockIsLegacyRequest.mockResolvedValue(true); mockServerFactory = vi.fn<(options: ServerFactoryOptions) => DebugMcpServer>().mockReturnValue(mockServer); mockExitProcess = vi.fn<(code: number) => void>(); @@ -575,6 +607,240 @@ describe('HTTP Command Handler', () => { }); }); + describe('the 2026-07-28 leg beside the sessionful app (issue #1025, step 2)', () => { + // The modern handler is the SDK's; this suite pins only the router split + // and the bookkeeping around it. The handler's own behaviour is pinned over + // the wire by tests/integration/cli/http-spec-contract-2026.test.ts. + const MODERN_BODY = { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } }, + }; + const INIT_BODY = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '1' } }, + }; + + function makeStreamRes() { + const res = new EventEmitter() as any; + res.status = vi.fn().mockReturnThis(); + res.json = vi.fn(); + res.end = vi.fn(); + res.headersSent = false; + return res; + } + + function makeReq(overrides: Partial<{ method: string; headers: any; body: any }> = {}) { + return { method: 'POST', headers: {}, body: undefined, ...overrides }; + } + + function makeRes() { + return { status: vi.fn().mockReturnThis(), json: vi.fn(), end: vi.fn(), headersSent: false }; + } + + function appAndHandler() { + const app = createHttpApp( + { port: '3001' }, + { logger: mockLogger, serverFactory: mockServerFactory, protocolServerFactory: mockProtocolServerFactory, proc: fakeProc } + ); + const postCall = mockApp.post.mock.calls.find((c: any) => c[0] === '/mcp'); + const healthCall = mockApp.get.mock.calls.find((c: any) => c[0] === '/health'); + return { app, handler: postCall![1] as (req: any, res: any) => Promise, healthHandler: healthCall![1] as (req: any, res: any) => void }; + } + + function healthBody(healthHandler: (req: any, res: any) => void) { + const res = { json: vi.fn() }; + healthHandler({}, res); + return res.json.mock.calls[0][0]; + } + + it('builds one strict modern handler per app over per-request facades, and feeds its notifier from the hub', () => { + appAndHandler(); + + expect(mockCreateMcpHandler).toHaveBeenCalledTimes(1); + const [factory, options] = mockCreateMcpHandler.mock.calls[0]; + expect(options).toMatchObject({ legacy: 'reject', maxRequestBodySize: 10 * 1024 * 1024 }); + expect(typeof options?.onerror).toBe('function'); + expect(factory({ era: 'modern' })).toBe(mockFacade); + expect(mockProtocolServerFactory).toHaveBeenLastCalledWith(mockServer, { era: 'modern', deliver: 'external' }); + // The one 10 MiB limit reaches every SDK entry the request passes through — the + // Node adapter included, or a non-JSON POST between the two limits dies in it. + expect(mockToNodeHandler).toHaveBeenCalledWith(mockModernHandler, expect.objectContaining({ + onerror: expect.any(Function), + maxRequestBodySize: 10 * 1024 * 1024, + })); + + // The hub's sink for this leg is the handler's notifier. + expect(mockServer.notifications.addSink).toHaveBeenCalledTimes(1); + const sink = vi.mocked(mockServer.notifications.addSink).mock.calls[0][0]; + sink.resourceUpdated('debug://sessions/s/output'); + sink.resourcesChanged(); + expect(mockModernHandler.notify.resourceUpdated).toHaveBeenCalledWith('debug://sessions/s/output'); + expect(mockModernHandler.notify.resourcesChanged).toHaveBeenCalledTimes(1); + }); + + it('routes a request the classifier calls modern to the SDK handler, never to the session branch', async () => { + const { app, handler, healthHandler } = appAndHandler(); + mockIsLegacyRequest.mockResolvedValue(false); + const req = makeReq({ body: MODERN_BODY }); + const res = makeRes(); + + await handler(req, res); + + expect(mockToWebRequest).toHaveBeenCalledWith(req, MODERN_BODY, { maxRequestBodySize: 10 * 1024 * 1024 }); + expect(mockIsLegacyRequest).toHaveBeenCalledWith(expect.anything(), MODERN_BODY, { maxRequestBodySize: 10 * 1024 * 1024 }); + expect(mockModernNode).toHaveBeenCalledWith(req, res, MODERN_BODY); + expect(MockedStreamableHTTPServerTransport).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect((app as any).httpSessions.size).toBe(0); + expect(healthBody(healthHandler).requests).toEqual({ legacy: 0, modern: 1 }); + }); + + // Classifying a request reads its body when Express did not parse it (a non-JSON + // content type). A body over the limit is the client's error and is answered as one — + // the same 413 the JSON parser gives — not as an "Internal error" with an error log. + it('answers 413 when the body is too large to classify, and logs no server error', async () => { + const { handler, healthHandler } = appAndHandler(); + mockToWebRequest.mockRejectedValueOnce( + Object.assign(new Error('Payload Too Large: Request body must not exceed 10485760 bytes'), { + name: 'RequestBodyTooLargeError', + status: 413, + }) + ); + const res = makeRes(); + + await handler(makeReq({ body: undefined, headers: { 'content-type': 'text/plain' } }), res); + + expect(res.status).toHaveBeenCalledWith(413); + expect(res.json.mock.calls[0][0]).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32600, message: 'Request body too large (limit 10mb)' }, + id: null, + }); + expect(mockLogger.error).not.toHaveBeenCalledWith('Error handling MCP request', expect.anything()); + expect(mockModernNode).not.toHaveBeenCalled(); + expect(healthBody(healthHandler).requests).toEqual({ legacy: 0, modern: 0 }); + }); + + it('still answers 500 for a fault of its own while classifying', async () => { + const { handler } = appAndHandler(); + mockToWebRequest.mockRejectedValueOnce(new Error('stream exploded')); + const res = makeRes(); + + await handler(makeReq({ body: undefined }), res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(mockLogger.error).toHaveBeenCalledWith('Error handling MCP request', expect.anything()); + }); + + // The SDK reports every request it refuses — a junk body, a missing header, a wrong + // content type — through the same hook as its own faults. A refusal is the caller's + // doing: a scanner posting `{}` must not write error lines. + it('logs a request the SDK refused as a warning, and a fault of the handler as an error', () => { + appAndHandler(); + const [, options] = mockCreateMcpHandler.mock.calls[0]; + + options!.onerror!(new Error('Rejected inbound request (jsonrpc-shape): Bad Request: the request body is not a valid JSON-RPC message')); + options!.onerror!(new Error('Rejected 2025-era request on a modern-only endpoint (era): Unsupported protocol version')); + options!.onerror!(new Error('Unsupported Media Type: Content-Type must be application/json')); + + expect(mockLogger.warn).toHaveBeenCalledTimes(3); + expect(mockLogger.warn).toHaveBeenCalledWith('Modern-era MCP request refused', { + reason: 'Rejected inbound request (jsonrpc-shape): Bad Request: the request body is not a valid JSON-RPC message', + }); + expect(mockLogger.error).not.toHaveBeenCalledWith('Modern-era MCP handler error', expect.anything()); + + const fault = new Error('factory exploded'); + options!.onerror!(fault); + + expect(mockLogger.error).toHaveBeenCalledWith('Modern-era MCP handler error', { error: fault }); + }); + + // Some refusals reach the hook as the SDK's typed protocol errors, with no + // "Rejected" in front: an unsupported protocol revision (-32022), a missing + // client capability. They are refusals all the same — a client whose + // preferred revision is newer than ours would write one on every connect. + it('logs a typed protocol refusal as a warning too, but an internal error as an error', () => { + appAndHandler(); + const [, options] = mockCreateMcpHandler.mock.calls[0]; + + options!.onerror!(new ProtocolError(-32022, 'Unsupported protocol version: 2099-01-01')); + + expect(mockLogger.warn).toHaveBeenCalledWith('Modern-era MCP request refused', { + reason: 'Unsupported protocol version: 2099-01-01', + }); + expect(mockLogger.error).not.toHaveBeenCalledWith('Modern-era MCP handler error', expect.anything()); + + const fault = new ProtocolError(ProtocolErrorCode.InternalError, 'the factory threw'); + options!.onerror!(fault); + + expect(mockLogger.error).toHaveBeenCalledWith('Modern-era MCP handler error', { error: fault }); + }); + + it('routes a request the classifier calls legacy to the sessionful app, untouched', async () => { + const { app, handler, healthHandler } = appAndHandler(); + mockIsLegacyRequest.mockResolvedValue(true); + + await handler(makeReq({ body: INIT_BODY }), makeRes()); + mockTransport.triggerSessionInit(); + + expect(mockModernNode).not.toHaveBeenCalled(); + expect(MockedStreamableHTTPServerTransport).toHaveBeenCalledTimes(1); + expect((app as any).httpSessions.size).toBe(1); + expect(healthBody(healthHandler).requests).toEqual({ legacy: 1, modern: 0 }); + }); + + it('classifies a GET or DELETE without a body', async () => { + const { handler } = appAndHandler(); + mockIsLegacyRequest.mockResolvedValue(true); + const req = makeReq({ method: 'GET', headers: { 'mcp-session-id': 'nope' }, body: undefined }); + + await handler(req, makeRes()); + + expect(mockToWebRequest).toHaveBeenCalledWith(req, undefined, { maxRequestBodySize: 10 * 1024 * 1024 }); + expect(mockIsLegacyRequest).toHaveBeenCalledWith(expect.anything(), undefined, { maxRequestBodySize: 10 * 1024 * 1024 }); + }); + + it('counts an open modern subscriptions/listen stream as attendance until its response closes', async () => { + const { handler, healthHandler } = appAndHandler(); + mockIsLegacyRequest.mockResolvedValue(false); + const attended = vi.mocked(mockServer.setAttendedSupplier).mock.calls[0][0] as () => boolean; + let release: () => void = () => undefined; + mockModernNode.mockImplementation(() => new Promise((resolve) => { release = resolve; })); + const res = makeStreamRes(); + const listen = { ...MODERN_BODY, method: 'subscriptions/listen', params: { ...MODERN_BODY.params, notifications: { resourcesListChanged: true } } }; + + const pending = handler(makeReq({ body: listen }), res); + // The classifier is asynchronous: the count lands once it has answered. + await vi.waitFor(() => expect(attended()).toBe(true)); + expect(healthBody(healthHandler)).toMatchObject({ attended: true, listenStreams: 1 }); + + res.emit('close'); + release(); + await pending; + expect(attended()).toBe(false); + expect(healthBody(healthHandler)).toMatchObject({ attended: false, listenStreams: 0 }); + }); + + it('allows the 2026 request headers through CORS and echoes a preflight\'s requested headers', () => { + appAndHandler(); + const corsMiddleware = mockApp.use.mock.calls[1][0]; + const headers = new Map(); + const res = { header: vi.fn((name: string, value: string) => headers.set(name.toLowerCase(), value)), sendStatus: vi.fn() }; + + corsMiddleware({ method: 'OPTIONS', headers: { 'access-control-request-headers': 'Mcp-Param-Region, X-Custom' } }, res, vi.fn()); + + const allowed = headers.get('access-control-allow-headers')?.toLowerCase() ?? ''; + for (const name of ['mcp-method', 'mcp-name', 'mcp-protocol-version', 'mcp-session-id', 'accept', 'content-type', 'mcp-param-region', 'x-custom']) { + expect(allowed, name).toContain(name); + } + expect(res.sendStatus).toHaveBeenCalledWith(200); + }); + }); + describe('/health endpoint', () => { it('reports the address and port the server listens on (issue #680)', () => { createHttpApp( @@ -604,6 +870,7 @@ describe('HTTP Command Handler', () => { connections: 0, attended: false, listenStreams: 0, + requests: { legacy: 0, modern: 0 }, sessions: [], details: [], debugSessions: [], @@ -1264,6 +1531,8 @@ describe('HTTP Command Handler', () => { it('handles SIGINT by closing all transports, stopping the one core, then exiting', async () => { const listen = vi.fn(() => mockHttpServer); mockApp.listen = listen; + const detachModernSink = vi.fn(); + vi.mocked(mockServer.notifications.addSink).mockReturnValue(detachModernSink); await handleHttpCommand( { port: '3001' }, @@ -1285,12 +1554,47 @@ describe('HTTP Command Handler', () => { expect(t1.close).toHaveBeenCalled(); expect(t2.close).toHaveBeenCalled(); expect(mockServer.stop).toHaveBeenCalledTimes(1); - // Transports first, so no request lands on a core that is closing its sessions. + // The modern handler first (its listen streams end gracefully), then the + // transports, so no request lands on a core that is closing its sessions. + expect(mockModernHandler.close).toHaveBeenCalledTimes(1); + expect(mockModernHandler.close.mock.invocationCallOrder[0]).toBeLessThan(t1.close.mock.invocationCallOrder[0]); + // Its sink leaves the hub before the handler closes: the core's own + // teardown fires resource changes a closed handler has no stream for. + expect(detachModernSink).toHaveBeenCalledTimes(1); + expect(detachModernSink.mock.invocationCallOrder[0]).toBeLessThan(mockModernHandler.close.mock.invocationCallOrder[0]); expect(vi.mocked(mockServer.stop).mock.invocationCallOrder[0]).toBeGreaterThan(t2.close.mock.invocationCallOrder[0]); expect(mockHttpServer.close).toHaveBeenCalled(); expect(mockExitProcess).toHaveBeenCalledWith(0); }); + it('completes shutdown after the modern handler fails to close', async () => { + mockApp.listen = vi.fn(() => mockHttpServer); + const error = new Error('modern close failed'); + mockModernHandler.close.mockRejectedValueOnce(error); + const detachModernSink = vi.fn(); + vi.mocked(mockServer.notifications.addSink).mockReturnValue(detachModernSink); + + await handleHttpCommand( + { port: '3001' }, + { logger: mockLogger, serverFactory: mockServerFactory, protocolServerFactory: mockProtocolServerFactory, exitProcess: mockExitProcess, proc: fakeProc } + ); + const transport = { close: vi.fn().mockResolvedValue(undefined) }; + const sessions = mockApp.httpSessions as Map; + sessions.set('legacy', { transport }); + + await fakeProc.lastListener('SIGINT')(); + + expect(mockLogger.error).toHaveBeenCalledWith('Error closing the modern-era handler during shutdown', { error }); + expect(detachModernSink).toHaveBeenCalledTimes(1); + expect(detachModernSink.mock.invocationCallOrder[0]).toBeLessThan(mockModernHandler.close.mock.invocationCallOrder[0]); + expect(transport.close).toHaveBeenCalledTimes(1); + expect(mockServer.stop).toHaveBeenCalledTimes(1); + expect(vi.mocked(mockServer.stop).mock.invocationCallOrder[0]).toBeGreaterThan(transport.close.mock.invocationCallOrder[0]); + expect(sessions.size).toBe(0); + expect(mockHttpServer.close).toHaveBeenCalledTimes(1); + expect(mockExitProcess).toHaveBeenCalledExactlyOnceWith(0); + }); + it('proceeds past a hung session stop after the guard and still exits 0 (issue #337)', async () => { const listen = vi.fn(() => mockHttpServer); mockApp.listen = listen; diff --git a/tests/unit/scripts/mcp-conformance.test.ts b/tests/unit/scripts/mcp-conformance.test.ts index 7143c913..0cbcc00c 100644 --- a/tests/unit/scripts/mcp-conformance.test.ts +++ b/tests/unit/scripts/mcp-conformance.test.ts @@ -1,5 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { APPLICABLE_SCENARIOS, judgeScenario, parseSummary } from '../../../scripts/mcp-conformance.mjs'; +import { + APPLICABLE_SCENARIOS, + baselineReport, + judgeBaselineRun, + judgeScenario, + parseSummary, + requirementsArgs +} from '../../../scripts/mcp-conformance.mjs'; const PASSING = [ "Running client scenario 'ping' against server: http://127.0.0.1:3919/mcp", @@ -36,3 +45,143 @@ describe('mcp-conformance runner: reading the CLI summary', () => { expect(APPLICABLE_SCENARIOS).toContain('dns-rebinding-protection'); }); }); + +// The tail of a `--requirements … --expected-failures` run, as 0.2.0-alpha.12 prints it +// (colour codes included: the CLI colours these lines whatever NO_COLOR says). +const BASELINE_PASS = [ + 'Running requirements 2026-07-28 (50 scenarios) against http://127.0.0.1:3997/mcp', + '✗ server-stateless: 24 passed, 4 failed', + '✓ tools-list: 4 passed, 0 failed', + '', + 'Total: 104 passed, 66 failed', + '', + '\u001b[33mExpected failures (in baseline):\u001b[0m', + ' ~ server-stateless:sep-2575-server-no-log-without-loglevel', + ' ~ tools-call-simple-text', + '', + '\u001b[32mBaseline check passed: all failures are expected.\u001b[0m', +].join('\r\n'); + +const BASELINE_FAIL = [ + 'Running requirements 2026-07-28 (50 scenarios) against http://127.0.0.1:3997/mcp', + '✓ sep-2164-resource-not-found: 3 passed, 0 failed', + '', + 'Total: 104 passed, 66 failed', + '', + '\u001b[33mExpected failures (in baseline):\u001b[0m', + ' ~ tools-call-simple-text', + '', + '\u001b[31mStale baseline entries (now passing - remove from baseline):\u001b[0m', + ' ✓ tools-call-image', + '', + '\u001b[31mUnexpected failures (not in baseline):\u001b[0m', + ' ✗ sep-2164-resource-not-found', + '', + '\u001b[31mBaseline is stale: update your expected-failures file to remove passing entries.\u001b[0m', + '', + '\u001b[31mUnexpected failures detected: these are not in your expected-failures baseline.\u001b[0m', +].join('\n'); + +// Node 23 to 24.19 and 26.0 to 26.6 abort inside process.exit on Windows (nodejs/node#56645). +const EXIT_ABORT = 'Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\\win\\async.c, line 76'; +const ABORT_STATUS = 3221226505; + +describe('mcp-conformance runner: the 2026-07-28 requirements lane (issue #1025)', () => { + // The suite judges its own baseline: it reads the file, fails on a failure or + // warning that is not listed and on a listed entry that passes, and says so in + // its exit code. This runner adds no judging of its own. + it('hands the suite its own baseline by a path relative to the repository root', () => { + expect(requirementsArgs('2026-07-28', 43210)).toEqual([ + 'server', + '--url', 'http://127.0.0.1:43210/mcp', + '--requirements', '2026-07-28', + '--expected-failures', 'scripts/conformance/expected-failures-2026-07-28.yaml' + ]); + }); + + it('takes the exit code as the verdict', () => { + expect(judgeBaselineRun({ code: 0, output: BASELINE_PASS })).toEqual({ ok: true, abortedAtExit: false, report: '' }); + expect(judgeBaselineRun({ code: 1, output: BASELINE_FAIL })).toMatchObject({ ok: false, abortedAtExit: false }); + }); + + it('never calls a run a pass on its printed text when the exit code says it failed', () => { + // A pass line in the output of a run that exited 1 for another reason proves nothing. + expect(judgeBaselineRun({ code: 1, output: BASELINE_PASS }).ok).toBe(false); + }); + + it('reports a failed run in the words of the suite: its stale and unexpected lists, colour stripped', () => { + const { report } = judgeBaselineRun({ code: 1, output: BASELINE_FAIL }); + + expect(report).toMatch(/^Stale baseline entries \(now passing - remove from baseline\):/); + expect(report).toContain('✓ tools-call-image'); + expect(report).toContain('✗ sep-2164-resource-not-found'); + expect(report).not.toContain('\u001b'); + expect(report).not.toContain('Expected failures (in baseline)'); + }); + + it('reports the tail of the output when the suite never reached its verdict', () => { + const output = ['npm error code E404', 'npm error 404 Not Found'].join('\n'); + + expect(baselineReport(output)).toBe(output); + expect(judgeBaselineRun({ code: 1, output })).toMatchObject({ ok: false, report: output }); + }); + + describe('a Node build that aborts at exit on Windows', () => { + // The process dies inside process.exit, after the suite printed its verdict, so + // the exit status says nothing. Only then is the suite's closing line the verdict. + it('reads the closing line: a pass is a pass, and the answer says how it was read', () => { + const output = `${BASELINE_PASS}\r\n${EXIT_ABORT}`; + + expect(judgeBaselineRun({ code: ABORT_STATUS, output })).toEqual({ ok: true, abortedAtExit: true, report: '' }); + }); + + it('still fails a run whose closing line is not the pass line', () => { + const output = `${BASELINE_FAIL}\n${EXIT_ABORT}`; + + const verdict = judgeBaselineRun({ code: ABORT_STATUS, output }); + expect(verdict).toMatchObject({ ok: false, abortedAtExit: true }); + expect(verdict.report).toContain('✗ sep-2164-resource-not-found'); + }); + }); + + describe('the baseline file', () => { + const text = readFileSync( + fileURLToPath(new URL('../../../scripts/conformance/expected-failures-2026-07-28.yaml', import.meta.url)), + 'utf8' + ); + const entries = text.split(/\r?\n/).filter((line) => /^\s+- /.test(line)); + + it('gives every entry the fixture or feature that makes it untestable here', () => { + for (const line of entries) { + expect(line, line).toMatch(/^\s+- [a-z0-9-]+:[a-z0-9-]+\s+# \S/); + } + expect(entries.length).toBeGreaterThan(0); + }); + + // A whole-scenario entry excuses every check in the scenario. Naming one + // check keeps the others enforced — above all wire-schema-valid, the suite's + // check that every message this server sent is valid per the spec's schema. + it('excuses one check per entry, never a whole scenario, and never the schema check', () => { + for (const name of entries.map((line) => /- (\S+)/.exec(line)![1])) { + expect(name, name).toMatch(/^[a-z0-9-]+:[a-z0-9-]+$/); + expect(name, name).not.toMatch(/:wire-schema-valid$/); + } + }); + + it('lists each entry once, and no scenario both whole and by check (the suite refuses that)', () => { + const names = entries.map((line) => /- (\S+)/.exec(line)![1]); + expect(new Set(names).size).toBe(names.length); + const whole = new Set(names.filter((name) => !name.includes(':'))); + for (const name of names.filter((entry) => entry.includes(':'))) { + expect(whole.has(name.split(':')[0]), name).toBe(false); + } + }); + + it('names checks by the suite\'s check ids, which is what the suite matches (ids are kebab-case; names are CamelCase)', () => { + const checks = entries.map((line) => /- (\S+)/.exec(line)![1].split(':')[1]); + for (const id of checks) { + expect(id, id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)+$/); + } + }); + }); +});