diff --git a/.claude/skills/mcp-sdk-tier-audit/README.md b/.claude/skills/mcp-sdk-tier-audit/README.md index 77fc8cf..45cbc09 100644 --- a/.claude/skills/mcp-sdk-tier-audit/README.md +++ b/.claude/skills/mcp-sdk-tier-audit/README.md @@ -9,6 +9,15 @@ Two components work together: ## Quick Start: CLI +For an SDK listed in `src/sdk-runner/known-sdks.ts`, tier-check manages the whole conformance side itself — clone/build, then for each shipped revision the server invocation that SDK declares for that revision: + +```bash +gh auth login +npx @modelcontextprotocol/conformance tier-check --sdk go-sdk +``` + +The per-SDK sections below are for the manual path: driving a server you started yourself. + The CLI is a subcommand of the [MCP Conformance](https://github.com/modelcontextprotocol/conformance) tool. ```bash @@ -95,56 +104,33 @@ The skill lives in `.claude/skills/` in this repo, so if you open [Claude Code]( 3. Run the skill: ``` -/mcp-sdk-tier-audit [client-cmd] +/mcp-sdk-tier-audit [client-cmd] [--requirements ] ``` Pass the client command as the third argument to include client conformance testing. If omitted, client conformance is skipped and noted as a gap in the report. -**TypeScript SDK example:** +**Pass `--requirements` with every revision the SDK claims**, comma-separated. Each revision's scenarios run at that revision's own wire version, and all of them must pass for Tier 1: the dated revisions through `2025-11-25` use the stateful initialize handshake while `2026-07-28` is stateless, so a scenario belonging to both has to work on both and one run does not cover the other. It also means a scenario added to the suite after a revision shipped cannot fail an SDK that had no opportunity to adopt it. Without the flag, scoring uses the suite as it stands today, which is not a tier claim. See [Conformance Requirements](../../../README.md#conformance-requirements), and run `conformance list --requirements 2025-11-25,2026-07-28` to see both sets. -```bash -# Terminal 1: start the everything server (build first: npm run build) -cd ~/src/mcp/typescript-sdk && npm run test:conformance:server:run +**Known SDKs — no server to start, no commands to look up:** -# Terminal 2: run the audit (from the conformance repo) -/mcp-sdk-tier-audit ~/src/mcp/typescript-sdk http://localhost:3000/mcp "npx tsx ~/src/mcp/typescript-sdk/test/conformance/src/everythingClient.ts" +``` +/mcp-sdk-tier-audit --requirements 2025-11-25,2026-07-28 ``` -**Python SDK example:** +The skill drives `tier-check --sdk-path`, which builds the SDK and starts the +server invocation its config declares **per revision** — the per-SDK commands +live in [`src/sdk-runner/known-sdks.ts`](../../../src/sdk-runner/known-sdks.ts), +not here. That matters because a hand-run example cannot be correct for every +SDK: go-sdk needs a different server process per revision (`-stateless` defaults +to true, so a bare invocation mis-measures 2025-11-25 — [#446](https://github.com/modelcontextprotocol/conformance/issues/446)), +and csharp-sdk serves the two revisions at different endpoints. Prose copies of +those invocations drift; the config is tested. -```bash -# Terminal 1: install and start the everything server -cd ~/src/mcp/python-sdk && uv sync --frozen --all-extras --package mcp-everything-server -uv run mcp-everything-server --port 3001 +**An SDK not in `known-sdks.ts`:** start its everything server yourself and pass +the URL and client command explicitly — see [Other SDKs](#running-conformance-tests) +below. The one endpoint you give must serve every claimed revision at its own +wire, or the numbers will be wrong for the revisions it does not speak. -# Terminal 2: run the audit (from the conformance repo) -/mcp-sdk-tier-audit ~/src/mcp/python-sdk http://localhost:3001/mcp "uv run python ~/src/mcp/python-sdk/.github/actions/conformance/client.py" -``` - -**Go SDK example:** - -```bash -# Terminal 1: build and start the everything server -cd ~/src/mcp/go-sdk && go build -o /tmp/go-conformance-server ./conformance/everything-server -go build -o /tmp/go-conformance-client ./conformance/everything-client -/tmp/go-conformance-server -http="localhost:3002" - -# Terminal 2: run the audit (from the conformance repo) -/mcp-sdk-tier-audit ~/src/mcp/go-sdk http://localhost:3002 "/tmp/go-conformance-client" -``` - -**C# SDK example:** - -```bash -# Terminal 1: start the everything server (requires .NET SDK) -cd ~/src/mcp/csharp-sdk -dotnet run --project tests/ModelContextProtocol.ConformanceServer --framework net9.0 -- --urls http://localhost:3003 - -# Terminal 2: run the audit (from the conformance repo) -/mcp-sdk-tier-audit ~/src/mcp/csharp-sdk http://localhost:3003 "dotnet run --project ~/src/mcp/csharp-sdk/tests/ModelContextProtocol.ConformanceClient" -``` - -The skill derives `owner/repo` from git remote, runs the CLI, launches parallel evaluations for docs and policy, and writes detailed reports to `results/`. ### Any Other AI Coding Agent @@ -181,66 +167,17 @@ Run the CLI for the scorecard, then review docs and policies yourself using the ## Running Conformance Tests -To include conformance test results, start the SDK's everything server first, then pass the URL to the CLI. To also run client conformance tests, pass `--client-cmd` with the command to launch the SDK's conformance client. - -**TypeScript SDK**: +For any SDK in [`src/sdk-runner/known-sdks.ts`](../../../src/sdk-runner/known-sdks.ts), don't start servers by hand — the config runs the right invocation per revision: ```bash -# Terminal 1: start the server (SDK must be built first) -cd ~/src/mcp/typescript-sdk && npm run build -npm run test:conformance:server:run # starts on port 3000 +# both legs, every revision, one verdict +npx @modelcontextprotocol/conformance tier-check --sdk go-sdk -# Terminal 2: run tier-check (server + client conformance) -npm run --silent tier-check -- \ - --repo modelcontextprotocol/typescript-sdk \ - --conformance-server-url http://localhost:3000/mcp \ - --client-cmd 'npx tsx ~/src/mcp/typescript-sdk/test/conformance/src/everythingClient.ts' +# a single leg at a single revision, for debugging +npx @modelcontextprotocol/conformance sdk --path --mode server --requirements 2026-07-28 ``` -**Python SDK**: - -```bash -# Terminal 1: install and start the server -cd ~/src/mcp/python-sdk -uv sync --frozen --all-extras --package mcp-everything-server -uv run mcp-everything-server --port 3001 # specify port to avoid conflicts - -# Terminal 2: run tier-check (server + client conformance) -npm run --silent tier-check -- \ - --repo modelcontextprotocol/python-sdk \ - --conformance-server-url http://localhost:3001/mcp \ - --client-cmd 'uv run python ~/src/mcp/python-sdk/.github/actions/conformance/client.py' -``` - -**Go SDK**: - -```bash -# Terminal 1: build and start the server -cd ~/src/mcp/go-sdk -go build -o /tmp/go-conformance-server ./conformance/everything-server -go build -o /tmp/go-conformance-client ./conformance/everything-client -/tmp/go-conformance-server -http="localhost:3002" - -# Terminal 2: run tier-check (server + client conformance) -npm run --silent tier-check -- \ - --repo modelcontextprotocol/go-sdk \ - --conformance-server-url http://localhost:3002 \ - --client-cmd '/tmp/go-conformance-client' -``` - -**C# SDK**: - -```bash -# Terminal 1: start the server (requires .NET SDK) -cd ~/src/mcp/csharp-sdk -dotnet run --project tests/ModelContextProtocol.ConformanceServer --framework net9.0 -- --urls http://localhost:3003 - -# Terminal 2: run tier-check (server + client conformance) -npm run --silent tier-check -- \ - --repo modelcontextprotocol/csharp-sdk \ - --conformance-server-url http://localhost:3003 \ - --client-cmd 'dotnet run --project ~/src/mcp/csharp-sdk/tests/ModelContextProtocol.ConformanceClient' -``` +To reproduce one leg fully by hand, copy the build/server/client commands from that SDK's `known-sdks.ts` entry (including its `specOverrides` for the revision you're running) rather than from a README: the entries are exercised by the test suite and per-revision, prose examples are neither. **Other SDKs:** Your SDK needs an "everything server" — an HTTP server implementing the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/draft/basic/transports.md) with all MCP features (tools, resources, prompts, etc.). See the implementations above as reference. diff --git a/.claude/skills/mcp-sdk-tier-audit/SKILL.md b/.claude/skills/mcp-sdk-tier-audit/SKILL.md index ca45df9..509df1d 100644 --- a/.claude/skills/mcp-sdk-tier-audit/SKILL.md +++ b/.claude/skills/mcp-sdk-tier-audit/SKILL.md @@ -5,7 +5,7 @@ description: >- Produces tier classification (1/2/3) with evidence table, gap list, and remediation guide. Works for any official MCP SDK (TypeScript, Python, Go, C#, Java, Kotlin, PHP, Swift, Rust, Ruby). -argument-hint: ' [client-cmd]' +argument-hint: ' [conformance-server-url] [client-cmd] [--requirements ] [--branch ]' --- # MCP SDK Tier Audit @@ -26,7 +26,9 @@ If this fails (exit code non-zero or shows "not logged in"), stop immediately an Do NOT proceed to any other step if this check fails. -After parsing arguments (Step 1), also verify the conformance server is reachable: +**Known SDKs need no server from you.** When the SDK is in `src/sdk-runner/known-sdks.ts` (typescript-sdk, python-sdk, go-sdk, rust-sdk, csharp-sdk, ruby-sdk, and variants), skip the server preflight entirely: Step 2's `--sdk-path` mode builds the SDK and starts the right server per revision itself. The reachability check below applies only to the URL fallback for unknown SDKs. + +After parsing arguments (Step 1), for the URL fallback only, verify the conformance server is reachable: ```bash curl -sf -o /dev/null -w '%{http_code}' 2>&1 || true @@ -41,8 +43,10 @@ If the server is not reachable, stop and tell the user: Extract from the user's input: - **local-path**: absolute path to the SDK checkout (e.g. `~/src/mcp/typescript-sdk`) -- **conformance-server-url**: URL where the SDK's everything server is already running (e.g. `http://localhost:3000/mcp`) +- **conformance-server-url** (only for SDKs not in `known-sdks.ts`): URL where the SDK's everything server is already running (e.g. `http://localhost:3000/mcp`). That one endpoint must serve every claimed revision at its own wire version; SDKs that vary the server per revision cannot be driven this way - **client-cmd** (optional): command to run the SDK's conformance client (e.g. `npx tsx test/conformance/src/everythingClient.ts`). If not provided, client conformance tests are skipped and noted as a gap in the report. +- **requirements** (optional): spec revisions to score against, comma-separated, e.g. `--requirements 2025-11-25,2026-07-28`. Each revision's scenarios run at that revision's wire version, and every listed revision must pass for Tier 1. Scores the SDK against exactly the scenarios that revision required when it shipped, rather than everything the suite carries today. Prefer it whenever the question is "does this SDK conform to revision X". Without it, scoring uses today's suite, which can fail an SDK for a scenario added after it shipped. Run `conformance list --requirements ` to see the set. +- **branch** (optional): Git branch to check on GitHub (e.g. `--branch fweinberger/v1x-governance-docs`). If not provided, derive from the local checkout's current branch: `cd && git rev-parse --abbrev-ref HEAD`. This is passed to the tier-check CLI so that policy signal file checks use the correct branch instead of the repo's default branch. The first two arguments are required. If either is missing, ask the user to provide it. @@ -56,15 +60,36 @@ cd && git remote get-url origin | sed 's#.*github.com[:/]##; s#\.gi The `tier-check` CLI handles all deterministic checks — server conformance, client conformance, labels, triage, P0 resolution, releases, policy signals, and spec tracking. You are already in the conformance repo, so run it directly. +For a known SDK, this manages everything — build, per-revision server, both legs: + +```bash +npm run --silent tier-check -- \ + --sdk-path \ + --requirements \ + --output json +``` + +`--repo` and `--branch` are derived from the SDK config and checkout; pass them only to override. For an SDK not in `known-sdks.ts`, fall back to driving it directly: + ```bash npm run --silent tier-check -- \ --repo \ + --branch \ + --requirements \ --conformance-server-url \ --client-cmd '' \ --output json ``` -If no client-cmd was detected, omit the `--client-cmd` flag (client conformance will be skipped). +Omit `--requirements` only if the user did not name a revision. When it is set the +scorecard reports `requirements_revision`, both pass rates count exactly the +scenarios that revision requires, and anything run but not scored carries a +`notScoredReason` of `extension` or `added-after-release`. `requirements_revisions` +lists every revision scored, and each detail carries the `revision` it came from. Quote the revision +alongside any conformance number, and report the not-scored failures separately +rather than folding them into the score or omitting them. + +If no client-cmd was detected, omit the `--client-cmd` flag (client conformance will be skipped). The `--branch` flag should always be included (derived from the local checkout if not explicitly provided). The CLI output includes server conformance pass rate, client conformance pass rate (with per-spec-version breakdown), issue triage compliance, P0 resolution times, label taxonomy, stable release status, policy signal files, and spec tracking gap. Parse the JSON output to feed into Step 4. @@ -115,8 +140,8 @@ Combine the deterministic scorecard (from the CLI) with the evaluation results ( ### Tier 1 requires ALL of: -- Server conformance test pass rate == 100% -- Client conformance test pass rate == 100% +- Server conformance test pass rate == 100% across every requirement set given to `--requirements` (each run at its own wire), otherwise of date-versioned scenarios only +- Client conformance test pass rate == 100%, on the same basis - Issue triage compliance >= 90% within 2 business days - All P0 bugs resolved within 7 days - Stable release >= 1.0.0 with no pre-release suffix @@ -127,8 +152,8 @@ Combine the deterministic scorecard (from the CLI) with the evaluation results ( ### Tier 2 requires ALL of: -- Server conformance test pass rate >= 80% -- Client conformance test pass rate >= 80% +- Server conformance test pass rate >= 80% (date-versioned scenarios only) +- Client conformance test pass rate >= 80% (date-versioned scenarios only) - Issue triage compliance >= 80% within 1 month - P0 bugs resolved within 2 weeks - At least one stable release >= 1.0.0 @@ -151,11 +176,19 @@ The **full suite** pass rates (server total, client total) are used for tier thr Example: -| | 2025-03-26 | 2025-06-18 | 2025-11-25 | draft | extension | All\* | -| ------------ | ---------- | ---------- | ---------- | ----- | --------- | ------------ | -| Server | — | 26/26 | 4/4 | — | — | 30/30 (100%) | -| Client: Core | — | 2/2 | 2/2 | — | — | 4/4 (100%) | -| Client: Auth | 0/2 | 3/3 | 6/11 | 0/1 | 0/2 | 9/19 (47%) | +| | 2025-03-26 | 2025-06-18 | 2025-11-25 | All\* | +| ------------ | ---------- | ---------- | ---------- | ------------ | +| Server | — | 26/26 | 4/4 | 30/30 (100%) | +| Client: Core | — | 2/2 | 2/2 | 4/4 (100%) | +| Client: Auth | 2/2 | 3/3 | 6/11 | 8/16 (50%) | + +Informational (not scored for tier): + +| | draft | extension | +| ------------ | ----- | --------- | +| Client: Auth | 0/1 | 0/2 | + +The tier-scoring table only includes date-versioned scenarios. `draft` and `extension` scenarios are shown separately as informational — they do not affect tier advancement. This immediately shows where failures concentrate. Failures clustered in Client: Auth / `2025-11-25` means "new auth features not yet implemented" — a scope gap, not a quality problem. Failures in Server or Client: Core are more concerning. @@ -205,15 +238,21 @@ After the subagents finish, output a short executive summary directly to the use Conformance: -| | 2025-03-26 | 2025-06-18 | 2025-11-25 | draft | extension | All* | T2 | T1 | -|--------------|------------|------------|------------|-------|-----------|-------|----|----| -| Server | — | pass/total | pass/total | — | — | pass/total (rate%) | ✓/✗ | ✓/✗ | -| Client: Core | — | pass/total | pass/total | — | — | pass/total (rate%) | — | — | -| Client: Auth | pass/total | pass/total | pass/total | pass/total | pass/total | pass/total (rate%) | — | — | -| **Client Total** | | | | | | **pass/total (rate%)** | **✓/✗** | **✓/✗** | +| | 2025-03-26 | 2025-06-18 | 2025-11-25 | All* | T2 | T1 | +|--------------|------------|------------|------------|------|----|----| +| Server | — | pass/total | pass/total | pass/total (rate%) | ✓/✗ | ✓/✗ | +| Client: Core | — | pass/total | pass/total | pass/total (rate%) | — | — | +| Client: Auth | pass/total | pass/total | pass/total | pass/total (rate%) | — | — | +| **Client Total** | | | | **pass/total (rate%)** | **✓/✗** | **✓/✗** | \* unique scenarios — a scenario may apply to multiple spec versions +Informational (not scored for tier): + +| | draft | extension | +|--------------|-------|-----------| +| Client: Auth | pass/total | pass/total | + If a baseline file was found, add a note below the conformance table: > **Baseline**: {N} failures in `baseline.yml` ({list by cell, e.g. "6 in Client: Auth/2025-11-25, 2 in Client: Auth/extension"}). diff --git a/.claude/skills/new-sep/SKILL.md b/.claude/skills/new-sep/SKILL.md new file mode 100644 index 0000000..fa19a2e --- /dev/null +++ b/.claude/skills/new-sep/SKILL.md @@ -0,0 +1,201 @@ +--- +name: new-sep +description: >- + Scaffold a sep-NNNN.yaml requirement-traceability file for the MCP + conformance repo from a SEP PR's spec diff. Runs the new-sep CLI, then + parses the modelcontextprotocol/modelcontextprotocol spec diff to populate + `requirements[]` with the RFC 2119 sentences and proposed check IDs. +argument-hint: '' +--- + +# new-sep: SEP traceability YAML scaffolding + +You are bootstrapping a `sep-NNNN.yaml` file for a new SEP in the MCP conformance repo. The output is the requirement-traceability file specified by SEP-2484: a YAML that maps each normative sentence from the SEP's spec diff to a `check:` ID (testable) or an `excluded:` reason (not testable). The CLI gets the skeleton; you fill in the rows by reading the spec diff. + +## Step 0: Pre-flight checks + +Before doing anything else, verify GitHub CLI authentication: + +```bash +gh auth status 2>&1 +``` + +If this fails, stop immediately and tell the user: + +> GitHub authentication is required for this skill. Please run `gh auth login` first, then re-run. + +Verify you're running inside the conformance repo: + +```bash +test -f package.json && jq -r '.name' package.json +``` + +The name should be `@modelcontextprotocol/conformance`. If not, stop and ask the user to `cd` into the conformance repo first. + +## Step 1: Parse arguments + +Extract from the user's input: + +- **sep-number** (required): the SEP number, e.g. `2164`. This is also the PR number in `modelcontextprotocol/modelcontextprotocol` by convention. + +## Step 2: Generate the skeleton + +Run the CLI: + +```bash +npm run --silent build +node dist/index.js new-sep +``` + +(For development against a non-built source tree: `npx tsx src/index.ts new-sep ...`.) + +The CLI writes `src/seps/sep-.yaml` with `sep`, `spec_url`, and two TODO `requirements[]` rows. Capture the output path from the CLI's `Wrote …` line and remember it as `$YAML`. + +If the CLI errors with "does not change any docs/specification/draft/\*.mdx", the SEP's spec changes landed in a separate PR — ask the user for the spec file path and rerun with `--spec-path docs/specification/draft/`. Do not guess. + +## Step 3: Fetch the spec diff + +`AGENTS.md` (lines 64–72) is explicit that severity must come from the spec text itself, not the SEP markdown or the conformance PR description: + +```bash +gh api "repos/modelcontextprotocol/modelcontextprotocol/pulls//files" \ + --jq '.[] | select(.filename | test("^docs/specification/draft/.*\\.mdx$")) | {filename, patch}' +``` + +For each file, pull the added (`+`-prefixed) lines from `patch`. If `patch` is truncated for a large file, fall back to fetching the whole file at the PR's head ref: + +```bash +gh api "repos/modelcontextprotocol/modelcontextprotocol/contents/?ref=" \ + --jq '.content' | base64 -d +``` + +## Step 4: Extract RFC 2119 requirements + +Walk the added lines and identify sentences containing the keywords: **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **MAY**, **OPTIONAL**. + +**Quote the whole sentence**, not just the matched line. The matched word may sit inside a bullet point whose lead-in sentence supplies the keyword by inheritance — e.g.: + +> Servers SHOULD return standard JSON-RPC errors for common failure cases: +> +> - Resource not found: -32602 (Invalid Params) + +The bullet inherits `SHOULD`. The yaml row should quote the _combined_ obligation: `'Servers SHOULD return standard JSON-RPC errors for common failure cases: Resource not found: -32602 (Invalid Params)'` — see `src/seps/sep-2164.yaml` for the canonical example. + +**Regex alone is insufficient** (this is called out in Issue #243). Read for context: pronouns, "the server", and "such cases" all refer back to the lead-in. + +## Step 5: Map severity → check vs. excluded + +From `AGENTS.md:50-56`: + +| Keyword | Severity | YAML field | +| ---------------------------------------------- | -------- | -------------------------- | +| MUST / MUST NOT / SHALL / SHALL NOT / REQUIRED | FAILURE | `check: sep--` | +| SHOULD / SHOULD NOT | WARNING | `check: sep--` | +| MAY / OPTIONAL | — | _no row — skip entirely_ | + +MAY / OPTIONAL sentences are noted in Step 4 only so you consciously skip them — they never produce a yaml row. + +A row is `excluded:` when a MUST/SHOULD requirement can't be protocol-observed by the harness. Do **not** write any `excluded:` row on your own authority — every exclusion goes through Step 6. + +While classifying, sort each MUST/SHOULD row into one of three buckets: + +- **`check:`** — observably testable on the wire. +- **clearly-excluded** — you're confident it can't be observed (e.g. "clients SHOULD also accept -32002" when the harness only drives servers). +- **borderline** — you'd default to `check:` but observability is questionable. Markers: + - _Internal state_ — verbs like _record_, _store_, _associate_, _track_, _cache_. The harness sees wire traffic, not memory; usually only observable via a downstream row already in your list. + - _UI / human-facing_ — _display_, _show_, _render_, _prompt the user_. + - _Precondition phrasing_ — "Before doing X, the implementation MUST Y" where X is itself another row. + +Slug convention: lowercase-kebab, derived from the verb phrase. Examples from `sep-2164.yaml`: `no-empty-contents`, `error-code`. Same `id` is used for SUCCESS and FAILURE (`AGENTS.md:52`). + +## Step 6: Confirm exclusions with the user + +Nothing becomes `excluded:` without sign-off. Two rounds: + +**Round 1 — clearly-excluded, single batch question.** One `AskUserQuestion` listing all clearly-excluded rows in the question body (slug + one-line reason each). Options: + +- `Exclude all as listed (Recommended)` +- `Flip all to check:` +- `Let me adjust per-row` — if chosen, append these rows to round 2. + +Skip this round if the bucket is empty. + +**Round 2 — borderline, one question per row.** One `AskUserQuestion` call with a question per borderline row (loop in batches of 4 if needed). For each: + +- header: the proposed slug +- question: quote the requirement sentence + your one-line observability concern +- options (list `check:` first — it's the default for borderline): + - `check:` — keep as a testable check + - `excluded: ` — drop to excluded with your stated reason + - `merge into ` — offer when the row is a precondition for another row already in the list + +Apply the answers before writing. For any `excluded:` outcome, write the reason verbatim into the yaml and add an `issue:` URL if the user supplies one. A `merge` outcome means: drop this row, and append its `text:` to the surviving row's `text:` separated by `/` so the traceability isn't lost. + +## Step 7: Rewrite the YAML + +Replace the two TODO rows the CLI generated with one row per extracted requirement. Preserve the CLI's quoting style (single quotes, two-space indent — see `src/seps/sep-2164.yaml`). + +**Key order within each row** — for `check:` rows the **`check:` key comes first**, then `text:`, then any optional `url:`. Scanning the left margin should reveal every check ID without reading the quoted sentences. For `excluded:` rows the order is **`text:` first**, then `excluded:`, then optional `issue:` — there's no ID to scan for, so lead with the requirement. + +**Row order in the file** — all `check:` rows first (in spec-diff order), then **all `excluded:` rows grouped at the bottom**, separated from the checks by **one blank line**. Do not interleave. + +```yaml +requirements: + - check: sep-NNNN-first-slug + text: '...' + - check: sep-NNNN-second-slug + text: '...' + + - text: '...' + excluded: 'reason' + issue: https://github.com/modelcontextprotocol/conformance/issues/ +``` + +If a requirement is ambiguous or you're not confident, leave it as a `TODO:` row rather than guessing — humans review this yaml before scenarios get written. + +Also fix the `spec_url`: the CLI emits the page URL with no anchor. If the requirements you extracted live under a specific spec subsection (e.g. `#error-handling`), append it. + +If a requirement comes from a **different spec page** than `spec_url` (the SEP touched multiple `.mdx` files — the CLI prints these as "PR also changes N other spec file(s)"), give that row a full `url:` override: + +```yaml +- check: sep-NNNN-slug + text: '...' + url: https://modelcontextprotocol.io/specification/draft/other/page#anchor +``` + +A row's effective spec reference is `row.url ?? file.spec_url`. + +Write the result back to `$YAML`. + +## Step 8: Suggest a host scenario + +`AGENTS.md` prefers **fewer scenarios with more checks** over one-scenario-per-check. Before telling the user to write a new scenario, look for an existing one the new checks could be folded into. + +Determine the suite directory from the requirement subjects ("MCP clients MUST…" → `client/`, "Servers MUST…" → `server/`, "authorization servers MUST…" → `authorization-server/`; a SEP may map to more than one). Then search that directory for scenarios touching the same spec area: + +```bash +rg -l -i '|' src/scenarios// --type ts +``` + +Pick 2–3 domain terms from the SEP's subject matter (for a discovery SEP: `metadata`, `well-known`; for an auth-response SEP: `redirect`, `callback`, `pkce`). For each hit, pull the scenario's `name`/`description` to confirm relevance: + +```bash +rg -A1 'name:|description:' +``` + +If you find a plausible host, recommend it by path. If nothing fits, say so explicitly — a new scenario file is then the right call. + +## Step 9: Hand-off + +Report to the user, in this order: + +1. Path to the generated yaml. +2. Row counts: "`N check:` rows, `M excluded:` rows" — and note which exclusions the user signed off in Step 6. +3. Any requirements you left as `TODO:` and why. +4. **Host-scenario recommendation** from Step 8 — either "consider adding these checks to `src/scenarios//.ts` (it already exercises _X_)" or "no existing scenario covers this area; a new file is appropriate". +5. Remaining next steps the user owns: + - add the checks to the host scenario (or create one) under `src/scenarios/{client,server,authorization-server}/`, + - register any new scenario in `src/scenarios/index.ts` (`AGENTS.md:48`), + - add a passing example to the everything-client/server and a negative test, per `AGENTS.md:74-81`. + +Do **not** generate or edit scenario `.ts` files or touch `src/scenarios/index.ts`. The skill's scope ends at the yaml plus the recommendation. diff --git a/.claude/skills/review-scenario/SKILL.md b/.claude/skills/review-scenario/SKILL.md new file mode 100644 index 0000000..af17ac5 --- /dev/null +++ b/.claude/skills/review-scenario/SKILL.md @@ -0,0 +1,53 @@ +--- +name: review-scenario +description: Use when reviewing a conformance PR that adds or changes scenario .ts files for a SEP — before approving, before requesting changes, or as a self-check before opening one. +--- + +# review-scenario + +## What to check + +**Spec diff is ground truth.** Pull the SEP's actual spec changes and read the RFC-2119 sentences yourself — don't trust the PR description or SEP summary for keyword levels: + +```bash +gh api "repos/modelcontextprotocol/modelcontextprotocol/pulls//files" \ + --jq '.[] | select(.filename | test("^docs/specification/draft/.*\\.mdx$")) | {filename, patch}' +``` + +If the SEP includes a conformance-test-case table, that table is authoritative for the cases it lists. A table/prose mismatch is a spec gap to flag, not something to silently resolve either way. + +**Traceability YAML.** `src/seps/sep-.yaml` should exist (run `/new-sep ` first if not). Diff its rows against the spec sentences you extracted; flag rows that paraphrase rather than quote, claim a keyword level the spec doesn't, or assert something the spec never says. Check IDs follow `sep--`. + +**Per-scenario-file:** + +- **Spec backing** — would a fully spec-compliant implementation FAIL this check? If yes — or if two compliant SDKs in different languages would get different results — the spec hasn't pinned the behavior; note it as a gap rather than enforce it. +- **Dead checks** — emits FAILURE with no reachable SUCCESS counterpart, or sits behind an always-false guard. +- **Logic** — does a missing/malformed input silently pass? Does the assertion distinguish "rejected for the right reason" from "rejected at all"? + +**Coverage.** Count YAML `check:` rows vs how many the PR's scenarios actually exercise; list the gaps. + +**Proof it runs.** The PR should reference at least one real implementation the scenario ran green against — the in-repo everything-client/server, or an external SDK via `npx https://pkg.pr.new/@modelcontextprotocol/conformance@`. No run referenced → ask for one before approving. + +**Negative test.** Pins the specific failing slugs, not just `failures.length > 0` (AGENTS.md §Examples: prove it passes and fails). + +## Output + +This is a first pass for a human reviewer — give them what they need to verify each finding without re-deriving it. + +**Open with a summary:** N scenarios added/changed, M distinct check IDs emitted, X/Y YAML `check:` rows covered, and which implementation it was run against. + +**Then one bullet per finding.** Each bullet makes its own case — the reviewer should be able to confirm or refute it from the bullet alone: + +> **``** — [`file.ts:Lnn`](https://github.com/modelcontextprotocol/conformance/blob//path/file.ts#Lnn) — claim. Spec: _"quoted normative sentence"_ ([page#anchor](https://modelcontextprotocol.io/specification/draft/...#anchor)). Consequence: what a compliant impl would do and how this check would mis-report it. + +e.g. + +> **`client-consistent-version`** — [`stateless.ts:86`](…/blob/abc123/src/scenarios/client/stateless.ts#L86) — no spec backing. Spec: _"Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version)"_ ([lifecycle#stateless](…)). A compliant client may change `protocolVersion` per request; this check FAILs it. The `flippingVersionClient` negative test enforces a non-requirement. + +Get `` once via `gh pr view --json headRefOid -q .headRefOid` and use it for all permalinks so they don't drift on rebase. + +Order: spec-backing → logic/dead → coverage (gap list) → conventions. Put spec gaps in a separate trailing list — those go upstream, not to the PR author. + +**Self-review:** fix in place and re-run. + +**If asked to push fixes** (stacked diff on top of the PR head): one commit per finding, commit message is the finding. Leave design-level items (scenario count, refactors) as prose. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7fce40a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Authorization server conformance area — team managed in modelcontextprotocol/access +/src/scenarios/authorization-server/ @modelcontextprotocol/conformance-auth +/src/runner/authorization-server.ts @modelcontextprotocol/conformance-auth diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 9d1dc11..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,29 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: monthly - groups: - all-dependencies: - patterns: - - '*' - - - package-ecosystem: npm - directory: /examples/servers/typescript - schedule: - interval: monthly - groups: - all-dependencies: - patterns: - - '*' - - - package-ecosystem: github-actions - directory: / - schedule: - interval: monthly - groups: - all-actions: - patterns: - - '*' diff --git a/.github/workflows/traceability.yml b/.github/workflows/traceability.yml new file mode 100644 index 0000000..3195105 --- /dev/null +++ b/.github/workflows/traceability.yml @@ -0,0 +1,130 @@ +name: Refresh SEP traceability manifest + +# Regenerates src/seps/traceability.json by running the conformance suite against +# the reference SDK and recording which check IDs were emitted, then opens a PR +# with the diff. NOT a PR gate — runs on demand / on a schedule and proposes an +# update for review. plan.modelcontextprotocol.io reads the committed file from +# main. +# +# Depends on the `conformance sdk` subcommand (#277), which clones+builds the SDK +# and runs the client+server suites. The `run` job executes third-party SDK code, +# so it has NO repo write token (read-only perms, persist-credentials: false) and +# only uploads results as an artifact; the separate `propose` job holds the +# write/PR permissions and never executes SDK code. + +on: + workflow_dispatch: + inputs: + sdk: + description: 'SDK ref to run against (e.g. typescript-sdk@)' + default: 'typescript-sdk@main' + schedule: + - cron: '0 6 * * 1' # Weekly, Monday 06:00 UTC. + +concurrency: + group: traceability-refresh + cancel-in-progress: true + +jobs: + run: + runs-on: ubuntu-latest + permissions: + contents: read + env: + SDK_REF: ${{ inputs.sdk || 'typescript-sdk@main' }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false # no git token while SDK code runs + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + # The SDK's own build (e.g. typescript-sdk uses `pnpm install && pnpm run + # build:all`) needs pnpm on PATH; corepack provides it. + - run: corepack enable + + - run: npm ci + - run: npm run build + + - name: Run conformance suites against the reference SDK + # `sdk` requires --mode client|server; run both into the same results dir + # (the second reuses the cached checkout + build via --skip-build). + # `|| true`: the manifest only needs the emitted check IDs (written + # regardless of pass/fail), so SDK conformance failures must not fail + # this step. The "no results produced" guard below is the real safety net. + run: | + node dist/index.js sdk "$SDK_REF" --mode client --suite all -o results || true + node dist/index.js sdk "$SDK_REF" --mode server --suite all --skip-build -o results || true + + - name: Fail if no results were produced + run: | + if [ -z "$(find results -name checks.json -print -quit 2>/dev/null)" ]; then + echo "No checks.json produced — the suite run failed; not proposing a manifest." + exit 1 + fi + + - uses: actions/upload-artifact@v4 + with: + name: conformance-results + path: results + retention-days: 7 + + propose: + needs: run + runs-on: ubuntu-latest + # Requires the repo/org setting "Allow GitHub Actions to create and approve + # pull requests" to be enabled, otherwise `gh pr create` fails. + permissions: + contents: write + pull-requests: write + env: + SDK_REF: ${{ inputs.sdk || 'typescript-sdk@main' }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + + - uses: actions/download-artifact@v4 + with: + name: conformance-results + path: results + + - name: Regenerate manifest + run: | + set -euo pipefail + # Record the resolved sha (stable per SDK commit) so the manifest's + # `source` only changes when the SDK actually advances — no per-run noise. + ref="${SDK_REF#*@}" + sha="$(git ls-remote https://github.com/modelcontextprotocol/typescript-sdk.git "$ref" | cut -f1)" + node dist/index.js traceability --results results \ + --source "typescript-sdk@${sha:0:12}" + + - name: Open/update the rolling refresh PR + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if git diff --quiet -- src/seps/traceability.json; then + echo "traceability.json unchanged" + exit 0 + fi + # One rolling branch/PR, force-updated each run, so the schedule does + # not accrue a new PR every week. + branch="traceability-refresh" + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -B "$branch" + git add src/seps/traceability.json + git commit -m "chore: refresh SEP traceability manifest ($SDK_REF)" + git push --force origin "$branch" + gh pr view "$branch" >/dev/null 2>&1 || gh pr create \ + --head "$branch" \ + --title 'chore: refresh SEP traceability manifest' \ + --body 'Automated refresh from a conformance run against the reference SDK. Review the coverage diff before merging.' diff --git a/.gitignore b/.gitignore index bd9f0f5..2b6bcd5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ lefthook-local.yml dist/ .vscode/ .idea/ +.claude/settings.local.json +.sdk-under-test/ +.sync-schema-tmp/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a354d83 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +# Generated by `conformance traceability` — formatting is owned by the +# generator (deterministic JSON.stringify), not Prettier. Without this, the +# repo's `prettier --check .` would reformat the file and fight the generator's +# output (and the refresh workflow's `git diff` check). +src/seps/traceability.json + +# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts and +# schema.json via `npm run sync-schema`. Keep byte-identical with upstream so +# the SOURCE pin is meaningful and re-syncing produces a clean diff. +src/spec-types/*.ts +src/spec-types/*.schema.json + +# Local tooling workspaces (not part of the repo). +.claude/ +.sdk-under-test/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca88ff8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,126 @@ +# AGENTS.md + +Guidance for AI agents (and humans) contributing to the MCP conformance test framework. + +## What this repo is + +A test harness that exercises MCP SDK implementations against the protocol spec. The coverage number that matters here is **spec coverage** — how much of the protocol the scenarios test. + +Uses **npm** (not pnpm/yarn). Don't commit `pnpm-lock.yaml` or `yarn.lock`. + +## Where to start + +**Open an issue first** — whether you've hit a bug in the harness or want to propose a new scenario. For scenarios, sketch which part of the spec you want to cover and roughly how; for bugs, include the command you ran and the output. Either way, a short discussion up front beats review churn on a PR that overlaps existing work or heads in a direction we're not going. + +**Don't point an agent at the repo and ask it to "find bugs."** Generic bug-hunting on a test harness produces low-signal PRs (typo fixes, unused-variable cleanups, speculative refactors). If you want to contribute via an agent, give it a concrete target: + +- Pick a specific MUST or SHOULD from the [MCP spec](https://modelcontextprotocol.io/specification/) that has no scenario yet, and ask the agent to draft one. +- Pick an [open issue](https://github.com/modelcontextprotocol/conformance/issues) and work on that. + +The valuable contribution here is **spec coverage**, not harness polish. + +## Scenario design: fewer scenarios, more checks + +**The strongest rule in this repo:** prefer one scenario with many checks over many scenarios with one check each. + +Why: + +- Each scenario often spins up its own HTTP server. These suites run in CI on every push for every SDK, so per-scenario overhead multiplies fast. +- Less code to maintain and update when the spec shifts. +- Progress on making an SDK better shows up as "pass 7/10 checks" rather than "pass 1 test, fail another" — finer-grained signal from the same run. + +### Granularity heuristic + +Ask: **"Would it make sense for someone to implement a server/client that does just this scenario?"** + +If two scenarios would always be implemented together, merge them. Examples: + +- `tools/list` + a simple `tools/call` → one scenario +- All content-type variants (image, audio, mixed, resource) → one scenario +- Full OAuth flow with token refresh → one scenario, not separate "basic" + "refresh" scenarios. A client that passes "basic" but not "refresh" just shows up as passing N−2 checks. + +Keep scenarios separate when they're genuinely independent features or when they're mutually exclusive (e.g., an SDK should support writing a server that _doesn't_ implement certain stateful features). + +### When a PR adds scenarios + +- Start with **one end-to-end scenario** covering the happy path with many checks along the way. +- Don't add "step 1 only" and "step 1+2" as separate scenarios — the second subsumes the first. +- Register the scenario in the appropriate suite list in `src/scenarios/index.ts` (`core`, `extensions`, `backcompat`, etc.). + +## Check conventions + +- **Same `id` for SUCCESS and FAIL.** A check should use one slug and flip `status` + `errorMessage`, not branch into `foo-success` vs `foo-failure` slugs. +- **Optimize for Ctrl+F on the slug.** Repetitive check blocks are fine — easier to find the failing one than to unwind a clever helper. +- Reuse `ConformanceCheck` and other types from `src/types.ts` rather than defining parallel shapes. +- **Don't reimplement the runner.** New subcommands that need to "select scenarios → run them → print summary → compute exit code" must go through the existing `client` / `server` commands (subprocess via `process.execPath` like `tier-check` and `sdk` do) or call shared helpers — never a parallel suite-map / summary loop. +- Include `specReferences` pointing to the relevant spec section. +- **Severity follows the spec keyword:** MUST / MUST NOT → `FAILURE`; SHOULD / SHOULD NOT → `WARNING`. (CI treats WARNING as a failure, so Tier-1 SDKs still need to satisfy SHOULDs — see #245.) +- **A missing prerequisite is a failure, not a skip** (#248). When a check cannot be exercised — the server under test lacks a diagnostic fixture tool, rejects the probe, or advertises a feature it does not serve — report it failed via `notTestable()` / `untestableCheck()` from `src/scenarios/untestable.ts`, with the missing prerequisite named in `errorMessage` and `details.untestable: true`. SKIPPED reads as green in every consumer (pass counts, exit codes, expected-failures), so it is reserved for checks that are legitimately not applicable: an optional capability the server never declared, spec-version inapplicability handled by the runner, or a documented harness gap the suite itself tracks (e.g. `tasks-status-notifications` pending its subscriptions/listen rewrite). The untestable severity follows the underlying requirement's keyword (MUST → `FAILURE`, SHOULD → `WARNING`); the expected-failures baseline is the escape hatch for SDKs that haven't built the fixture yet. + +## Descriptions and wording + +Be precise about what's **required** vs **optional**. A scenario description that tests optional behavior should make that clear — e.g. "Tests that a client _that wants a refresh token_ handles offline_access scope…" not "Tests that a client handles offline_access scope…". Don't accidentally promote a MAY/SHOULD to a MUST in the prose. + +When in doubt about spec details (OAuth parameters, audiences, grant types), check the actual spec in `modelcontextprotocol` rather than guessing. + +## Reviewing PRs + +### SEP scenarios + +Verify requirement levels against the SEP's **spec diff** — the change to `docs/specification/draft/` in the SEP's PR — not the SEP markdown summary or the conformance PR's description. The keyword that governs check severity is the one in the spec text; a bullet under a "Servers SHOULD…" sentence is SHOULD-level even if the SEP's title says "standardize." + +```sh +gh api "repos/modelcontextprotocol/modelcontextprotocol/contents/docs/specification/draft/?ref=" --jq '.content' | base64 -d +``` + +### Adding a new SEP + +Scaffold the requirement-traceability YAML with: + +```sh +npx @modelcontextprotocol/conformance new-sep +``` + +The command looks up PR #`` in `modelcontextprotocol/modelcontextprotocol` (SEP numbers are PR numbers), derives `spec_url` from the `docs/specification/draft/*.mdx` file it changes, and writes `src/seps/sep-.yaml` with TODO `requirements[]` rows. Use `--spec-path` or `--spec-url` to skip the lookup. The `new-sep` Claude Code skill drives the same flow end-to-end, parses the spec diff, and fills in the requirement rows. + +### Traceability manifest + +`src/seps/traceability.json` is a generated map of, per SEP, which declared `check:` IDs are actually emitted when the conformance suite runs against the reference SDK. It is consumed by plan.modelcontextprotocol.io to track SEP-2484 progress. + +The emitted check IDs come from a real suite run (not a source scan), so dynamic (template-literal) IDs resolve to their concrete values. Generate the manifest from a results directory: + +```sh +# 1. Run the suite against the reference SDK, collecting checks.json files: +node dist/index.js client --command '' --suite all -o results +node dist/index.js server --url '' --suite all -o results +# 2. Build the manifest from those results: +npm run traceability -- --results results +npm run traceability -- --results results --strict # exit 1 on any untested (advisory) +``` + +Manifest shape: `{ schemaVersion, docs, source, seps }`, where `seps` is keyed by SEP number. Each requirement is `tested` (its check ID was emitted) or `untested` (declared but never emitted — a real gap, or a check that only fires against a deliberately-broken impl, i.e. it needs a negative test). `"tested" means a scenario emitted the check ID, NOT that any SDK passes it` — per-SDK results live in `tier-check`. Matching is exact, so a scenario's emitted check IDs must match the requirement slugs in the yaml (one check ID per MUST/SHOULD, emitted once per case). `source` records what was run against (e.g. `typescript-sdk@`); the `docs` field points back here. + +Contract for consumers (plan.mcp.io): a SEP appears only if it has a traceability yaml or emits `sep-NNNN-*` check IDs. **A SEP absent from the manifest has no conformance artifacts — treat it as not-started** (diff against your own SEP list to find them). `untracked` lists emitted IDs with no yaml row (usually scenario gates). + +The manifest is refreshed by `.github/workflows/traceability.yml` (manual/scheduled), which runs the suite against typescript-sdk and opens a PR with the diff — it is **not** a PR gate. Untested checks are advisory for now; the intended future policy is that an untested check must be backed by a negative test. + +## Examples: prove it passes and fails + +A new scenario should come with: + +1. **A passing example** — usually by extending `examples/clients/typescript/everything-client.ts` or the everything-server, not a new file. +2. **A negative test** — a deliberately-broken implementation in `examples/{clients,servers}/typescript/` plus a vitest case asserting the check emits `FAILURE`/`WARNING` against it. See `src/scenarios/client/auth/index.test.ts` and `src/scenarios/server/negative.test.ts` for the pattern. A passing run against the everything-server proves the check doesn't false-positive, but not that it catches anything. + +Delete unused example scenarios. If a scenario key in the everything-client has no corresponding test, remove it. + +## Don't add new ways to run tests + +Use the existing CLI runner (`npx @modelcontextprotocol/conformance client|server ...`). If you need a feature the runner doesn't have, add it to the runner rather than building a parallel entry point. + +## Before opening a PR + +- `npm run build` passes +- `npm test` passes +- For non-trivial scenario changes, run against at least one real SDK (typescript-sdk or python-sdk) to see actual output. For changes to shared infrastructure (runner, tier-check), test against go-sdk or csharp-sdk too. +- Scenario is registered in the right suite in `src/scenarios/index.ts` +- If you changed a `sep-*.yaml` or scenario check IDs, `src/seps/traceability.json` will drift; the traceability workflow refreshes it via PR (or regenerate locally with `--results` from a suite run) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..920b175 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing + +Thanks for helping improve the MCP conformance suite! + +The most valuable contributions are **new conformance scenarios** that cover under-tested parts of the [MCP spec](https://modelcontextprotocol.io/specification/). If you're not sure where to start, ask in `#conformance-testing-wg` on the MCP Contributors Discord. + +## Before you start + +**Open an issue first** — whether you've found a bug or want to propose a new scenario. A short discussion up front saves everyone time on PRs that overlap existing work or head in a direction we're not going. + +Then read **[AGENTS.md](./AGENTS.md)** — it's the design guide for scenarios and checks. The short version: + +- **Fewer scenarios, more checks.** Each scenario spins up its own server and runs in CI for every SDK. One scenario with 10 checks beats 10 scenarios with one check each. +- **Prove it passes and fails.** Extend the existing everything-client/server to pass your scenario, and show (or include) a failing case. +- **Reuse the CLI runner.** Don't add parallel entry points. + +If you're using an AI agent to help, please **don't** point it at the repo with a generic "find bugs" prompt — give it a specific MUST from the spec or an open issue to work on. See AGENTS.md for details. + +## Setup + +```sh +npm install +npm run build +npm test +``` + +This repo uses **npm** — don't commit `pnpm-lock.yaml` or `yarn.lock`. + +## Running your scenario + +Run a client scenario against the bundled example client: + +```sh +npm run build +node dist/index.js client --command "tsx examples/clients/typescript/everything-client.ts" --scenario +``` + +Run a server scenario against any server running locally (replace 3000 with your server's port, if necessary): + +```sh +# Against a server +node dist/index.js server --url http://localhost:3000/mcp --scenario +``` + +Run a server scenario against the latest TypeScript SDK: + +```sh +# In another directory, clone and prepare the SDK once +cd .. +git clone https://github.com/modelcontextprotocol/typescript-sdk.git +cd typescript-sdk +pnpm install +pnpm run build:all + +# Start the SDK's conformance server in one terminal +cd test/conformance +PORT=3100 pnpm run test:conformance:server:run + +# Back in this repo, run your local conformance build against it +cd /path/to/conformance +npm run build +node dist/index.js server --url http://localhost:3100/mcp --scenario +``` + +See the [README](./README.md) for full CLI options and the [SDK Integration Guide](./SDK_INTEGRATION.md) for more on testing against a real SDK. + +## Pull requests + +- Register your scenario in the right suite in `src/scenarios/index.ts` +- Run against at least one real SDK (see above) before opening the PR — we'll ask what the output looked like +- Keep PRs focused; one feature or scenario group at a time diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..b099240 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,10 @@ +# MCP Maintainers + +This document lists current maintainers of the Model Context Protocol conformance test framework. + +**Last updated:** March 27, 2026 + +## Maintainers + +- [Paul Carleton](https://github.com/pcarleton) +- [Felix Weinberger](https://github.com/felixweinberger) diff --git a/README.md b/README.md index 8096214..d5c4cb3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,6 @@ A framework for testing MCP (Model Context Protocol) client and server implementations against the specification. -> [!WARNING] -> This repository is a work in progress and is unstable. Join the conversation in the #conformance-testing-wg in the MCP Contributors discord. - **For SDK maintainers:** See [SDK Integration Guide](./SDK_INTEGRATION.md) for a streamlined guide on integrating conformance tests into your SDK repository. ## Quick Start @@ -66,12 +63,15 @@ npx @modelcontextprotocol/conformance client --command "" --scen - `--command` - The command to run your MCP client (can include flags) - `--scenario` - The test scenario to run (e.g., "initialize") -- `--suite` - Run a suite of tests in parallel (e.g., "auth") +- `--suite` - Run a suite of tests in parallel: `all`, `core`, `extensions`, `backcompat`, `auth`, `metadata`, `draft` (scenarios targeting the in-progress draft spec), or `sep-835` +- `--spec-version ` - Filter scenarios by spec version (e.g., `2025-11-25`, `2026-07-28`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios. When omitted, the version is inferred from the scenario's spec applicability (draft-only scenarios run at the draft version, everything else at the latest dated release); an explicitly requested version outside a scenario's applicability window skips the scenario (exit 0) unless `--force` is passed +- `--force` - Run a scenario even if it is not applicable at the requested `--spec-version` +- `--requirements ` - Run exactly what a spec revision requires, frozen at its release (see [Conformance Requirements](#conformance-requirements)) - `--expected-failures ` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures)) - `--timeout` - Timeout in milliseconds (default: 30000) - `--verbose` - Show verbose output -The framework appends `` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data. +The framework appends `` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data. When `--spec-version` is passed, its resolved value is forwarded to the client process as `MCP_CONFORMANCE_PROTOCOL_VERSION`; example clients can use this value directly as their `protocolVersion`. SDKs that hard-code their protocol version can ignore it. Clients under test must derive the lifecycle from the protocol version they are asked to run: dated versions through `2025-11-25` use the stateful lifecycle (initialize handshake), while the 2026 draft (`2026-07-28`) uses the stateless lifecycle (per-request `_meta`). ### Server Testing @@ -83,7 +83,8 @@ npx @modelcontextprotocol/conformance server --url [--scenario ] - `--url` - URL of the server to test - `--scenario ` - Test scenario to run (e.g., "server-initialize"). Runs all available scenarios by default -- `--suite ` - Suite to run: "active" (default), "all", or "pending" +- `--suite ` - Suite to run: "active" (default; excludes pending and draft-spec scenarios), "all", "draft" (scenarios targeting the in-progress draft spec), or "pending" +- `--requirements ` - Run exactly what a spec revision requires, frozen at its release (see [Conformance Requirements](#conformance-requirements)) - `--expected-failures ` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures)) - `--verbose` - Show verbose output @@ -99,6 +100,92 @@ npx @modelcontextprotocol/conformance server --url [--scenario ] - `checks.json` - Array of conformance check results with pass/fail status +### Wire-schema checks + +Every scenario also validates each JSON-RPC message on the wire against the +spec's JSON schema for the negotiated spec version, and emits up to two +synthetic checks alongside the scenario's own: + +- `wire-schema-valid` - fails when a message _the implementation under test + sent_ violates the spec JSON schema. The failure details include every + violating message and its schema errors. +- `wire-schema-harness-error` - fails when the _harness itself_ sent an + invalid message. This indicates a bug in the conformance suite (or a + deliberately nonconformant fixture), not in the implementation under test; + please report it. + +Scenarios that exchange no instrumented wire traffic (see issue #418) emit +neither check. Like any other check, `wire-schema-valid` can be baselined via +the expected-failures file. + +## Conformance Requirements + +`--suite` and `--spec-version` describe the suite as it is today. Neither answers +"which scenarios did I need to pass to conform to the spec released on +2026-07-28", because the suite keeps growing: a scenario merged after a revision +ships still carries that revision's applicability tag, so it is +indistinguishable from one that existed at release. + +A requirement set answers that question. Each `requirements/.yaml` +names the scenarios a revision requires, for the two roles the specification +defines: an MCP server acting as an OAuth resource server, and an MCP client +acting as an OAuth client. It deliberately covers no authorization-server +scenarios, because the specification puts authorization server implementation +beyond its own scope, so those scenarios serve people deploying an authorization +server rather than implementations of MCP itself. + +**Scenarios run at their revision's wire version.** That is the point of a +per-revision set, not a label on it: the dated revisions through `2025-11-25` +use the stateful initialize handshake and `2026-07-28` is stateless with +per-request `_meta`, and a scenario emits different checks under each. A +scenario belonging to both revisions must therefore be run twice, once under +each set. Passing it on one wire says nothing about the other: + +```bash +# what does conforming to 2026-07-28 actually require? +npx @modelcontextprotocol/conformance list --requirements 2026-07-28 + +# run exactly that +npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp --requirements 2026-07-28 +``` + +`--requirements` replaces `--suite`, `--spec-version` and `--scenario`, since the +set already names every scenario that runs and the revision fixes the wire they +run at. Without it nothing changes: the default is still to run everything, which +is where completeness lives. + +`tier-check` takes several at once, and every one of them must pass for Tier 1: + +```bash +npx @modelcontextprotocol/conformance tier-check --repo \ + --conformance-server-url http://localhost:3000/mcp \ + --requirements 2025-11-25,2026-07-28 +``` + +Only scenarios a revision actually requires decide the exit code and the pass +rate. Anything run without being scored is reported separately and cannot fail +the run. + +Requirement sets are frozen, and `not_scored` holds what a revision runs and +reports without counting. Two reasons qualify, and the report names which +applies: + +| Reason | Meaning | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `extension` | Optional by definition. SEP-1730: "Experimental features and protocol extensions (such as Tasks and MCP Apps) are not required for any tier." | +| `added-after-release` | The scenario did not exist when the revision shipped, so no implementation could have been passing it. | +| `pending` | The suite's own reference fixture cannot pass it yet, so it cannot be required — but the implementation under test may pass it, so it runs for visibility. | + +Both still run, so a failing extension stays visible in the report; neither moves +the pass rate. Promoting an entry into the required lists is a deliberate, +reviewable change, which is how the suite grows without retroactively failing +anyone. + +This is the project's contract and lives in this repository. It is the opposite +of an [expected-failures](#expected-failures) baseline, which lives in an +implementation's own repository and records what that implementation knows it +fails. A baselined failure is still a failure against a requirement set. + ## Expected Failures SDKs that don't yet pass all conformance tests can specify a baseline of known failures. This allows running conformance tests in CI without failing, while still catching regressions. @@ -135,6 +222,40 @@ This ensures: - CI fails on new regressions (unexpected failures) - CI fails when a fix lands but the baseline isn't updated (stale entries) +### Baselining a single check + +A scenario is many checks — `server-stateless` alone is over twenty — so baselining the +whole scenario to excuse one of them stops enforcing the other nineteen. An entry can +instead name a single check, as `:`: + +```yaml +server: + - tasks-lifecycle # whole scenario may fail + - server-stateless:sep-2575-server-implements-discover # only this check may fail +``` + +The check id is the left-hand column the runner already prints for each check, so it can +be copied straight out of a failing run. + +With a per-check entry, every failing check in that scenario is judged on its own: the +named one is excused, and any other failure is still an unexpected regression. The four +exit-code rules above apply per check rather than per scenario, with one addition — a +baselined check that is absent or skipped is tolerated, because a scenario that bails on +a failed prerequisite legitimately never reaches its later checks, and the prerequisite +reports its own failure anyway. + +Two things to know: + +- **A check id addresses every occurrence of that id.** Ids repeat within a run (a loop, + a retried flow), and the occurrences collapse to one verdict, most-severe first. So + baselining a repeated id excuses all of its occurrences — coarser than ideal, still far + narrower than baselining the scenario. +- **Mind the space.** `- scenario:check-id` is a string; `- scenario: check-id` is YAML + for a mapping and is rejected with an error. + +A scenario cannot be listed both wholesale and per-check — the wholesale entry already +excuses everything, so the pair is contradictory and is rejected. + ## GitHub Action This repo provides a composite GitHub Action so SDK repos don't need to write their own conformance scripts. @@ -212,28 +333,149 @@ Run `npx @modelcontextprotocol/conformance list --server` to see all available s - **resources-\*** - Resource management scenarios - **prompts-\*** - Prompt management scenarios -## SDK Tier Assessment +## Running Against an SDK at a Specific Ref -The `tier-check` subcommand evaluates an MCP SDK repository against [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) (the SDK Tiering System): +The `sdk` subcommand clones an SDK repository at a given ref, builds it, and runs the **local** conformance build against it. This is the inner-loop tool for scenario authors and the basis for cross-SDK CI. Examples below use `npm start --` so they run from source — no `npm run build` between edits. + +`--mode client` or `--mode server` is required — each invocation tests exactly one side, so client and server are run (and pass/fail) independently. + +```bash +# Run the client conformance suite against typescript-sdk @main (v2) +npm start -- sdk typescript-sdk --mode client + +# Run the server conformance suite (separate invocation) +npm start -- sdk typescript-sdk --mode server + +# A specific main-line SHA or branch (v2 monorepo) +npm start -- sdk typescript-sdk@abc123f --mode client +npm start -- sdk typescript-sdk@some-branch --mode server + +# The published v1.x line — separate entry (npm build), defaults to the v1.x branch +npm start -- sdk typescript-sdk-v1 --mode client +npm start -- sdk typescript-sdk-v1@v1.29.0 --mode server + +# Use an existing local checkout (no clone, no fetch) +npm start -- sdk --path ../typescript-sdk --skip-build --mode client + +# Narrow to one scenario / suite +npm start -- sdk --path ../typescript-sdk --mode server --scenario server-initialize +npm start -- sdk typescript-sdk --mode client --suite auth + +# Target a specific spec version (passed through to the underlying run). +# When omitted, the SDK's `specVersion` from KNOWN_SDKS is used, if set — +# e.g. typescript-sdk-v1 defaults to 2025-11-25. +npm start -- sdk typescript-sdk --mode client --spec-version draft +``` + +Build/run commands for each official SDK are looked up by name from [`src/sdk-runner/known-sdks.ts`](src/sdk-runner/known-sdks.ts) — no config file is required in the SDK repo. Resolution order is **CLI flag > built-in entry**, so any field can be overridden on the command line for refs that diverge from the built-in. + +An SDK can have more than one entry when its layout differs across major versions — e.g. `typescript-sdk` (v2, the `main` monorepo) and `typescript-sdk-v1` (the published npm v1.x line). An entry may set `defaultRef` (the branch used when you don't pass `@`) and `repo` (the real clone target when the entry name is an alias). + +When the right invocation depends on the spec version being targeted, the entry carries it in `specOverrides` instead of a comment to copy from. The matching entry is merged over the base config when you pass `--spec-version` (or when the entry's own `specVersion` default applies), so version-specific runs need no extra flags: + +```bash +# go-sdk's server pins -stateless=false for the dated-spec suites; its +# specOverrides['2026-07-28'] entry swaps in the stateless invocation, so +# this is the whole command: +npm start -- sdk go-sdk --mode server --suite all --spec-version 2026-07-28 + +# same for csharp-sdk (stateless URL), rust-sdk (STATELESS=1 env), and +# python-sdk (per-revision expected-failures baseline) +``` + +Explicit CLI flags still beat everything, config included — overriding a field for a one-off run: + +```bash +npm start -- sdk go-sdk@my-fork-branch --mode server \ + --build-cmd 'go build -o ./.conformance-server ./experimental/server' +``` + +To add a new SDK to the matrix, add an entry to `KNOWN_SDKS`. + +Clones are cached under `.sdk-under-test/` and reused (fetched) on subsequent runs. + +## SDK Tier Assessment + +The `tier-check` subcommand evaluates an MCP SDK repository against [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) (the SDK Tiering System). There are two ways to run it, and they answer different questions. + +### 1. The CLI, for the deterministic half + +Conformance pass rates, issue triage, P0 resolution, labels, releases, policy files. No AI, no judgment, reproducible. + +For a known SDK (see `src/sdk-runner/known-sdks.ts`), one command does the whole +thing — clone, build, and for **each** shipped revision start the server +invocation that SDK's config declares for that revision, run both legs at that +revision's wire, and merge: ```bash -# Without conformance tests (fastest) gh auth login -npm run --silent tier-check -- --repo modelcontextprotocol/typescript-sdk --skip-conformance +npx @modelcontextprotocol/conformance tier-check --sdk go-sdk +# or against a checkout you already have: +npx @modelcontextprotocol/conformance tier-check --sdk-path ~/src/go-sdk +``` -# With conformance tests (start the everything server first) -npm run --silent tier-check -- \ +Per-revision resolution is the point, not a convenience. Some SDKs fix their +wire era per server process (go-sdk only speaks 2026-07-28 when started +stateless) or per endpoint (csharp-sdk serves it at `/stateless`), so no single +server URL can be measured against two revisions. The per-SDK, per-revision +invocations live in `known-sdks.ts` `specOverrides`; adding an SDK there is how +it joins the matrix. + +For an SDK not in the config, drive it directly — but then the one endpoint you +give must serve every claimed revision at its own wire, and the server must +already be running: + +```bash +npx @modelcontextprotocol/conformance tier-check \ --repo modelcontextprotocol/typescript-sdk \ - --conformance-server-url http://localhost:3000/mcp + --conformance-server-url http://localhost:3000/mcp \ + --client-cmd '' \ + --requirements 2025-11-25,2026-07-28 ``` -For a full AI-assisted assessment with remediation guide, use Claude Code: +Omit `--client-cmd` and the client leg is skipped and reported as a gap. Omit +`--requirements` and scoring falls back to the suite as it stands today, which is +not what you want for a tier claim; see [Conformance Requirements](#conformance-requirements). + +The exit code is a CI verdict on the machine-checkable half: nonzero when any +scored conformance scenario fails or a leg could not be measured. Governance +findings (triage, P0s, policy files) shape the tier but never the exit code. + +### 2. The skill, for the whole assessment + +The CLI cannot judge documentation coverage, dependency policy or roadmap quality, and those decide the tier as much as conformance does. The [`mcp-sdk-tier-audit`](.claude/skills/mcp-sdk-tier-audit/README.md) skill runs the CLI, adds those evaluations, and writes a full report with a remediation plan. In Claude Code, from a checkout of this repo: ``` -/mcp-sdk-tier-audit +/mcp-sdk-tier-audit '' --requirements 2025-11-25,2026-07-28 ``` -See [`.claude/skills/mcp-sdk-tier-audit/README.md`](.claude/skills/mcp-sdk-tier-audit/README.md) for full documentation. +The server must already be running and stay up for the whole audit. Expect a few minutes. + +### Reading the result + +``` +Scored against 2025-11-25 and 2026-07-28, each run at its own wire version. + + Server 67/67 required scenarios (100%) + Client 50/50 required scenarios (100%) + + Not scored (8 run, 4 failing, no effect on tier): + ✗ auth/dpop (extension) + ✗ json-schema-2020-12-preservation (added-after-release) + +Tier 1 Blockers: + • triage + • p0_resolution +``` + +- **Required scenarios** are the only ones that move the number. `67/67` spans every revision listed: a scenario belonging to both runs once per revision, on that revision's wire, and both have to pass. +- **Not scored** ran and is reported so you can see it, but cannot fail the tier. `extension` means optional by definition; `added-after-release` means the scenario did not exist when that revision shipped. A failure here is information, not a blocker. +- **Not measured** is different from either, and means the run could not happen at all, e.g. a requirement set naming a scenario this build no longer has. Treat it as a broken invocation, never as an SDK failure. +- **Tier 1 blockers** lists every requirement short of Tier 1. Conformance absent from that list means the SDK met every requirement each listed revision imposes. + +The exit code follows the same rule: it reflects required scenarios only, so an implementation that meets a revision's requirements exits 0 even with failing extensions. + +In `--output json`, `passed` / `failed` / `total` describe the scored set and so always agree with `pass_rate`; anything run without being scored is counted separately under `not_scored`. ## Architecture diff --git a/action.yml b/action.yml index e5fe549..f3ee636 100644 --- a/action.yml +++ b/action.yml @@ -28,28 +28,28 @@ inputs: required: false default: 'false' node-version: - description: 'Node.js version to use (default: 20)' + description: 'Node.js version to use (default: 22)' required: false - default: '20' + default: '22' runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - name: Build conformance tests shell: bash run: | - cd "${{ github.action_path }}" + cd "$GITHUB_ACTION_PATH" npm ci npm run build - name: Run conformance tests shell: bash run: | - CONFORMANCE="${{ github.action_path }}/dist/index.js" + CONFORMANCE="$GITHUB_ACTION_PATH/dist/index.js" # Build the command arguments ARGS="${{ inputs.mode }}" diff --git a/examples/authorization-server-settings.example.json b/examples/authorization-server-settings.example.json new file mode 100644 index 0000000..a511114 --- /dev/null +++ b/examples/authorization-server-settings.example.json @@ -0,0 +1,3 @@ +{ + "url": "https://auth.example.com" +} diff --git a/examples/clients/typescript/auth-test-dpop-bearer.ts b/examples/clients/typescript/auth-test-dpop-bearer.ts new file mode 100644 index 0000000..6ddd4ea --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-bearer.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: builds correct per-request proofs but presents the + * DPoP-bound token with the `Bearer` scheme instead of `DPoP`. Isolates a + * failure of sep-1932-client-dpop-auth-scheme (RFC 9449 §7.1). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'Bearer', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-bearer '); diff --git a/examples/clients/typescript/auth-test-dpop-no-as-nonce.ts b/examples/clients/typescript/auth-test-dpop-no-as-nonce.ts new file mode 100644 index 0000000..0c395c4 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-as-nonce.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: ignores the authorization server's `use_dpop_nonce` + * challenge at the token endpoint (RFC 9449 §8) — it does not retry the token + * request with the supplied nonce. Isolates a failure of + * sep-1932-client-as-nonce. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: false, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-as-nonce '); diff --git a/examples/clients/typescript/auth-test-dpop-no-nonce.ts b/examples/clients/typescript/auth-test-dpop-no-nonce.ts new file mode 100644 index 0000000..232367f --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-nonce.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Nonce-incapable but otherwise compliant DPoP client (SEP-1932 / RFC 9449): + * presents the DPoP-bound token with the `DPoP` Authorization scheme and a fresh + * proof on every request, but implements NO `use_dpop_nonce` handling. Against + * the nonce-less `auth/dpop` scenario (where neither server issues a challenge) + * it completes the flow successfully — proving that a client which does not + * support nonces still passes when the server does not require one (the common + * case, since server nonces are OPTIONAL in RFC 9449 §8/§9). + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: false, + handleRsNonce: false + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-nonce '); diff --git a/examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts b/examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts new file mode 100644 index 0000000..35f1991 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: obtains the token correctly (handles the AS nonce) but + * ignores the MCP server's `use_dpop_nonce` challenge at the resource + * (RFC 9449 §9) — it does not retry the request with the supplied nonce. + * Isolates a failure of sep-1932-client-rs-nonce. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: false + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-rs-nonce '); diff --git a/examples/clients/typescript/auth-test-dpop-no-token-proof.ts b/examples/clients/typescript/auth-test-dpop-no-token-proof.ts new file mode 100644 index 0000000..ab72c5f --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-no-token-proof.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: never sends a DPoP proof at the token endpoint, so it + * never asks for a sender-constrained token — the AS falls back to an unbound + * Bearer token. Isolates a failure of sep-1932-client-token-request-proof + * (RFC 9449 §5). Because the resulting token is unbound, the resource-side + * binding check (sep-1932-client-fresh-proof) also fails as a consequence. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: false, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-dpop-no-token-proof ' +); diff --git a/examples/clients/typescript/auth-test-dpop-replay.ts b/examples/clients/typescript/auth-test-dpop-replay.ts new file mode 100644 index 0000000..50b7efc --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop-replay.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken DPoP client: uses the DPoP scheme correctly but reuses a single proof + * (same `jti`) across requests instead of a fresh one each time. Isolates a + * failure of sep-1932-client-fresh-proof. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: false, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop-replay '); diff --git a/examples/clients/typescript/auth-test-dpop.ts b/examples/clients/typescript/auth-test-dpop.ts new file mode 100644 index 0000000..feabfa7 --- /dev/null +++ b/examples/clients/typescript/auth-test-dpop.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +import { runDpopClient } from './helpers/dpopClientFlow'; +import { runAsCli } from './helpers/cliRunner'; + +/** + * Well-behaved DPoP client (SEP-1932 / RFC 9449): presents the DPoP-bound token + * with the `DPoP` Authorization scheme and a fresh proof on every MCP request. + */ +export async function runClient(serverUrl: string): Promise { + await runDpopClient(serverUrl, { + scheme: 'DPoP', + freshProofPerRequest: true, + sendTokenRequestProof: true, + handleAsNonce: true, + handleRsNonce: true + }); +} + +runAsCli(runClient, import.meta.url, 'auth-test-dpop '); diff --git a/examples/clients/typescript/auth-test-echo-scope.ts b/examples/clients/typescript/auth-test-echo-scope.ts new file mode 100644 index 0000000..7a197c6 --- /dev/null +++ b/examples/clients/typescript/auth-test-echo-scope.ts @@ -0,0 +1,62 @@ +/** + * Negative client for SEP-2350: on a step-up 403, re-authorizes with ONLY the + * scope from the challenge (does not accumulate previously-granted scopes). + * Expected to trigger sep-2350-scope-union-on-reauth = WARNING. + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + auth, + extractWWWAuthenticateParams +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; + +// handle401 variant that ECHOES the challenge scope verbatim (no SEP-2350 union). +const handle401EchoScope = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL +): Promise => { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: next + }); + if (result === 'REDIRECT') { + const authorizationCode = await provider.getAuthCode(); + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: next + }); + } +}; + +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'auth-test-echo-scope', version: '1.0.0' }, + { capabilities: {} } + ); + const oauthFetch = withOAuthRetry( + 'auth-test-echo-scope', + new URL(serverUrl), + handle401EchoScope + )(fetch); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + await client.connect(transport); + await client.listTools(); + await client.callTool({ name: 'test-tool', arguments: {} }); + await transport.close(); +} + +runAsCli(runClient, import.meta.url, 'auth-test-echo-scope '); diff --git a/examples/clients/typescript/auth-test-inert.ts b/examples/clients/typescript/auth-test-inert.ts new file mode 100644 index 0000000..74e9be3 --- /dev/null +++ b/examples/clients/typescript/auth-test-inert.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { runAsCli } from './helpers/cliRunner'; + +/** + * Broken client that gives up before performing any discovery request. + * + * BUG: it never fetches Protected Resource Metadata, so it never reads — let + * alone validates — the `resource` value the scenario mismatches on purpose. + * + * It exists to pin issue #467. `auth/resource-mismatch` decides its verdict + * from `!authorizationRequestMade` alone, and a client that does nothing at + * all satisfies that verdict, so the check scores SUCCESS for a client that + * cannot possibly have performed the validation under test. + */ +export async function runClient(_serverUrl: string): Promise { + throw new Error( + 'inert client: aborted before any discovery request (no PRM fetch, no authorization request)' + ); +} + +runAsCli(runClient, import.meta.url, 'auth-test-inert '); diff --git a/examples/clients/typescript/auth-test-iss-normalize.ts b/examples/clients/typescript/auth-test-iss-normalize.ts new file mode 100644 index 0000000..09f8b5b --- /dev/null +++ b/examples/clients/typescript/auth-test-iss-normalize.ts @@ -0,0 +1,243 @@ +#!/usr/bin/env node + +/** + * Misbehaving client that normalizes URLs before comparing the iss parameter. + * + * Per RFC 9207 / SEP-2468, iss comparison must be a simple string comparison + * with no URL normalization. This client instead compares + * `new URL(received).href` against `new URL(expected).href`, so a + * normalization-equivalent variant of the issuer (e.g. a trailing slash on an + * empty path) is incorrectly accepted and the client proceeds to the token + * endpoint. It also does not validate the AS metadata issuer against the + * issuer identifier used to construct the well-known URL. + * + * Used as a negative fixture for the auth/iss-normalized scenario. + */ + +import { createHash, randomBytes } from 'crypto'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +interface OAuthTokens { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +function generateCodeVerifier(): string { + return randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function computeS256Challenge(codeVerifier: string): string { + const hash = createHash('sha256').update(codeVerifier).digest(); + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Returns true when the two URLs are equal after URL normalization. This is + * the incorrect comparison this fixture deliberately performs. + */ +function urlsMatchAfterNormalization(a: string, b: string): boolean { + return new URL(a).href === new URL(b).href; +} + +/** + * OAuth flow that incorrectly normalizes URLs before comparing iss. + */ +async function oauthFlowWithIssNormalization( + _serverUrl: string | URL, + resourceMetadataUrl: string | URL, + fetchFn: FetchLike +): Promise { + // 1. Fetch Protected Resource Metadata + const prmResponse = await fetchFn(resourceMetadataUrl); + if (!prmResponse.ok) { + throw new Error(`Failed to fetch PRM: ${prmResponse.status}`); + } + const prm = await prmResponse.json(); + const authServerUrl = prm.authorization_servers?.[0]; + if (!authServerUrl) { + throw new Error('No authorization server in PRM'); + } + + // 2. Fetch Authorization Server Metadata + const asMetadataUrl = new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ); + const asResponse = await fetchFn(asMetadataUrl.toString()); + if (!asResponse.ok) { + throw new Error(`Failed to fetch AS metadata: ${asResponse.status}`); + } + const asMetadata = await asResponse.json(); + + // NOTE: deliberately no RFC 8414 §3.3 metadata-issuer validation here. + + const expectedIssuer: string = asMetadata.issuer; + const issParameterSupported: boolean = + asMetadata.authorization_response_iss_parameter_supported === true; + + // 3. Register client (DCR) + const dcrResponse = await fetchFn(asMetadata.registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'test-auth-client-iss-normalize', + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'native' + }) + }); + if (!dcrResponse.ok) { + throw new Error(`DCR failed: ${dcrResponse.status}`); + } + const clientInfo = await dcrResponse.json(); + + // 4. Build authorization URL with PKCE + const codeVerifier = generateCodeVerifier(); + const codeChallenge = computeS256Challenge(codeVerifier); + + const authUrl = new URL(asMetadata.authorization_endpoint); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('client_id', clientInfo.client_id); + authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback'); + authUrl.searchParams.set('state', 'test-state'); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + // 5. Fetch authorization endpoint (simulates redirect) + const authResponse = await fetchFn(authUrl.toString(), { + redirect: 'manual' + }); + const location = authResponse.headers.get('location'); + if (!location) { + throw new Error('No redirect from authorization endpoint'); + } + const redirectUrl = new URL(location); + const authCode = redirectUrl.searchParams.get('code'); + if (!authCode) { + throw new Error('No auth code in redirect'); + } + + // 6. Validate iss parameter — INCORRECTLY normalizing both sides first + const issInRedirect = redirectUrl.searchParams.get('iss'); + + if (issParameterSupported) { + // Server advertised support: iss must be present, but this client + // accepts any normalization-equivalent value. + if (!issInRedirect) { + throw new Error( + 'Server advertised authorization_response_iss_parameter_supported but iss is absent from redirect' + ); + } + if (!urlsMatchAfterNormalization(issInRedirect, expectedIssuer)) { + throw new Error( + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` + ); + } + } else if ( + issInRedirect && + !urlsMatchAfterNormalization(issInRedirect, expectedIssuer) + ) { + throw new Error( + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` + ); + } + + // 7. Exchange code for token with PKCE code_verifier + const tokenResponse = await fetchFn(asMetadata.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authCode, + redirect_uri: 'http://localhost:3000/callback', + client_id: clientInfo.client_id, + code_verifier: codeVerifier + }).toString() + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`); + } + + return tokenResponse.json(); +} + +/** + * Creates a fetch wrapper that uses OAuth with normalized iss comparison. + */ +function withOAuthIssNormalization(baseUrl: string | URL): Middleware { + let tokens: OAuthTokens | undefined; + + return (next: FetchLike) => { + return async ( + input: string | URL, + init?: RequestInit + ): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + return next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + if (response.status === 401) { + const { resourceMetadataUrl } = extractWWWAuthenticateParams(response); + if (!resourceMetadataUrl) { + throw new Error('No resource_metadata in WWW-Authenticate'); + } + tokens = await oauthFlowWithIssNormalization( + baseUrl, + resourceMetadataUrl, + next + ); + response = await makeRequest(); + } + + return response; + }; + }; +} + +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-iss-normalize', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthIssNormalization(new URL(serverUrl))(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-iss-normalize '); diff --git a/examples/clients/typescript/auth-test-iss-validation.ts b/examples/clients/typescript/auth-test-iss-validation.ts new file mode 100644 index 0000000..327cb4c --- /dev/null +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -0,0 +1,246 @@ +#!/usr/bin/env node + +/** + * Well-behaved client that validates the iss parameter in authorization responses. + * + * Per RFC 8414 §3.3: + * - The issuer in the retrieved AS metadata document MUST be identical to the + * issuer identifier used to construct the well-known URL; otherwise the + * metadata MUST NOT be used. + * + * Per RFC 9207: + * - If the AS advertises authorization_response_iss_parameter_supported: true, + * the client MUST require iss in the redirect and MUST validate it against + * the AS metadata issuer. + * - If the AS does NOT advertise support but the redirect contains iss anyway, + * the client MUST still compare it to the recorded issuer and reject on + * mismatch (SEP-2468 validation table row 3). + * - Comparison is simple string comparison — no URL normalization. + */ + +import { createHash, randomBytes } from 'crypto'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +interface OAuthTokens { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; +} + +function generateCodeVerifier(): string { + return randomBytes(32) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function computeS256Challenge(codeVerifier: string): string { + const hash = createHash('sha256').update(codeVerifier).digest(); + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * OAuth flow that correctly validates the iss parameter per RFC 9207. + */ +async function oauthFlowWithIssValidation( + _serverUrl: string | URL, + resourceMetadataUrl: string | URL, + fetchFn: FetchLike +): Promise { + // 1. Fetch Protected Resource Metadata + const prmResponse = await fetchFn(resourceMetadataUrl); + if (!prmResponse.ok) { + throw new Error(`Failed to fetch PRM: ${prmResponse.status}`); + } + const prm = await prmResponse.json(); + const authServerUrl = prm.authorization_servers?.[0]; + if (!authServerUrl) { + throw new Error('No authorization server in PRM'); + } + + // 2. Fetch Authorization Server Metadata + const asMetadataUrl = new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ); + const asResponse = await fetchFn(asMetadataUrl.toString()); + if (!asResponse.ok) { + throw new Error(`Failed to fetch AS metadata: ${asResponse.status}`); + } + const asMetadata = await asResponse.json(); + + // RFC 8414 §3.3: the issuer in the metadata document must be identical + // (simple string comparison) to the issuer identifier used to construct + // the well-known URL. On mismatch the metadata must not be used. + if (asMetadata.issuer !== authServerUrl) { + throw new Error( + `AS metadata issuer mismatch: expected '${authServerUrl}', got '${asMetadata.issuer}'` + ); + } + + const expectedIssuer: string = asMetadata.issuer; + const issParameterSupported: boolean = + asMetadata.authorization_response_iss_parameter_supported === true; + + // 3. Register client (DCR) + const dcrResponse = await fetchFn(asMetadata.registration_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'test-auth-client-iss-validation', + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'native' + }) + }); + if (!dcrResponse.ok) { + throw new Error(`DCR failed: ${dcrResponse.status}`); + } + const clientInfo = await dcrResponse.json(); + + // 4. Build authorization URL with PKCE + const codeVerifier = generateCodeVerifier(); + const codeChallenge = computeS256Challenge(codeVerifier); + + const authUrl = new URL(asMetadata.authorization_endpoint); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('client_id', clientInfo.client_id); + authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback'); + authUrl.searchParams.set('state', 'test-state'); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + // 5. Fetch authorization endpoint (simulates redirect) + const authResponse = await fetchFn(authUrl.toString(), { + redirect: 'manual' + }); + const location = authResponse.headers.get('location'); + if (!location) { + throw new Error('No redirect from authorization endpoint'); + } + const redirectUrl = new URL(location); + const authCode = redirectUrl.searchParams.get('code'); + if (!authCode) { + throw new Error('No auth code in redirect'); + } + + // 6. Validate iss parameter per RFC 9207 + const issInRedirect = redirectUrl.searchParams.get('iss'); + + if (issParameterSupported) { + // Server advertised support: iss MUST be present and MUST match metadata issuer + if (!issInRedirect) { + throw new Error( + 'Server advertised authorization_response_iss_parameter_supported but iss is absent from redirect' + ); + } + if (issInRedirect !== expectedIssuer) { + throw new Error( + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` + ); + } + } else { + // Server did NOT advertise support: if iss is present, compare anyway + // (SEP-2468 spec table row 3 — local-policy provision per RFC 9207 §2.4) + if (issInRedirect && issInRedirect !== expectedIssuer) { + throw new Error( + `iss mismatch: expected '${expectedIssuer}', got '${issInRedirect}'` + ); + } + } + + // 7. Exchange code for token with PKCE code_verifier + const tokenResponse = await fetchFn(asMetadata.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authCode, + redirect_uri: 'http://localhost:3000/callback', + client_id: clientInfo.client_id, + code_verifier: codeVerifier + }).toString() + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`); + } + + return tokenResponse.json(); +} + +/** + * Creates a fetch wrapper that uses OAuth with iss parameter validation. + */ +function withOAuthIssValidation(baseUrl: string | URL): Middleware { + let tokens: OAuthTokens | undefined; + + return (next: FetchLike) => { + return async ( + input: string | URL, + init?: RequestInit + ): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + return next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + if (response.status === 401) { + const { resourceMetadataUrl } = extractWWWAuthenticateParams(response); + if (!resourceMetadataUrl) { + throw new Error('No resource_metadata in WWW-Authenticate'); + } + tokens = await oauthFlowWithIssValidation( + baseUrl, + resourceMetadataUrl, + next + ); + response = await makeRequest(); + } + + return response; + }; + }; +} + +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-iss-validation', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthIssValidation(new URL(serverUrl))(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-iss-validation '); diff --git a/examples/clients/typescript/auth-test-no-application-type.ts b/examples/clients/typescript/auth-test-no-application-type.ts new file mode 100644 index 0000000..e25ccd8 --- /dev/null +++ b/examples/clients/typescript/auth-test-no-application-type.ts @@ -0,0 +1,44 @@ +/** + * Negative client for SEP-837: registers via DCR WITHOUT application_type. + * Expected to trigger sep-837-application-type-present = FAILURE. + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + withOAuthRetryWithProvider, + handle401 +} from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; + +export async function runClient(serverUrl: string): Promise { + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: 'auth-test-no-application-type', + redirect_uris: ['http://localhost:3000/callback'] + // application_type intentionally omitted + } + ); + const oauthFetch = withOAuthRetryWithProvider( + provider, + new URL(serverUrl), + handle401 + )(fetch); + const client = new Client( + { name: 'auth-test-no-application-type', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-no-application-type ' +); diff --git a/examples/clients/typescript/auth-test-resource-slash.ts b/examples/clients/typescript/auth-test-resource-slash.ts new file mode 100644 index 0000000..191cf22 --- /dev/null +++ b/examples/clients/typescript/auth-test-resource-slash.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { withOAuthRetryWithProvider } from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +/** + * Broken client that re-serializes the protected resource metadata's + * `resource` through a URL parser before sending it as the RFC 8707 + * `resource` parameter. + * + * BUG: `new URL('https://example.com').href` is `https://example.com/`, so a + * pathless resource identifier gains a trailing slash and no longer matches + * the value the server published. Authorization servers that compare the + * indicator exactly (Microsoft Entra ID, AADSTS9010010) reject the request. + * This is the shape of typescript-sdk#1968 and python-sdk#2578. + */ +class SlashAppendingResourceProvider extends ConformanceOAuthProvider { + async validateResourceURL( + _serverUrl: string | URL, + resource?: string + ): Promise { + if (!resource) { + return undefined; + } + const url = new URL(resource); + // BUG: always emit the normalized form with a trailing slash, whatever + // the server published. + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + return url; + } +} + +export async function runClient(serverUrl: string): Promise { + const provider = new SlashAppendingResourceProvider( + 'http://localhost:3000/callback', + { + client_name: 'test-auth-client-resource-slash', + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'native' + } + ); + + const client = new Client( + { name: 'test-auth-client-resource-slash', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetryWithProvider( + provider, + new URL(serverUrl) + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('✅ Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('✅ Successfully listed tools'); + + await transport.close(); + logger.debug('✅ Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-resource-slash '); diff --git a/examples/clients/typescript/auth-test-reuse-credentials.ts b/examples/clients/typescript/auth-test-reuse-credentials.ts new file mode 100644 index 0000000..842950e --- /dev/null +++ b/examples/clients/typescript/auth-test-reuse-credentials.ts @@ -0,0 +1,35 @@ +/** + * Negative client for SEP-2352: uses a single provider with no issuer keying, + * so when PRM authorization_servers changes it presents the previous AS's + * client_id at the new AS. Expected to trigger + * sep-2352-no-reuse-on-as-change / sep-2352-reregister-on-as-change FAILURE. + */ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry'; +import { runAsCli } from './helpers/cliRunner'; + +export async function runClient(serverUrl: string): Promise { + const oauthFetch = withOAuthRetry( + 'auth-test-reuse-credentials', + new URL(serverUrl), + handle401 + )(fetch); + const client = new Client( + { name: 'auth-test-reuse-credentials', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + await client.connect(transport); + await client.listTools(); + await client.callTool({ name: 'test-tool', arguments: {} }); + await transport.close(); +} + +runAsCli( + runClient, + import.meta.url, + 'auth-test-reuse-credentials ' +); diff --git a/examples/clients/typescript/auth-test-wif-expired-assertion.ts b/examples/clients/typescript/auth-test-wif-expired-assertion.ts new file mode 100644 index 0000000..7bbfa3c --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-expired-assertion.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: presents a JWT that is already expired. + * BUG: Uses expired_jwt instead of valid_jwt — server rejects with invalid_grant. + */ + +import { runWifJwtBearerExpiredAssertion } from './wif-broken-clients.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerExpiredAssertion, + import.meta.url, + 'auth-test-wif-expired-assertion ' +); diff --git a/examples/clients/typescript/auth-test-wif-grant-fallback.ts b/examples/clients/typescript/auth-test-wif-grant-fallback.ts new file mode 100644 index 0000000..a7fa1b9 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-grant-fallback.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: falls back to authorization_code after receiving unauthorized_client. + * BUG: switches grant type instead of surfacing the error. + */ + +import { runWifJwtBearerGrantFallback } from './wif-broken-clients.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerGrantFallback, + import.meta.url, + 'auth-test-wif-grant-fallback ' +); diff --git a/examples/clients/typescript/auth-test-wif-no-assertion.ts b/examples/clients/typescript/auth-test-wif-no-assertion.ts new file mode 100644 index 0000000..430406d --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-no-assertion.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: omits the assertion parameter from the token request. + * BUG: Does not include assertion in JWT-bearer grant — server rejects with invalid_request. + */ + +import { runWifJwtBearerMissingAssertion } from './wif-broken-clients.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerMissingAssertion, + import.meta.url, + 'auth-test-wif-no-assertion ' +); diff --git a/examples/clients/typescript/auth-test-wif-retry.ts b/examples/clients/typescript/auth-test-wif-retry.ts new file mode 100644 index 0000000..476a448 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-retry.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: retries JWT-bearer after receiving unauthorized_client. + * BUG: retries instead of surfacing the error. + */ + +import { runWifJwtBearerRetry } from './wif-broken-clients.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerRetry, + import.meta.url, + 'auth-test-wif-retry ' +); diff --git a/examples/clients/typescript/auth-test-wif-scope-rejected.ts b/examples/clients/typescript/auth-test-wif-scope-rejected.ts new file mode 100644 index 0000000..9fbe9db --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-scope-rejected.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: requests a scope the AS does not permit for JWT-bearer grant. + * BUG: Includes 'wif.rejected' in the scope parameter — AS returns invalid_scope. + */ + +import { runWifJwtBearerScopeRejected } from './wif-broken-clients.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerScopeRejected, + import.meta.url, + 'auth-test-wif-scope-rejected ' +); diff --git a/examples/clients/typescript/auth-test-wif-wrong-audience.ts b/examples/clients/typescript/auth-test-wif-wrong-audience.ts new file mode 100644 index 0000000..0a1cab8 --- /dev/null +++ b/examples/clients/typescript/auth-test-wif-wrong-audience.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Broken WIF client: presents a JWT with the wrong audience. + * BUG: Uses wrong_audience_jwt instead of valid_jwt — server rejects with invalid_grant. + */ + +import { runWifJwtBearerWrongAudience } from './wif-broken-clients.js'; +import { runAsCli } from './helpers/cliRunner.js'; + +runAsCli( + runWifJwtBearerWrongAudience, + import.meta.url, + 'auth-test-wif-wrong-audience ' +); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 21804a8..b3ccbed 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -19,14 +19,30 @@ import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { JWT_BEARER_GRANT_TYPE } from '../../../src/scenarios/client/auth/helpers/createWorkloadJwt.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; +import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js'; +import { STATELESS_SPEC_VERSIONS } from '../../../src/connection/select.js'; +import { + auth, + extractWWWAuthenticateParams +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import { withOAuthRetry, withOAuthRetryWithProvider, handle401 } from './helpers/withOAuthRetry.js'; import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runClient as issValidationClient } from './auth-test-iss-validation.js'; +import { runClient as dpopClient } from './auth-test-dpop.js'; import { logger } from './helpers/logger.js'; /** @@ -64,10 +80,135 @@ export function getHandler(scenarioName: string): ScenarioHandler | undefined { } // ============================================================================ -// Basic scenarios (initialize, tools-call) +// Stateless requester (SEP-2575 / 2026-x lifecycle) +// +// Shim for the fact that the SDK Client doesn't support stateless mode yet. +// Carry-forward handlers below pick this when the runner says the resolved +// spec version is stateless, so the same handler exercises both lifecycles. +// ============================================================================ + +const PROTOCOL_VERSION = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION; + +// Lifecycle decision: derived from the runner-provided protocol version. +// The version→lifecycle mapping is spec knowledge a client must own; this +// in-repo client imports the stateless version set from src/ so it cannot +// drift from the runner's mapping. +const USE_STATELESS_LIFECYCLE = PROTOCOL_VERSION + ? (STATELESS_SPEC_VERSIONS as readonly string[]).includes(PROTOCOL_VERSION) + : false; + +// Wire protocolVersion for stateless requests: the runner-resolved version +// when available (so a dated stateless release is exercised under its own +// identifier), the current draft otherwise. +const STATELESS_PROTOCOL_VERSION = PROTOCOL_VERSION ?? DRAFT_PROTOCOL_VERSION; + +const STATELESS_META_BASE = { + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + tools: {}, + roots: {}, + sampling: {}, + elicitation: {} + } +}; + +let _nextStatelessId = 1; +async function statelessRequest( + serverUrl: string, + method: string, + params: Record = {} +): Promise { + const _meta = { + 'io.modelcontextprotocol/protocolVersion': STATELESS_PROTOCOL_VERSION, + ...STATELESS_META_BASE, + ...((params._meta as object | undefined) ?? {}) + }; + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Servers built on the SDK's StreamableHTTPServerTransport reject + // requests that don't accept both JSON and SSE responses. + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': STATELESS_PROTOCOL_VERSION + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: _nextStatelessId++, + method, + params: { ...params, _meta } + }) + }); + const body = await response.json(); + if (body.error) { + throw new Error( + `${method} failed: ${body.error.code} ${body.error.message}` + ); + } + return body.result; +} + +// ============================================================================ +// Basic scenarios (initialize, tools_call) // ============================================================================ async function runBasicClient(serverUrl: string): Promise { + if (USE_STATELESS_LIFECYCLE) { + logger.debug('Stateless lifecycle: calling tools/list + tools/call'); + const list = await statelessRequest(serverUrl, 'tools/list'); + logger.debug('Successfully listed tools:', JSON.stringify(list)); + const tool = list?.tools?.[0]; + if (tool) { + const result = await statelessRequest(serverUrl, 'tools/call', { + name: tool.name, + arguments: { a: 2, b: 3 } + }); + logger.debug('Successfully called tool:', JSON.stringify(result)); + } + return; + } + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const list = await client.listTools(); + logger.debug('Successfully listed tools'); + + const tool = list.tools[0]; + if (tool) { + await client.callTool({ name: tool.name, arguments: { a: 2, b: 3 } }); + logger.debug('Successfully called tool'); + } + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenarios(['initialize', 'tools_call', 'tools-call'], runBasicClient); + +// SEP-2106: json-schema-ref-no-deref advertises a tool whose inputSchema +// contains a network-URI $ref. A conformant client lists tools normally and +// simply never fetches that URI. The scenario's mock only serves tools/list, +// so this handler stops after listing instead of reusing runBasicClient +// (whose tools/call would get -32601 and fail the run). +async function runListToolsOnlyClient(serverUrl: string): Promise { + if (USE_STATELESS_LIFECYCLE) { + logger.debug('Stateless lifecycle: calling tools/list'); + const list = await statelessRequest(serverUrl, 'tools/list'); + logger.debug('Successfully listed tools:', JSON.stringify(list)); + return; + } + const client = new Client( { name: 'test-client', version: '1.0.0' }, { capabilities: {} } @@ -85,7 +226,178 @@ async function runBasicClient(serverUrl: string): Promise { logger.debug('Connection closed successfully'); } -registerScenarios(['initialize', 'tools-call'], runBasicClient); +registerScenario('json-schema-ref-no-deref', runListToolsOnlyClient); + +// ============================================================================ +// json-schema-2020-12-preservation (SEP-1613, SEP-2106, Issue #101) +// +// Scenario contract: +// 1. tools/list — observe `json_schema_2020_12_tool` and its inputSchema +// 2. tools/call json_schema_echo with `{ schema: }` +// The scenario diffs the echoed schema against its fixture to detect +// client-side keyword stripping; this handler just round-trips the schema +// verbatim, which is the compliant behavior. +// ============================================================================ + +const FOCAL_TOOL_NAME = 'json_schema_2020_12_tool'; +const ECHO_TOOL_NAME = 'json_schema_echo'; + +async function runJsonSchema2020_12PreservationClient( + serverUrl: string +): Promise { + if (USE_STATELESS_LIFECYCLE) { + logger.debug( + 'Stateless lifecycle: listing tools and echoing observed schema' + ); + const list = await statelessRequest(serverUrl, 'tools/list'); + const focal = list?.tools?.find( + (t: { name: string }) => t.name === FOCAL_TOOL_NAME + ); + if (!focal) { + throw new Error(`Focal tool '${FOCAL_TOOL_NAME}' not advertised`); + } + await statelessRequest(serverUrl, 'tools/call', { + name: ECHO_TOOL_NAME, + arguments: { schema: focal.inputSchema } + }); + logger.debug('Successfully echoed observed inputSchema'); + return; + } + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + try { + const list = await client.listTools(); + const focal = list.tools.find((t) => t.name === FOCAL_TOOL_NAME); + if (!focal) { + throw new Error(`Focal tool '${FOCAL_TOOL_NAME}' not advertised`); + } + await client.callTool({ + name: ECHO_TOOL_NAME, + arguments: { schema: focal.inputSchema as Record } + }); + logger.debug('Successfully echoed observed inputSchema'); + } finally { + await transport.close(); + } +} + +registerScenario( + 'json-schema-2020-12-preservation', + runJsonSchema2020_12PreservationClient +); + +// ============================================================================ +// request-metadata scenario (SEP-2575) +// ============================================================================ + +async function runRequestMetadataClient(serverUrl: string): Promise { + logger.debug('Starting request-metadata client flow...'); + + const meta = STATELESS_META_BASE; + + let activeVersion = STATELESS_PROTOCOL_VERSION; + + const sendRequestWithNegotiation = async ( + method: string, + requestId: string | number, + params: any + ): Promise => { + const getPayload = (version: string) => ({ + jsonrpc: '2.0', + id: requestId, + method, + params: { + ...params, + _meta: { + ...params?._meta, + 'io.modelcontextprotocol/protocolVersion': version + } + } + }); + + const send = async (version: string) => { + return fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': version + }, + body: JSON.stringify(getPayload(version)) + }); + }; + + let response = await send(activeVersion); + if (response.status === 400) { + const clone = response.clone(); + try { + const errorResult = await clone.json(); + // UnsupportedProtocolVersionError is -32022 in the draft schema. + if (errorResult.error?.code === -32022) { + logger.debug( + 'Received UnsupportedProtocolVersionError, starting negotiation...' + ); + const serverSupported: string[] = + errorResult.error.data?.supported || []; + const clientSupported = [ + ...new Set([STATELESS_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION]) + ]; + const mutuallySupported = clientSupported.filter((v) => + serverSupported.includes(v) + ); + if (mutuallySupported.length > 0) { + activeVersion = mutuallySupported[0]; + logger.debug( + `Mutually supported version found: ${activeVersion}. Retrying...` + ); + response = await send(activeVersion); + } else { + logger.debug('No mutually supported version found. Aborting.'); + } + } + } catch (err) { + logger.debug('Failed to parse error response as JSON:', err); + } + } + + if (!response.ok) { + throw new Error(`${method} failed: ${response.status}`); + } + return response.json(); + }; + + // Call server/discover (optional for clients, but every POST still needs + // the header + _meta). + logger.debug('Calling server/discover...'); + const discoverResult = await sendRequestWithNegotiation( + 'server/discover', + 'discover-1', + { _meta: meta } + ); + logger.debug( + 'Successfully discovered server capabilities:', + JSON.stringify(discoverResult.result) + ); + + // Call tools/list with required inline _meta tags and header + logger.debug('Calling tools/list with inline _meta...'); + const toolsResult = await sendRequestWithNegotiation('tools/list', 2, { + _meta: meta + }); + logger.debug( + 'Successfully listed tools statelessly:', + JSON.stringify(toolsResult.result) + ); + + logger.debug('request-metadata client flow completed successfully'); +} + +// Register the scenario handler +registerScenario('request-metadata', runRequestMetadataClient); // ============================================================================ // Auth scenarios - well-behaved client @@ -146,11 +458,96 @@ registerScenarios( 'auth/token-endpoint-auth-post', 'auth/token-endpoint-auth-none', // Resource mismatch (client should error when PRM resource doesn't match) - 'auth/resource-mismatch' + 'auth/resource-mismatch', + // SEP-2207: Offline access / refresh token guidance (draft) + 'auth/offline-access-scope', + 'auth/offline-access-not-supported', + // SEP-2468: ISS parameter - positive scenarios (standard client is fine) + 'auth/iss-supported', + 'auth/iss-not-advertised' ], runAuthClient ); +// SEP-2352: a well-behaved client keys credentials by issuer. Before each +// (re-)authorization, fetch PRM and rebind the provider; bindIssuer clears +// stale credentials when authorization_servers has changed so the SDK +// re-registers instead of presenting the previous AS's client_id. +async function runAuthMigrationClient(serverUrl: string): Promise { + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: 'auth-migration-client', + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'native' + } + ); + + const issuerAware401: typeof handle401 = async ( + response, + p, + next, + sUrl + ): Promise => { + const { resourceMetadataUrl, scope } = + extractWWWAuthenticateParams(response); + if (resourceMetadataUrl) { + const prm = await (await next(resourceMetadataUrl)).json(); + const issuer = Array.isArray(prm?.authorization_servers) + ? prm.authorization_servers[0] + : undefined; + if (issuer) p.bindIssuer(issuer); + } + let result = await auth(p, { + serverUrl: sUrl, + resourceMetadataUrl, + scope, + fetchFn: next as FetchLike + }); + if (result === 'REDIRECT') { + const code = await p.getAuthCode(); + result = await auth(p, { + serverUrl: sUrl, + resourceMetadataUrl, + scope, + authorizationCode: code, + fetchFn: next as FetchLike + }); + } + }; + + const oauthFetch = withOAuthRetryWithProvider( + provider, + new URL(serverUrl), + issuerAware401 + )(fetch); + const client = new Client( + { name: 'auth-migration-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + await client.connect(transport); + await client.listTools(); // phase 1: AS₁ + await client.callTool({ name: 'test-tool', arguments: {} }); // phase 2: AS₂ + await transport.close(); +} + +registerScenario('auth/authorization-server-migration', runAuthMigrationClient); + +// SEP-2468: ISS parameter - rejection scenarios use iss-validating client +registerScenarios( + [ + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected', + 'auth/iss-normalized', + 'auth/metadata-issuer-mismatch' + ], + issValidationClient +); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ @@ -344,6 +741,10 @@ export async function runPreRegistration(serverUrl: string): Promise { redirect_uris: ['http://localhost:3000/callback'] }); + // Associate the pre-registered credentials with the AS that issued them, + // keyed by its issuer (Authorization Server Binding). + provider.bindIssuer(ctx.issuer); + // Use the provider-based middleware const oauthFetch = withOAuthRetryWithProvider( provider, @@ -372,20 +773,20 @@ registerScenario('auth/pre-registration', runPreRegistration); // ============================================================================ /** - * Cross-app access: Complete Flow (SEP-990) + * Enterprise-Managed Authorization (SEP-990) * Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access. */ -export async function runCrossAppAccessCompleteFlow( +export async function runEnterpriseManagedAuthorization( serverUrl: string ): Promise { const ctx = parseContext(); - if (ctx.name !== 'auth/cross-app-access-complete-flow') { + if (ctx.name !== 'auth/enterprise-managed-authorization') { throw new Error( - `Expected cross-app-access-complete-flow context, got ${ctx.name}` + `Expected enterprise-managed-authorization context, got ${ctx.name}` ); } - logger.debug('Starting complete cross-app access flow...'); + logger.debug('Starting enterprise-managed authorization flow...'); logger.debug('IDP Issuer:', ctx.idp_issuer); logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint); @@ -491,7 +892,7 @@ export async function runCrossAppAccessCompleteFlow( // Step 3: Use access token to access MCP server logger.debug('Step 3: Accessing MCP server with access token...'); const client = new Client( - { name: 'conformance-cross-app-access', version: '1.0.0' }, + { name: 'conformance-enterprise-managed-authorization', version: '1.0.0' }, { capabilities: {} } ); @@ -513,14 +914,265 @@ export async function runCrossAppAccessCompleteFlow( logger.debug('Successfully called tool'); await transport.close(); - logger.debug('Complete cross-app access flow completed successfully'); + logger.debug('Enterprise-managed authorization flow completed successfully'); } registerScenario( - 'auth/cross-app-access-complete-flow', - runCrossAppAccessCompleteFlow + 'auth/enterprise-managed-authorization', + runEnterpriseManagedAuthorization ); +// ============================================================================ +// DPoP client conformance (SEP-1932) +// ============================================================================ + +registerScenario('auth/dpop', dpopClient); +registerScenario('auth/dpop-nonce', dpopClient); + +// ============================================================================ +// MRTR client conformance (SEP-2322) +// ============================================================================ + +async function runMRTRClient(serverUrl: string): Promise { + let nextId = 1; + + async function sendRpc( + method: string, + params?: Record + ): Promise<{ + id: number; + result?: Record; + error?: { code: number; message: string }; + }> { + const id = nextId++; + const body: Record = { + jsonrpc: '2.0', + id, + method + }; + if (params) body.params = params; + + const resp = await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (resp.status === 204) return { id, result: {} }; + return (await resp.json()) as { + id: number; + result?: Record; + error?: { code: number; message: string }; + }; + } + + // List tools + const toolsResp = await sendRpc('tools/list'); + const tools = + (toolsResp.result as { tools: Array<{ name: string }> })?.tools ?? []; + logger.debug( + 'Available tools:', + tools.map((t) => t.name) + ); + + // Tool 1: test_mrtr_echo_state — call, get InputRequiredResult with requestState, retry + const r1 = await sendRpc('tools/call', { + name: 'test_mrtr_echo_state', + arguments: {} + }); + + const r1Result = r1.result as Record | undefined; + if (r1Result?.resultType === 'input_required') { + const inputRequests = r1Result.inputRequests as Record; + const requestState = r1Result.requestState as string | undefined; + + // Build inputResponses by fulfilling each inputRequest + const inputResponses: Record = {}; + for (const [key, req] of Object.entries(inputRequests)) { + const request = req as { method: string; params: unknown }; + if (request.method === 'elicitation/create') { + inputResponses[key] = { + action: 'accept', + content: { confirmed: true } + }; + } + } + + // Call an unrelated tool BEFORE retrying — must NOT carry over inputResponses/requestState + await sendRpc('tools/call', { + name: 'test_mrtr_unrelated', + arguments: {} + }); + logger.debug( + 'test_mrtr_unrelated: called without MRTR state (isolation check)' + ); + + // Retry with inputResponses + requestState echoed back unchanged + const retryParams: Record = { + name: 'test_mrtr_echo_state', + arguments: {}, + inputResponses + }; + if (requestState !== undefined) { + retryParams.requestState = requestState; + } + + await sendRpc('tools/call', retryParams); + logger.debug('test_mrtr_echo_state: MRTR flow completed'); + } + + // Tool 2: test_mrtr_no_state — call, get InputRequiredResult WITHOUT requestState, retry without it + const r2 = await sendRpc('tools/call', { + name: 'test_mrtr_no_state', + arguments: {} + }); + + const r2Result = r2.result as Record | undefined; + if (r2Result?.resultType === 'input_required') { + const inputRequests = r2Result.inputRequests as Record; + + // Build inputResponses + const inputResponses: Record = {}; + for (const [key, req] of Object.entries(inputRequests)) { + const request = req as { method: string; params: unknown }; + if (request.method === 'elicitation/create') { + inputResponses[key] = { + action: 'accept', + content: { confirmed: true } + }; + } + } + + // Retry WITHOUT requestState (server didn't send one) + await sendRpc('tools/call', { + name: 'test_mrtr_no_state', + arguments: {}, + inputResponses + }); + logger.debug('test_mrtr_no_state: MRTR flow completed'); + } + + // Tool 3: test_mrtr_no_result_type — returns result without resultType field + // Client must treat it as complete (default) and NOT retry + const r3 = await sendRpc('tools/call', { + name: 'test_mrtr_no_result_type', + arguments: {} + }); + + const r3Result = r3.result as Record | undefined; + if (r3Result && !r3Result.resultType) { + // No resultType means default to "complete" — do nothing, don't retry + logger.debug( + 'test_mrtr_no_result_type: result has no resultType, treating as complete' + ); + } + + logger.debug('MRTR client scenario completed'); +} + +registerScenario('sep-2322-client-request-state', runMRTRClient); + +// ============================================================================ +// WIF JWT-bearer scenario +// ============================================================================ + +class WifJwtBearerProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private readonly _clientMetadata: OAuthClientMetadata; + private hasAttempted = false; + + // Pass null for assertion to deliberately omit it (missing-assertion negative tests). + constructor( + private readonly assertion: string | null, + clientId: string, + private readonly scope?: string + ) { + this._clientInfo = { client_id: clientId }; + this._clientMetadata = { + client_name: 'conformance-wif-jwt-bearer', + redirect_uris: [], + grant_types: [JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method: 'none' + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for JWT-bearer flow'); + } + + saveCodeVerifier(): void {} + + codeVerifier(): string { + throw new Error('codeVerifier is not used for JWT-bearer flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + if (this.hasAttempted) { + throw new Error('JWT-bearer grant must not be retried after failure'); + } + this.hasAttempted = true; + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + if (this.assertion !== null) params.set('assertion', this.assertion); + const effectiveScope = this.scope ?? scope; + if (effectiveScope) params.set('scope', effectiveScope); + return params; + } +} + +export async function runWifJwtBearer(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + + const provider = new WifJwtBearerProvider(ctx.valid_jwt, ctx.client_id); + + const client = new Client( + { name: 'conformance-wif-jwt-bearer', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with JWT-bearer assertion'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/wif-jwt-bearer', runWifJwtBearer); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index aa3aef5..5dffd3d 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -6,16 +6,28 @@ import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +/** + * SEP-837 adds `application_type` to DCR; the SDK's OAuthClientMetadataSchema + * doesn't include it yet. The SDK spreads clientMetadata verbatim into the + * /register POST body, so widening the type here is sufficient to get the + * field on the wire. Drop this once the SDK schema is updated. + */ +type ConformanceClientMetadata = OAuthClientMetadata & { + application_type?: 'native' | 'web'; +}; + export class ConformanceOAuthProvider implements OAuthClientProvider { private _clientInformation?: OAuthClientInformationFull; private _tokens?: OAuthTokens; private _codeVerifier?: string; private _authCode?: string; private _authCodePromise?: Promise; + /** Issuer the current credentials were obtained from (SEP-2352 keying). */ + private _boundIssuer?: string; constructor( private readonly _redirectUrl: string | URL, - private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadata: ConformanceClientMetadata, private readonly _clientMetadataUrl?: string | URL ) {} @@ -23,7 +35,7 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { return this._redirectUrl; } - get clientMetadata(): OAuthClientMetadata { + get clientMetadata(): ConformanceClientMetadata { return this._clientMetadata; } @@ -92,4 +104,30 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { } return this._codeVerifier; } + + /** SDK calls this on auth errors; also used by bindIssuer below. */ + invalidateCredentials(scope: 'all' | 'tokens'): void { + this._tokens = undefined; + this._authCode = undefined; + if (scope === 'all') { + this._clientInformation = undefined; + this._codeVerifier = undefined; + } + } + + /** + * SEP-2352: associate stored credentials with the AS issuer that issued them. + * If the issuer changes (PRM migrated to a new authorization server), clear + * everything so the SDK re-registers instead of reusing stale credentials. + * Returns true when a change was detected and credentials were cleared. + */ + bindIssuer(issuer: string): boolean { + if (this._boundIssuer !== undefined && this._boundIssuer !== issuer) { + this.invalidateCredentials('all'); + this._boundIssuer = issuer; + return true; + } + this._boundIssuer = issuer; + return false; + } } diff --git a/examples/clients/typescript/helpers/dpopClientFlow.ts b/examples/clients/typescript/helpers/dpopClientFlow.ts new file mode 100644 index 0000000..f4cb81f --- /dev/null +++ b/examples/clients/typescript/helpers/dpopClientFlow.ts @@ -0,0 +1,234 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { request } from 'undici'; +import { createHash, randomBytes } from 'crypto'; +import { + generateDpopKeyPair, + buildDpopProof +} from '../../../../src/scenarios/client/auth/helpers/dpopProof'; +import { logger } from './logger'; + +/** + * Shared DPoP client flow (SEP-1932 / RFC 9449). Acquires a DPoP-bound access + * token via the authorization_code + PKCE grant (with a DPoP proof at the token + * endpoint), then opens an MCP session presenting the token to the resource. + * + * The OAuth flow is hand-rolled rather than reusing ConformanceOAuthProvider / + * withOAuthRetry because the SDK's OAuth provider offers no hook to attach a + * DPoP proof to the token-endpoint request, which is required to obtain a bound + * token. The MCP session itself does use the SDK Client (via a fetch wrapper). + * + * Parameterized so the compliant client and the deliberately-broken variants + * differ by exactly one behaviour: + * - `scheme: 'Bearer'` → fails sep-1932-client-dpop-auth-scheme + * - `freshProofPerRequest:false` → reuses one proof, fails sep-1932-client-fresh-proof + * - `sendTokenRequestProof:false`→ omits the token-endpoint proof, so the AS + * issues an unbound Bearer token; fails sep-1932-client-token-request-proof + * (and, downstream, the resource binding check, since the token isn't bound) + * - `handleAsNonce:false` → ignores the token endpoint's `use_dpop_nonce` + * challenge (RFC 9449 §8); fails sep-1932-client-as-nonce + * - `handleRsNonce:false` → ignores the MCP server's `use_dpop_nonce` + * challenge (RFC 9449 §9); fails sep-1932-client-rs-nonce + */ +export interface DpopClientOptions { + scheme: 'DPoP' | 'Bearer'; + freshProofPerRequest: boolean; + sendTokenRequestProof: boolean; + /** Retry the token request with the AS-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §8). */ + handleAsNonce: boolean; + /** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */ + handleRsNonce: boolean; +} + +const REDIRECT_URI = 'http://127.0.0.1:9876/callback'; + +export async function runDpopClient( + serverUrl: string, + options: DpopClientOptions +): Promise { + const keyPair = await generateDpopKeyPair(); + + // 1. Discover the authorization server via Protected Resource Metadata. + const prmUrl = new URL( + '/.well-known/oauth-protected-resource/mcp', + serverUrl + ); + const prm = await (await fetch(prmUrl.toString())).json(); + const authServerUrl: string = prm.authorization_servers[0]; + + // 2. Authorization server metadata. + const asMeta = await ( + await fetch( + new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ).toString() + ) + ).json(); + const authorizationEndpoint: string = asMeta.authorization_endpoint; + const tokenEndpoint: string = asMeta.token_endpoint; + const registrationEndpoint: string = asMeta.registration_endpoint; + + // 3. Dynamic client registration. + const reg = await ( + await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'conformance-dpop-client', + redirect_uris: [REDIRECT_URI], + application_type: 'native' + }) + }) + ).json(); + const clientId: string = reg.client_id; + + // 4. Authorization request (PKCE). The test AS redirects straight to the + // redirect_uri, so read the code from the Location header (no callback needed). + const state = randomBytes(16).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + const authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({ + response_type: 'code', + client_id: clientId, + state, + redirect_uri: REDIRECT_URI, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }).toString()}`; + const authorizeResponse = await request(authorizeUrl, { method: 'GET' }); + await authorizeResponse.body.text().catch(() => undefined); + const location = authorizeResponse.headers['location']; + const locationStr = Array.isArray(location) ? location[0] : location; + if (!locationStr) { + throw new Error('Authorization endpoint did not redirect with a code'); + } + const code = new URL(locationStr).searchParams.get('code'); + if (!code) throw new Error('No authorization code in redirect'); + + // 5. Token request with a DPoP proof → DPoP-bound access token. The broken + // `sendTokenRequestProof:false` variant omits the proof, so the AS issues an + // unbound Bearer token instead. + const tokenReqBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + client_id: clientId + }).toString(); + const requestToken = async (nonce?: string): Promise => { + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + if (options.sendTokenRequestProof) { + headers.dpop = await buildDpopProof({ + keyPair, + htm: 'POST', + // RFC 9449 §4.2: htu carries no query/fragment (the token endpoint URL + // may legally have a query, so strip it here). + htu: stripQuery(tokenEndpoint), + ...(nonce ? { nonce } : {}) + }); + } + return fetch(tokenEndpoint, { + method: 'POST', + headers, + body: tokenReqBody + }); + }; + let tokenResponse = await requestToken(); + // RFC 9449 §8: the AS may answer with `use_dpop_nonce` (HTTP 400 + DPoP-Nonce); + // a conformant client retries the token request with the supplied nonce. Match + // on the `use_dpop_nonce` error code (not merely any 400 carrying a nonce), so + // an unrelated error (e.g. invalid_grant) that an AS proactively decorates with + // a DPoP-Nonce header does not burn the retry and mask the real failure — + // consistent with the resource-side check below. + const asNonce = tokenResponse.headers.get('DPoP-Nonce'); + if (tokenResponse.status === 400 && asNonce && options.handleAsNonce) { + const challenge = await tokenResponse + .clone() + .json() + .catch(() => ({}) as { error?: string }); + if (challenge?.error === 'use_dpop_nonce') { + tokenResponse = await requestToken(asNonce); + } + } + if (!tokenResponse.ok) { + throw new Error(`Token request failed: HTTP ${tokenResponse.status}`); + } + const tokenBody = await tokenResponse.json(); + const accessToken: string = tokenBody.access_token; + logger.debug(`Obtained ${tokenBody.token_type} access token`); + + // 6. MCP session — present the token to the resource with a per-request proof. + // On a `use_dpop_nonce` challenge (RFC 9449 §9) a conformant client retries + // with the server-supplied nonce embedded in the proof, and carries it on + // subsequent requests. + const mcpUrl = `${serverUrl}`; + let reusableProof: string | undefined; + let rsNonce: string | undefined; + const dpopFetch = async ( + input: string | URL, + init?: RequestInit + ): Promise => { + const method = (init?.method ?? 'POST').toUpperCase(); + const htu = stripQuery( + typeof input === 'string' ? input : input.toString() + ); + const attempt = async (): Promise => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `${options.scheme} ${accessToken}`); + let proof: string; + if (options.freshProofPerRequest || !reusableProof) { + proof = await buildDpopProof({ + keyPair, + htm: method, + htu, + accessToken, + ...(rsNonce ? { nonce: rsNonce } : {}) + }); + if (!options.freshProofPerRequest) reusableProof = proof; + } else { + proof = reusableProof; + } + headers.set('DPoP', proof); + return fetch(input, { ...init, headers }); + }; + let res = await attempt(); + const nonce = res.headers.get('DPoP-Nonce'); + if ( + res.status === 401 && + nonce && + options.handleRsNonce && + (res.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce') + ) { + rsNonce = nonce; + reusableProof = undefined; // rebuild the proof carrying the nonce + res = await attempt(); + } + return res; + }; + + const client = new Client( + { name: 'conformance-dpop-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), { + fetch: dpopFetch as typeof fetch + }); + + await client.connect(transport); + logger.debug('Connected to MCP server'); + await client.listTools(); + logger.debug('Listed tools'); + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Called tool'); + await transport.close(); +} + +function stripQuery(url: string): string { + const u = new URL(url); + return `${u.origin}${u.pathname}`; +} diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index 429ca53..92767e9 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -7,13 +7,33 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js'; import { ConformanceOAuthProvider } from './ConformanceOAuthProvider'; +/** + * SEP-2350: a well-behaved client computes the union of previously-granted + * scopes and the newly challenged scopes when re-authorizing, so it doesn't + * lose permissions needed for other operations. + */ +const unionScopes = ( + prior: string | undefined, + challenged: string | undefined +): string | undefined => { + const set = new Set( + [...(prior?.split(' ') ?? []), ...(challenged?.split(' ') ?? [])].filter( + Boolean + ) + ); + return set.size > 0 ? [...set].join(' ') : undefined; +}; + export const handle401 = async ( response: Response, provider: ConformanceOAuthProvider, next: FetchLike, serverUrl: string | URL ): Promise => { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + const { resourceMetadataUrl, scope: challengedScope } = + extractWWWAuthenticateParams(response); + const prior = (await provider.tokens())?.scope; + const scope = unionScopes(prior, challengedScope); let result = await auth(provider, { serverUrl, resourceMetadataUrl, @@ -70,7 +90,10 @@ export const withOAuthRetry = ( 'http://localhost:3000/callback', { client_name: clientName, - redirect_uris: ['http://localhost:3000/callback'] + redirect_uris: ['http://localhost:3000/callback'], + // SEP-837: the conformance example clients are CLI tools, i.e. native + // applications under the OIDC client-type taxonomy. + application_type: 'native' }, clientMetadataUrl ); diff --git a/examples/clients/typescript/wif-broken-clients.ts b/examples/clients/typescript/wif-broken-clients.ts new file mode 100644 index 0000000..2416d1b --- /dev/null +++ b/examples/clients/typescript/wif-broken-clients.ts @@ -0,0 +1,303 @@ +#!/usr/bin/env node + +/** + * Broken WIF client implementations for negative conformance tests. + * + * Each class/runner exercises a specific non-compliant behaviour. + * WifJwtBearerProvider (the well-behaved client) lives in everything-client.ts. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { + JWT_BEARER_GRANT_TYPE, + WIF_TRIGGER_UNAUTHORIZED_SCOPE, + WIF_REJECTED_SCOPE +} from '../../../src/scenarios/client/auth/helpers/createWorkloadJwt.js'; +import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; + +function parseWifContext() { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) throw new Error('MCP_CONFORMANCE_CONTEXT not set'); + const ctx = ClientConformanceContextSchema.parse(JSON.parse(raw)); + if (ctx.name !== 'auth/wif-jwt-bearer') { + throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`); + } + return ctx; +} + +// Base class with OAuthClientProvider boilerplate shared across all broken variants. +abstract class WifProviderBase implements OAuthClientProvider { + private _tokens?: OAuthTokens; + protected _clientInfo: OAuthClientInformation; + protected readonly _clientMetadata: OAuthClientMetadata; + + constructor(clientId: string, clientName: string) { + this._clientInfo = { client_id: clientId }; + this._clientMetadata = { + client_name: clientName, + redirect_uris: [], + grant_types: [JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method: 'none' + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for JWT-bearer flow'); + } + + saveCodeVerifier(): void {} + + codeVerifier(): string { + throw new Error('codeVerifier is not used for JWT-bearer flow'); + } + + abstract prepareTokenRequest(_scope?: string): URLSearchParams; +} + +async function runWifBrokenClient( + serverUrl: string, + provider: WifProviderBase, + clientName: string +): Promise { + const client = new Client( + { name: clientName, version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +// --------------------------------------------------------------------------- +// Wrong audience +// --------------------------------------------------------------------------- + +// BUG: presents a JWT whose aud does not match the AS +class WifWrongAudienceProvider extends WifProviderBase { + constructor( + private readonly assertion: string, + clientId: string + ) { + super(clientId, 'conformance-wif-wrong-audience'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + return params; + } +} + +export async function runWifJwtBearerWrongAudience( + serverUrl: string +): Promise { + const ctx = parseWifContext(); + await runWifBrokenClient( + serverUrl, + new WifWrongAudienceProvider(ctx.wrong_audience_jwt, ctx.client_id), + 'conformance-wif-wrong-audience' + ); +} + +// --------------------------------------------------------------------------- +// Missing assertion +// --------------------------------------------------------------------------- + +// BUG: omits the assertion parameter entirely +class WifMissingAssertionProvider extends WifProviderBase { + constructor(clientId: string) { + super(clientId, 'conformance-wif-no-assertion'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + return new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + } +} + +export async function runWifJwtBearerMissingAssertion( + serverUrl: string +): Promise { + const ctx = parseWifContext(); + await runWifBrokenClient( + serverUrl, + new WifMissingAssertionProvider(ctx.client_id), + 'conformance-wif-no-assertion' + ); +} + +// --------------------------------------------------------------------------- +// Expired assertion +// --------------------------------------------------------------------------- + +// BUG: presents a JWT whose exp has already passed +class WifExpiredAssertionProvider extends WifProviderBase { + constructor( + private readonly assertion: string, + clientId: string + ) { + super(clientId, 'conformance-wif-expired-assertion'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + return params; + } +} + +export async function runWifJwtBearerExpiredAssertion( + serverUrl: string +): Promise { + const ctx = parseWifContext(); + await runWifBrokenClient( + serverUrl, + new WifExpiredAssertionProvider(ctx.expired_jwt, ctx.client_id), + 'conformance-wif-expired-assertion' + ); +} + +// --------------------------------------------------------------------------- +// Scope rejected +// --------------------------------------------------------------------------- + +// BUG: requests a scope the AS does not permit for JWT-bearer grant +class WifScopeRejectedProvider extends WifProviderBase { + constructor( + private readonly assertion: string, + clientId: string + ) { + super(clientId, 'conformance-wif-scope-rejected'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + params.set('scope', WIF_REJECTED_SCOPE); + return params; + } +} + +export async function runWifJwtBearerScopeRejected( + serverUrl: string +): Promise { + const ctx = parseWifContext(); + await runWifBrokenClient( + serverUrl, + new WifScopeRejectedProvider(ctx.valid_jwt, ctx.client_id), + 'conformance-wif-scope-rejected' + ); +} + +// --------------------------------------------------------------------------- +// Grant fallback +// --------------------------------------------------------------------------- + +// BUG: falls back to authorization_code after receiving unauthorized_client +class WifGrantFallbackProvider extends WifProviderBase { + private attemptCount = 0; + + constructor( + private readonly assertion: string, + clientId: string + ) { + super(clientId, 'conformance-wif-grant-fallback'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + this.attemptCount++; + if (this.attemptCount === 1) { + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + params.set('scope', WIF_TRIGGER_UNAUTHORIZED_SCOPE); + return params; + } + // BUG: switches to authorization_code instead of surfacing the error + return new URLSearchParams({ + grant_type: 'authorization_code', + code: 'fake-fallback-code' + }); + } +} + +export async function runWifJwtBearerGrantFallback( + serverUrl: string +): Promise { + const ctx = parseWifContext(); + await runWifBrokenClient( + serverUrl, + new WifGrantFallbackProvider(ctx.valid_jwt, ctx.client_id), + 'conformance-wif-grant-fallback' + ); +} + +// --------------------------------------------------------------------------- +// Retry +// --------------------------------------------------------------------------- + +// BUG: retries JWT-bearer after receiving unauthorized_client. +// Deliberately omits a hasAttempted guard so the SDK retry reaches the AS +// and the wif-no-retry check fires. +class WifRetryProvider extends WifProviderBase { + private attemptCount = 0; + + constructor( + private readonly assertion: string, + clientId: string + ) { + super(clientId, 'conformance-wif-retry'); + } + + prepareTokenRequest(_scope?: string): URLSearchParams { + this.attemptCount++; + const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE }); + params.set('assertion', this.assertion); + if (this.attemptCount === 1) { + params.set('scope', WIF_TRIGGER_UNAUTHORIZED_SCOPE); + } + // BUG: retries JWT-bearer instead of surfacing the error + return params; + } +} + +export async function runWifJwtBearerRetry(serverUrl: string): Promise { + const ctx = parseWifContext(); + await runWifBrokenClient( + serverUrl, + new WifRetryProvider(ctx.valid_jwt, ctx.client_id), + 'conformance-wif-retry' + ); +} diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 374e48c..c76c1f5 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -21,23 +21,105 @@ import { import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { ElicitResultSchema, + ResultSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, ListToolsRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, type ListToolsResult, type Tool } from '@modelcontextprotocol/sdk/types.js'; -import { zodToJsonSchema } from 'zod-to-json-schema'; import { z } from 'zod'; +import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import cors from 'cors'; -import { randomUUID } from 'crypto'; +import { randomUUID, createHmac } from 'crypto'; // Server state const resourceSubscriptions = new Set(); const watchedResourceContent = 'Watched resource content'; +// HMAC-based requestState for SEP-2322 MRTR integrity tests +const MRTR_STATE_SECRET = 'conformance-mrtr-secret-' + randomUUID(); + +function signMrtState(payload: Record): string { + const data = JSON.stringify(payload); + const hmac = createHmac('sha256', MRTR_STATE_SECRET) + .update(data) + .digest('hex'); + return JSON.stringify({ data, hmac }); +} + +function verifyMrtState(raw: string): Record | null { + try { + const { data, hmac } = JSON.parse(raw) as { data: string; hmac: string }; + const expected = createHmac('sha256', MRTR_STATE_SECRET) + .update(data) + .digest('hex'); + if (hmac !== expected) return null; + return JSON.parse(data) as Record; + } catch { + return null; + } +} + +function getMrtInputText(inputResponse: unknown, field: string): string { + const content = (inputResponse as Record | undefined) + ?.content as Record | undefined; + const value = content?.[field]; + return typeof value === 'string' ? value : 'unknown'; +} + // Session management const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const servers: { [sessionId: string]: McpServer } = {}; +// In-memory client connected to a fully-registered McpServer. Used by the +// stateless POST handler to serve carry-forward methods (tools/call, +// resources/*, prompts/get, completion/complete) without duplicating the +// registrations. The SDK doesn't yet support a stateless server natively, +// so this bridges via the in-memory transport after a one-time initialize. +// +// A fresh server+client pair is built per request so concurrent requests +// can't observe each other's notifications. +type DispatchClient = { + client: Client; + drainNotifications: () => unknown[]; + close: () => Promise; +}; +async function getStatelessDispatchClient(): Promise { + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverT); + const client = new Client( + { name: 'stateless-dispatch', version: '1.0.0' }, + { capabilities: { sampling: {}, elicitation: {} } } + ); + await client.connect(clientT); + + // Buffer notifications so the stateless handler can flush them to the SSE + // response after the request completes. The SDK pre-registers a handler for + // notifications/progress so a fallback alone would miss it. + const buffer: unknown[] = []; + const collect = async (n: unknown) => + void buffer.push({ jsonrpc: '2.0', ...(n as object) }); + client.setNotificationHandler(ProgressNotificationSchema, collect); + client.setNotificationHandler(LoggingMessageNotificationSchema, collect); + client.fallbackNotificationHandler = collect; + + return { + client, + drainNotifications: () => buffer.splice(0, buffer.length), + close: async () => { + await client.close(); + await server.close(); + } + }; +} + // In-memory event store for SEP-1699 resumability const eventStoreData = new Map< string, @@ -89,6 +171,8 @@ const JSON_SCHEMA_2020_12_INPUT_SCHEMA = { type: 'object' as const, $defs: { address: { + // SEP-2106: reference keyword ($anchor) must be preserved + $anchor: 'addressDef', type: 'object', properties: { street: { type: 'string' }, @@ -98,8 +182,25 @@ const JSON_SCHEMA_2020_12_INPUT_SCHEMA = { }, properties: { name: { type: 'string' }, - address: { $ref: '#/$defs/address' } + address: { $ref: '#/$defs/address' }, + contactMethod: { type: 'string', enum: ['phone', 'email'] }, + phone: { type: 'string' }, + email: { type: 'string' } }, + // SEP-2106: the full JSON Schema 2020-12 vocabulary is permitted in + // inputSchema (alongside the required root `type: "object"`). These keywords + // exercise that SDKs preserve them through tools/list rather than stripping + // them down to properties/required. + // + // Composition keywords (allOf / anyOf): + allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }], + // Conditional keywords (if / then / else): + if: { + properties: { contactMethod: { const: 'phone' } }, + required: ['contactMethod'] + }, + then: { required: ['phone'] }, + else: { required: ['email'] }, additionalProperties: false }; @@ -128,6 +229,46 @@ function createMcpServer() { } ); + // SEP-2549: Wrap setRequestHandler so the SDK's own list handlers + // automatically get caching hints appended to their responses. + const originalSetRequestHandler = mcpServer.server.setRequestHandler.bind( + mcpServer.server + ); + const listSchemasForCaching = new Set([ + ListToolsRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema + ]); + mcpServer.server.setRequestHandler = ((schema: any, handler: any) => { + if (listSchemasForCaching.has(schema)) { + return originalSetRequestHandler(schema, async (...args: any[]) => { + const result = await handler(...args); + return { ...result, ttlMs: 300000, cacheScope: 'public' as const }; + }); + } + return originalSetRequestHandler(schema, handler); + }) as typeof mcpServer.server.setRequestHandler; + + const registerResourceWithCacheHints = + mcpServer.registerResource.bind(mcpServer); + mcpServer.registerResource = (( + name: string, + uriOrTemplate: string | ResourceTemplate, + config: any, + readCallback: any + ) => + registerResourceWithCacheHints( + name, + uriOrTemplate as any, + config, + async (...args: any[]) => ({ + ...(await readCallback(...args)), + ttlMs: 300000, + cacheScope: 'private' as const + }) + )) as typeof mcpServer.registerResource; + // Helper to send log messages using the underlying server function sendLog( level: @@ -398,10 +539,10 @@ function createMcpServer() { prompt: z.string().describe('The prompt to send to the LLM') } }, - async (args: { prompt: string }) => { + async (args: { prompt: string }, { sendRequest }) => { try { - // Request sampling from client - const result = await mcpServer.server.request( + // Request sampling from client on the tool call's stream + const result = await sendRequest( { method: 'sampling/createMessage', params: { @@ -458,10 +599,10 @@ function createMcpServer() { message: z.string().describe('The message to show the user') } }, - async (args: { message: string }) => { + async (args: { message: string }, { sendRequest }) => { try { - // Request user input from client - const result = await mcpServer.server.request( + // Request user input from client on the tool call's stream + const result = await sendRequest( { method: 'elicitation/create', params: { @@ -510,10 +651,10 @@ function createMcpServer() { description: 'Tests elicitation with default values per SEP-1034', inputSchema: {} }, - async () => { + async (_args, { sendRequest }) => { try { // Request user input with default values for all primitive types - const result = await mcpServer.server.request( + const result = await sendRequest( { method: 'elicitation/create', params: { @@ -585,10 +726,10 @@ function createMcpServer() { 'Tests elicitation with enum schema improvements per SEP-1330', inputSchema: {} }, - async () => { + async (_args, { sendRequest }) => { try { // Request user input with all 5 enum schema variants - const result = await mcpServer.server.request( + const result = await sendRequest( { method: 'elicitation/create', params: { @@ -1006,31 +1147,14 @@ function createMcpServer() { }; } - // For other tools, convert Zod to JSON Schema - // Handle different inputSchema formats: - // - undefined/null: use empty object schema - // - Zod schema (has _def): convert directly - // - Raw shape (object with Zod values): wrap in z.object first - let inputSchema: Tool['inputSchema']; - if (!tool.inputSchema) { - inputSchema = { type: 'object' as const, properties: {} }; - } else if ('_def' in tool.inputSchema) { - // Already a Zod schema - inputSchema = zodToJsonSchema(tool.inputSchema, { - strictUnions: true - }) as Tool['inputSchema']; - } else if ( - typeof tool.inputSchema === 'object' && - Object.keys(tool.inputSchema).length > 0 - ) { - // Raw shape with Zod values - inputSchema = zodToJsonSchema(z.object(tool.inputSchema), { - strictUnions: true - }) as Tool['inputSchema']; - } else { - // Empty object or unknown format - inputSchema = { type: 'object' as const, properties: {} }; - } + // For other tools, use the SDK's own JSON Schema conversion + // which handles zod v3/v4/v4-mini compatibility + const inputSchema: Tool['inputSchema'] = tool.inputSchema + ? (toJsonSchemaCompat(tool.inputSchema, { + strictUnions: true, + pipeStrategy: 'input' + }) as Tool['inputSchema']) + : { type: 'object' as const, properties: {} }; return { name, @@ -1041,6 +1165,8 @@ function createMcpServer() { _meta: tool._meta }; }) + // Note: SEP-2549 caching hints are added automatically by the + // setRequestHandler wrapper above }; } ); @@ -1058,6 +1184,38 @@ function isInitializeRequest(body: any): boolean { // Use createMcpExpressApp for DNS rebinding protection on localhost const app = createMcpExpressApp(); +// Open subscriptions/listen streams (SEP-2575). Notifications are delivered +// to whichever streams are open *at the time of the change* and whose filter +// requested that notification type. +interface ListenStream { + res: import('express').Response; + subscriptionId: string; + wantsTools: boolean; + wantsPrompts: boolean; +} +const activeListenStreams: ListenStream[] = []; + +function notifyListenStreams( + type: 'tools' | 'prompts', + notificationMethod: string +) { + for (const stream of activeListenStreams) { + const wants = type === 'tools' ? stream.wantsTools : stream.wantsPrompts; + if (!wants) continue; + stream.res.write( + JSON.stringify({ + jsonrpc: '2.0', + method: notificationMethod, + params: { + _meta: { + 'io.modelcontextprotocol/subscriptionId': stream.subscriptionId + } + } + }) + '\n' + ); + } +} + // Configure CORS to expose Mcp-Session-Id header for browser-based clients app.use( cors({ @@ -1067,9 +1225,1120 @@ app.use( }) ); +// Protocol revisions that use the initialize/session lifecycle. The +// per-request `_meta` and header/body validation requirements apply to +// 2026-07-28 and later, not to traffic from these revisions. +const LEGACY_SESSION_PROTOCOL_VERSIONS = [ + '2024-11-05', + '2025-03-26', + '2025-06-18', + '2025-11-25' +]; + +// Stateless (draft) operations whose results MUST carry the SEP-2549 caching +// hints (`ttlMs`, `cacheScope`). +const STATELESS_CACHEABLE_METHODS: ReadonlySet = new Set([ + 'server/discover', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read' +]); + +/** Normalize a stateless (draft) JSON-RPC response. Draft results MUST carry `resultType` + * and cacheable operations the SEP-2549 caching hints; stamp any the dispatch site did + * not set so every stateless result is draft-schema-valid. Errors pass through untouched. */ +function normalizeStatelessResponse( + method: string, + payload: { result?: Record; [key: string]: unknown } +): { result?: Record; [key: string]: unknown } { + const result = payload.result; + if (result && typeof result === 'object' && !Array.isArray(result)) { + result.resultType ??= 'complete'; + if (STATELESS_CACHEABLE_METHODS.has(method)) { + result.ttlMs ??= 0; + result.cacheScope ??= 'private'; + } + } + return payload; +} + +/** Send a normalized stateless response as JSON. */ +function sendStatelessJson( + res: import('express').Response, + method: string, + payload: { result?: Record; [key: string]: unknown } +): import('express').Response { + return res.json(normalizeStatelessResponse(method, payload)); +} + // Handle POST requests - stateful mode app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; + const reqVersion = req.headers['mcp-protocol-version'] as string | undefined; + const body = req.body || {}; + const method = body.method; + const id = body.id ?? null; + const params = body.params || {}; + const meta = params._meta; + const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; + + // A request that carries no `_meta` and names a legacy session-era revision + // in the header is legacy traffic; it is served by the session path below + // instead of being rejected for missing per-request metadata. + const isLegacySessionEraRequest = + meta === undefined && + reqVersion !== undefined && + LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); + + if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) { + // Missing Transport Header Validation Check + if (!reqVersion) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { code: -32020, message: 'Missing MCP-Protocol-Version header' } + }); + } + + // Per-Request Metadata Integrity Checks (Fields verification). + // A request missing any required `_meta` field is malformed: -32602 and, + // on HTTP, status 400 Bad Request. `clientInfo` is a SHOULD since spec + // PR #3002 and is never required. + if ( + !meta || + !meta['io.modelcontextprotocol/protocolVersion'] || + !meta['io.modelcontextprotocol/clientCapabilities'] + ) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'Invalid params: missing _meta or required fields' + } + }); + } + + // Header Mismatch Verification (-32020, HTTP 400) + if (reqVersion !== metaVersion) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32020, + message: 'Mismatched MCP-Protocol-Version header' + } + }); + } + + // Protocol Version Negotiation Matrix (-32022, HTTP 400) + if (metaVersion !== '2026-07-28') { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32022, + message: 'UnsupportedProtocolVersionError', + data: { + supported: ['2026-07-28'], + requested: String(metaVersion) + } + } + }); + } + + // Subscriptions Listening Endpoint Stream Handler (SSE/Chunked Line) + if (method === 'subscriptions/listen') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Transfer-Encoding': 'chunked' + }); + + const requestedNotifications = params.notifications || {}; + // The subscription ID matches the JSON-RPC id of the listen request. + const trackingSubId = String(id ?? 'sub-token-stateless-123'); + + const wantsTools = requestedNotifications.toolsListChanged === true; + const wantsPrompts = requestedNotifications.promptsListChanged === true; + + // First message MUST be notifications/subscriptions/acknowledged carrying tracking token in _meta + // The `notifications` field echoes the subset of the requested filter the server honors. + const ackFrame = { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { + _meta: { 'io.modelcontextprotocol/subscriptionId': trackingSubId }, + notifications: { + ...(wantsTools ? { toolsListChanged: true } : {}), + ...(wantsPrompts ? { promptsListChanged: true } : {}) + } + } + }; + res.write(JSON.stringify(ackFrame) + '\n'); + + // Keep the stream open and register it so list-changed notifications + // triggered by later requests are delivered to it. The stream ends when + // the client disconnects (which is also how the client cancels it). + const stream: ListenStream = { + res, + subscriptionId: trackingSubId, + wantsTools, + wantsPrompts + }; + activeListenStreams.push(stream); + res.on('close', () => { + const index = activeListenStreams.indexOf(stream); + if (index !== -1) activeListenStreams.splice(index, 1); + }); + return; + } + + if (method === 'server/discover') { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: { + tools: { listChanged: true }, // Explicitly announce dynamic capabilities matching Section 7 expectations + prompts: { listChanged: true }, + // resources/list, resources/templates/list and resources/read are + // served on this path, so the capability must be declared too. + resources: {} + }, + // Spec PR #3002: server identity lives in the result `_meta`. + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'everything-stateless-server', + version: '1.0.0' + } + } + } + }); + } + + if (method === 'tools/list') { + const dispatch = await getStatelessDispatchClient(); + try { + const fromServer = (await dispatch.client.request( + { method: 'tools/list', params: {} }, + ResultSchema as any + )) as { tools: any[]; [k: string]: unknown }; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + ...fromServer, + tools: [ + ...fromServer.tools, + { + name: 'test_missing_capability', + description: 'Test tool requiring sampling', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_elicitation', + description: + 'MRTR: returns InputRequiredResult with elicitation request', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_sampling', + description: + 'MRTR: returns InputRequiredResult with sampling request', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_list_roots', + description: + 'MRTR: returns InputRequiredResult with roots/list request', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_request_state', + description: + 'MRTR: returns InputRequiredResult with requestState', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_multiple_inputs', + description: + 'MRTR: returns InputRequiredResult with multiple input requests', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_multi_round', + description: 'MRTR: multi-round InputRequiredResult workflow', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_tampered_state', + description: 'MRTR: HMAC-signed requestState integrity test', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_capabilities', + description: + 'MRTR: respects client capabilities in inputRequests', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_streaming_elicitation', + description: + 'Diagnostic tool validating response progress streams', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_logging_tool', + description: 'Diagnostic logging validator tool', + inputSchema: { type: 'object', properties: {} } + } + ], + // SEP-2549 caching hints are required on cacheable list results. + ttlMs: 300000, + cacheScope: 'public' + } + }); + } catch (e: any) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); + } finally { + await dispatch.close(); + } + } + + // Mock fallbacks to answer prompts capability matches safely + if (method === 'prompts/list') { + const dispatch = await getStatelessDispatchClient(); + try { + const fromServer = (await dispatch.client.request( + { method: 'prompts/list', params: {} }, + ResultSchema as any + )) as { prompts: any[]; [k: string]: unknown }; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + ...fromServer, + prompts: [ + ...fromServer.prompts, + { + name: 'test_input_required_result_prompt', + description: 'MRTR: prompt that requires elicitation input' + } + ], + ttlMs: 300000, + cacheScope: 'public' + } + }); + } catch (e: any) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); + } finally { + await dispatch.close(); + } + } + + // SEP-2322 MRTR: prompts/get handler + if (method === 'prompts/get') { + if (params.name === 'test_input_required_result_prompt') { + const inputResponses = params.inputResponses as + | Record + | undefined; + if (inputResponses?.['user_context']) { + const context = getMrtInputText( + inputResponses['user_context'], + 'context' + ); + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with context: ${context}` + } + } + ] + } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + user_context: { + method: 'elicitation/create', + params: { + message: 'What context should the prompt use?', + requestedSchema: { + type: 'object', + properties: { context: { type: 'string' } }, + required: ['context'] + } + } + } + } + } + }); + } + } + + // Resources on the stateless path (SEP-2575): the McpServer-registered + // resources are merged with the stateless-only resource, mirroring the + // tools/list and prompts/list handlers above (SEP-2549 hints + SEP-2164 + // errors via the carry-forward dispatch below). + if (method === 'resources/list') { + const dispatch = await getStatelessDispatchClient(); + try { + const fromServer = (await dispatch.client.request( + { method: 'resources/list', params: {} }, + ResultSchema as any + )) as { resources: any[]; [k: string]: unknown }; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + ...fromServer, + resources: [ + ...fromServer.resources, + { + uri: 'test://stateless-static-text', + name: 'Stateless Static Text', + description: 'A static text resource served on the draft path', + mimeType: 'text/plain' + } + ], + ttlMs: 300000, + cacheScope: 'public' + } + }); + } catch (e: any) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); + } finally { + await dispatch.close(); + } + } + + if (method === 'resources/templates/list') { + const dispatch = await getStatelessDispatchClient(); + try { + const fromServer = (await dispatch.client.request( + { method: 'resources/templates/list', params: {} }, + ResultSchema as any + )) as { resourceTemplates: any[]; [k: string]: unknown }; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + ...fromServer, + ttlMs: 300000, + cacheScope: 'public' + } + }); + } catch (e: any) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); + } finally { + await dispatch.close(); + } + } + + if (method === 'resources/read') { + const uri = params.uri as string | undefined; + if (uri === 'test://stateless-static-text') { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + contents: [ + { + uri, + mimeType: 'text/plain', + text: 'Static text content from the stateless draft path.' + } + ], + ttlMs: 300000, + cacheScope: 'private' + } + }); + } + // Other URIs (including unknown ones) fall through to the carry-forward + // dispatch below, which serves the McpServer-registered resources. + } + + if (method === 'tools/call') { + const name = params.name; + const inputResponses = params.inputResponses as + | Record + | undefined; + const requestState = params.requestState as string | undefined; + + if (name === 'test_missing_capability') { + const clientCaps = meta['io.modelcontextprotocol/clientCapabilities']; + + // Missing Required Client Capability Check (-32021, HTTP 400) + if (!clientCaps?.sampling) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32021, + message: 'MissingRequiredClientCapabilityError', + // Per the schema, requiredCapabilities is a ClientCapabilities + // object keyed by the missing capability, not an array of names. + data: { requiredCapabilities: { sampling: {} } } + } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Success' }] } + }); + } + + // ===== SEP-2322 MRTR tools/call handlers ===== + + if (name === 'test_input_required_result_elicitation') { + if (inputResponses?.['user_name']) { + const userName = getMrtInputText(inputResponses['user_name'], 'name'); + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: `Hello, ${userName}!` }] } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + } + } + }); + } + + if (name === 'test_input_required_result_sampling') { + if (inputResponses?.['sample_request']) { + const sample = inputResponses['sample_request'] as Record< + string, + unknown + >; + const content = sample.content as Record | undefined; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}` + } + ] + } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + sample_request: { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'What is the capital of France?' + } + } + ], + maxTokens: 100 + } + } + } + } + }); + } + + if (name === 'test_input_required_result_list_roots') { + if (inputResponses?.['roots_request']) { + const rootsResult = inputResponses['roots_request'] as Record< + string, + unknown + >; + const roots = Array.isArray(rootsResult.roots) + ? rootsResult.roots + : []; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] + } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + roots_request: { method: 'roots/list', params: {} } + } + } + }); + } + + if (name === 'test_input_required_result_request_state') { + if (requestState && inputResponses?.['confirm']) { + const state = JSON.parse(requestState) as Record; + const ok = (inputResponses['confirm'] as Record) + ?.content as Record | undefined; + if (state.kind === 'request-state' && ok?.ok === true) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'state-ok: requestState validated' } + ] + } + }); + } + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + } + } + }, + requestState: JSON.stringify({ + kind: 'request-state', + nonce: randomUUID() + }) + } + }); + } + + if (name === 'test_input_required_result_multiple_inputs') { + if ( + requestState && + inputResponses?.['user_name'] && + inputResponses['greeting'] && + inputResponses['client_roots'] + ) { + const state = JSON.parse(requestState) as Record; + if (state.kind === 'multiple-inputs') { + const userName = getMrtInputText( + inputResponses['user_name'], + 'name' + ); + const greetingContent = ( + inputResponses['greeting'] as Record + ).content as Record | undefined; + const greeting = + typeof greetingContent?.text === 'string' + ? greetingContent.text + : 'Hello there!'; + const rootsResult = inputResponses['client_roots'] as Record< + string, + unknown + >; + const roots = Array.isArray(rootsResult.roots) + ? rootsResult.roots + : []; + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Name: ${userName}; Greeting: ${greeting}; Roots: ${roots.length}` + } + ] + } + }); + } + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + }, + greeting: { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { type: 'text', text: 'Generate a greeting' } + } + ], + maxTokens: 50 + } + }, + client_roots: { method: 'roots/list', params: {} } + }, + requestState: JSON.stringify({ + kind: 'multiple-inputs', + nonce: randomUUID() + }) + } + }); + } + + if (name === 'test_input_required_result_multi_round') { + if (!requestState) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + step1: { + method: 'elicitation/create', + params: { + message: 'Step 1: What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + }, + requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) + } + }); + } + const state = JSON.parse(requestState) as Record; + if (state.round === 1 && inputResponses?.['step1']) { + const userName = getMrtInputText(inputResponses['step1'], 'name'); + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + step2: { + method: 'elicitation/create', + params: { + message: 'Step 2: What is your favorite color?', + requestedSchema: { + type: 'object', + properties: { color: { type: 'string' } }, + required: ['color'] + } + } + } + }, + requestState: JSON.stringify({ + round: 2, + name: userName, + nonce: randomUUID() + }) + } + }); + } + if (state.round === 2 && inputResponses?.['step2']) { + const userName = + typeof state.name === 'string' ? state.name : 'friend'; + const color = getMrtInputText(inputResponses['step2'], 'color'); + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Multi-round complete for ${userName} who likes ${color}` + } + ] + } + }); + } + // Fallback: restart + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + step1: { + method: 'elicitation/create', + params: { + message: 'Step 1: What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + }, + requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) + } + }); + } + + if (name === 'test_input_required_result_tampered_state') { + if (requestState) { + const verified = verifyMrtState(requestState); + if (!verified) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'requestState integrity check failed' + } + }); + } + if (verified.kind === 'tamper-test' && inputResponses?.['confirm']) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'integrity-ok: state verified' } + ] + } + }); + } + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + } + } + }, + requestState: signMrtState({ + kind: 'tamper-test', + nonce: randomUUID() + }) + } + }); + } + + if (name === 'test_input_required_result_capabilities') { + const clientCaps = meta[ + 'io.modelcontextprotocol/clientCapabilities' + ] as Record | undefined; + const inputRequests: Record = {}; + + if (clientCaps?.elicitation) { + inputRequests['elicit_input'] = { + method: 'elicitation/create', + params: { + message: 'Elicitation input', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'] + } + } + }; + } + if (clientCaps?.sampling) { + inputRequests['sample_input'] = { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { type: 'text', text: 'Sample request' } + } + ], + maxTokens: 50 + } + }; + } + + if (inputResponses && Object.keys(inputResponses).length > 0) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `capabilities-ok: received ${Object.keys(inputResponses).join(',')}` + } + ] + } + }); + } + if (Object.keys(inputRequests).length === 0) { + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'No supported capabilities declared' } + ] + } + }); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests, + requestState: signMrtState({ + kind: 'capabilities-test', + nonce: randomUUID() + }) + } + }); + } + + // Progressive IncompleteResult Stream Generator Handling + if (name === 'test_streaming_elicitation') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked' + }); + + res.write( + JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/progress', // Emits standard progress notice + params: { progressToken: 'token-abc', total: 100, value: 50 } + }) + '\n' + ); + + return res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Streaming complete' }] } + }) + ); + } + + // Contextual Logging Constraints Verification Handler + if (name === 'test_logging_tool') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked' + }); + + // RULE: No logs allowed if meta configuration lacks explicit log level bounds + if (meta && meta['io.modelcontextprotocol/logLevel']) { + res.write( + JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { + level: 'info', + text: 'Diagnostic trace logging activated' + } + }) + '\n' + ); + } + + return res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Logging evaluated' }] } + }) + ); + } + + // Helper mutation hooks used by dynamic tests to force stream activity + // evaluation. The list change is fanned out to whichever + // subscriptions/listen streams are currently open and asked for it. + if ( + name === 'test_trigger_tool_change' || + name === 'test_trigger_prompt_change' + ) { + if (name === 'test_trigger_tool_change') { + notifyListenStreams('tools', 'notifications/tools/list_changed'); + } else { + notifyListenStreams('prompts', 'notifications/prompts/list_changed'); + } + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Mutation triggered' }] } + }); + } + } + + // Carry-forward methods that fell through the MRTR-specific handlers above + // (tools/call for non-MRTR tools, resources/*, prompts/get for non-MRTR + // prompts, completion/complete) are dispatched to the same McpServer the + // stateful path uses, via an in-memory client. This avoids duplicating the + // tool/resource/prompt registrations for the stateless path. + // + // tools/call is served as text/event-stream so progress and logging + // notifications from the underlying tool reach the conformance client. + if (method === 'tools/call') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache' + }); + const write = (msg: unknown) => + res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`); + const dispatch = await getStatelessDispatchClient(); + try { + const result = await dispatch.client.request( + { method, params }, + ResultSchema as any + ); + for (const n of dispatch.drainNotifications()) write(n); + write( + normalizeStatelessResponse(method, { jsonrpc: '2.0', id, result }) + ); + } catch (e: any) { + for (const n of dispatch.drainNotifications()) write(n); + write({ + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); + } finally { + await dispatch.close(); + } + return res.end(); + } + if ( + [ + 'resources/list', + 'resources/read', + 'resources/templates/list', + 'prompts/get', + 'completion/complete' + ].includes(method) + ) { + const dispatch = await getStatelessDispatchClient(); + try { + const result = await dispatch.client.request( + { method, params }, + ResultSchema as any + ); + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result }); + } catch (e: any) { + // SEP-2164: unknown resources get -32602 with the requested uri in + // data; the SDK's McpError does not populate data itself. + const data = + e.data ?? + (method === 'resources/read' ? { uri: params.uri } : undefined); + return sendStatelessJson(res, method, { + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data } + }); + } finally { + await dispatch.close(); + } + } + + // Removed Methods per SEP-2575 (Changed status from 200 to 400/404 per Transport Spec) + if ( + [ + 'initialize', + 'ping', + 'logging/setLevel', + 'resources/subscribe', + 'resources/unsubscribe' + ].includes(method) + ) { + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { + code: -32601, + message: 'Method not found: removed stateful RPC' + } + }); + } + + // Generic Fallback Unknown Method Handling (HTTP 404, -32601) + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } + }); + } try { let transport: StreamableHTTPServerTransport; @@ -1107,6 +2376,18 @@ app.post('/mcp', async (req, res) => { await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); return; + } else if (sessionId) { + // Session ID was provided but no transport matches it — the session + // has been terminated or was never issued. Spec: HTTP 404. + res.status(404).json({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Session not found' + }, + id: null + }); + return; } else { res.status(400).json({ jsonrpc: '2.0', @@ -1166,11 +2447,17 @@ app.get('/mcp', async (req, res) => { app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (!sessionId || !transports[sessionId]) { + if (!sessionId) { res.status(400).send('Invalid or missing session ID'); return; } + if (!transports[sessionId]) { + // Session has been terminated or was never issued. Spec: HTTP 404. + res.status(404).send('Session not found'); + return; + } + console.log(`Received session termination request for session ${sessionId}`); try { diff --git a/examples/servers/typescript/invalid-tool-names.ts b/examples/servers/typescript/invalid-tool-names.ts new file mode 100644 index 0000000..a71925d --- /dev/null +++ b/examples/servers/typescript/invalid-tool-names.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +/** + * Negative fixture for the tools-name-format check: advertises tool names that + * violate the 2025-11-25 Tool Names rules (spec #tool-names) by bypassing the + * SDK's registerTool validation, so the check can be shown to emit WARNING. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import express from 'express'; + +function createServer() { + const server = new Server( + { name: 'invalid-tool-names', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'bad tool name', + description: 'Deliberately invalid tool name for conformance testing', + inputSchema: { type: 'object' } + }, + { + name: 'valid_tool', + description: 'A conformant tool name', + inputSchema: { type: 'object' } + } + ] + })); + + return server; +} + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + try { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3009', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log( + `Invalid tool names negative test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index 80a2f37..638171a 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -8,11 +8,11 @@ "name": "mcp-conformance-test-server", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", - "zod-to-json-schema": "^3.25.0" + "zod": "^4.3.6" }, "devDependencies": { "tsx": "^4.7.0", @@ -20,9 +20,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -37,9 +37,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -54,9 +54,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -71,9 +71,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -88,9 +88,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -105,9 +105,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -122,9 +122,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -139,9 +139,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -156,9 +156,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -173,9 +173,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -190,9 +190,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -207,9 +207,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -224,9 +224,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -241,9 +241,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -258,9 +258,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -275,9 +275,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -292,9 +292,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -309,9 +309,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -326,9 +326,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -343,9 +343,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -360,9 +360,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -377,9 +377,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -394,9 +394,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -411,9 +411,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -428,9 +428,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -445,9 +445,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -461,12 +461,25 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.0.tgz", - "integrity": "sha512-D8h5KXY2vHFW8zTuxn2vuZGN0HGrQ5No6LkHwlEA9trVgNdPL3TF1dSqKA7Dny6BbBYKSW/rOBDXdC8KJAjUCg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -474,13 +487,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -508,12 +523,12 @@ } }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/accepts": { @@ -530,9 +545,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -563,9 +578,9 @@ } }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -574,7 +589,7 @@ "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", - "qs": "^6.14.0", + "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, @@ -665,9 +680,9 @@ } }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -675,6 +690,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -777,9 +796,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -790,32 +809,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/escape-html": { @@ -859,7 +878,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -899,10 +917,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, "engines": { "node": ">= 16" }, @@ -936,9 +957,9 @@ "license": "BSD-3-Clause" }, "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -949,7 +970,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/forwarded": { @@ -1032,9 +1057,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1080,6 +1105,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1101,9 +1135,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1122,6 +1156,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1144,9 +1187,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1158,6 +1201,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1289,9 +1338,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1321,9 +1370,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1401,31 +1450,35 @@ "license": "MIT" }, "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "^4.3.5", + "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "statuses": "^2.0.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", @@ -1435,6 +1488,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -1555,13 +1612,13 @@ } }, "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.25.0", + "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -1603,9 +1660,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unpipe": { @@ -1648,19 +1705,18 @@ "license": "ISC" }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index eab4eb7..4356d42 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -15,11 +15,11 @@ "testing" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", - "zod-to-json-schema": "^3.25.0" + "zod": "^4.3.6" }, "devDependencies": { "tsx": "^4.7.0", diff --git a/examples/servers/typescript/sep-2106-stripped-schema.ts b/examples/servers/typescript/sep-2106-stripped-schema.ts new file mode 100644 index 0000000..30da624 --- /dev/null +++ b/examples/servers/typescript/sep-2106-stripped-schema.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +/** + * SEP-2106 Negative Test Server + * + * Advertises `json_schema_2020_12_tool` but strips its inputSchema down to a + * bare `type: "object"` with simple properties — dropping the JSON Schema + * 2020-12 vocabulary (no $schema, $defs, additionalProperties, composition + * (allOf/anyOf), conditional (if/then/else), or $anchor keywords). + * + * The json-schema-2020-12 scenario should emit FAILURE for the SEP-2106 + * composition / conditional / $anchor preservation checks (as well as the + * SEP-1613 $schema / $defs / additionalProperties checks) against this server. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import express from 'express'; + +function createServer() { + const server = new Server( + { name: 'sep-2106-stripped-schema', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'json_schema_2020_12_tool', + description: + 'Tool whose JSON Schema 2020-12 keywords have been stripped', + // Stripped: only type + plain properties survive. + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + } + } + } + ] + })); + + return server; +} + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + try { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } +}); + +const PORT = parseInt(process.env.PORT || '3007', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log( + `SEP-2106 negative test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts new file mode 100644 index 0000000..06fc311 --- /dev/null +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +/** + * SEP-2164 Negative Test Server + * + * Speaks the stateless wire protocol (SEP-2575) but returns an empty + * contents array for any resources/read request, violating the SEP-2164 MUST + * NOT. The sep-2164-resource-not-found scenario should emit FAILURE for + * sep-2164-no-empty-contents against this server. + * + * Responses are otherwise schema-valid (resultType and SEP-2549 caching hints present); + * an empty contents array is schema-valid too, forbidden only by the SEP-2164 prose. + */ + +import express from 'express'; + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; + + switch (method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + supportedVersions: ['2026-07-28'], + capabilities: { resources: {} }, + serverInfo: { name: 'sep-2164-empty-contents', version: '1.0.0' } + } + }); + case 'resources/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + resources: [] + } + }); + case 'resources/read': + // Deliberately return an empty contents array instead of an error. + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + contents: [] + } + }); + default: + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } + }); + } +}); + +const PORT = parseInt(process.env.PORT || '3005', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log( + `SEP-2164 negative test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts new file mode 100644 index 0000000..72cf602 --- /dev/null +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +/** + * SEP-2322 MRTR Broken Server — Negative Test Case + * + * Deliberately violates several SEP-2322 MUST requirements: + * 1. Omits `resultType` field from InputRequiredResult responses + * 2. Returns InputRequiredResult on `tools/list` (unsupported method) + * 3. Accepts tampered requestState without integrity verification + * + * Plus one case that is not a violation at all: + * + * 4. Answers with a conformant input_required result that names no input + * request, leaving the capability check with nothing to verify. + * + * The conformance scenarios should emit FAILURE against this server. + */ + +import express from 'express'; +import { randomUUID } from 'crypto'; + +const PORT = parseInt(process.env.PORT || '3011', 10); + +// --- JSON-RPC dispatch --- + +type Handler = (params: Record) => unknown; + +const handlers: Record = {}; + +handlers['server/discover'] = () => ({ + supportedVersions: ['2026-07-28'], + capabilities: { + tools: {}, + prompts: {}, + elicitation: {} + }, + serverInfo: { name: 'sep-2322-mrtr-broken-server', version: '1.0.0' } +}); + +// BUG 2: Returns InputRequiredResult on tools/list (unsupported method) +handlers['tools/list'] = () => ({ + resultType: 'input_required', + inputRequests: { + bogus: { + method: 'elicitation/create', + params: { + message: 'This should not happen on tools/list', + requestedSchema: { type: 'object', properties: {} } + } + } + }, + tools: [ + { + name: 'test_input_required_result_elicitation', + description: 'Test tool for elicitation', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_tampered_state', + description: 'Test tool for tampered state', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_capabilities', + description: 'Test tool for client capability handling', + inputSchema: { type: 'object' as const, properties: {} } + } + ] +}); + +handlers['prompts/list'] = () => ({ + prompts: [] +}); + +handlers['tools/call'] = (params) => { + const toolName = params.name as string; + const inputResponses = params.inputResponses as + | Record + | undefined; + + switch (toolName) { + case 'test_input_required_result_elicitation': { + if (inputResponses?.['user_name']) { + return { + content: [{ type: 'text', text: 'Hello!' }] + }; + } + // BUG 1: Omits `resultType` field — spec says MUST include it + return { + // resultType: 'input_required', <-- deliberately omitted + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + } + }; + } + + case 'test_input_required_result_capabilities': { + // BUG 4: Conformant on its face — `requestState` satisfies "at least one + // of inputRequests or requestState" — but it names no input request, so + // the capability restriction under test is never exercised. + return { + resultType: 'input_required', + requestState: 'no-input-requested' + }; + } + + case 'test_input_required_result_tampered_state': { + if (inputResponses) { + // BUG 3: Accepts ANY requestState without verification + // A compliant server MUST reject tampered state + return { + content: [{ type: 'text', text: 'Accepted (no integrity check)' }] + }; + } + return { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Confirm?', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + } + } + }, + requestState: 'unprotected-state-' + randomUUID() + }; + } + + default: + throw { code: -32602, message: `Unknown tool: ${toolName}` }; + } +}; + +// --- Express app --- + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const body = req.body as { + jsonrpc: string; + id: number; + method: string; + params?: Record; + }; + + const handler = handlers[body.method]; + if (!handler) { + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { code: -32601, message: `Method not found: ${body.method}` } + }); + return; + } + + try { + const result = handler(body.params || {}); + res.json({ jsonrpc: '2.0', id: body.id, result }); + } catch (err: unknown) { + const error = err as { code?: number; message?: string }; + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { + code: error.code || -32603, + message: error.message || 'Internal error' + } + }); + } +}); + +app.listen(PORT, () => { + console.log( + `sep-2322-mrtr-broken-server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/sep-2549-no-caching-hints.ts b/examples/servers/typescript/sep-2549-no-caching-hints.ts new file mode 100644 index 0000000..efee1c2 --- /dev/null +++ b/examples/servers/typescript/sep-2549-no-caching-hints.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/** + * SEP-2549 Negative Test Server + * + * Speaks the stateless wire protocol (SEP-2575) but returns list and + * read results WITHOUT ttlMs and cacheScope fields, violating the SEP-2549 + * MUST. The caching scenario should emit FAILURE for presence checks against + * this server. + */ + +import express from 'express'; + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; + + // Deliberately omit ttlMs and cacheScope from every result below. + switch (method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'sep-2549-no-caching-hints', version: '1.0.0' } + } + }); + case 'tools/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { + tools: [ + { + name: 'test_tool', + description: 'A test tool', + inputSchema: { type: 'object' } + } + ] + } + }); + case 'prompts/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { + prompts: [{ name: 'test_prompt', description: 'A test prompt' }] + } + }); + case 'resources/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { + resources: [ + { + uri: 'test://static-text', + name: 'Static Text', + description: 'A static text resource' + } + ] + } + }); + case 'resources/templates/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { resourceTemplates: [] } + }); + case 'resources/read': + return res.json({ + jsonrpc: '2.0', + id, + result: { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'Static text content.' + } + ] + } + }); + default: + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } + }); + } +}); + +const PORT = parseInt(process.env.PORT || '3006', 10); +app.listen(PORT, () => { + console.log( + `SEP-2549 negative test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/tools-list-rotated-order.ts b/examples/servers/typescript/tools-list-rotated-order.ts new file mode 100644 index 0000000..df32529 --- /dev/null +++ b/examples/servers/typescript/tools-list-rotated-order.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +/** + * tools/list ordering negative test server. + * + * Speaks the sessionless 2026-07-28 wire (SEP-2575) and advertises the same + * four tools on every tools/list request, but rotates the list by one + * position each time (it keeps a call counter for that, nothing else). The set never changes, only the order, which violates the + * 2026-07-28 SHOULD "Servers SHOULD return tools in a deterministic order". + * The tools-list scenario should emit WARNING for + * tools-list-deterministic-order against this server while tools-list itself + * still passes, since every response is structurally valid. + */ + +import express from 'express'; + +const app = express(); +app.use(express.json()); + +const TOOLS = ['alpha', 'bravo', 'charlie', 'delta'].map((name) => ({ + name, + description: `Fixture tool ${name}`, + inputSchema: { type: 'object', properties: {} } +})); + +let listCalls = 0; + +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; + + switch (method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { name: 'tools-list-rotated-order', version: '1.0.0' } + } + }); + case 'tools/list': { + // Rotate by one position per call: the same set, never the same order. + const offset = listCalls++ % TOOLS.length; + const tools = [...TOOLS.slice(offset), ...TOOLS.slice(0, offset)]; + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools + } + }); + } + default: + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } + }); + } +}); + +const PORT = parseInt(process.env.PORT || '3008', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log( + `tools/list rotated-order negative test server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/package-lock.json b/package-lock.json index 250c002..13e41d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,127 +1,130 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.13", + "version": "0.2.0-alpha.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.13", + "version": "0.2.0-alpha.11", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "commander": "^14.0.2", - "eventsource-parser": "^3.0.6", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "jose": "^6.1.2", - "undici": "^7.19.0", + "undici": "^7.25.0", "yaml": "^2.8.2", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "bin": { "conformance": "dist/index.js" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.4", "@types/express": "^5.0.3", - "@types/node": "^22.10.2", - "@typescript/native-preview": "^7.0.0-dev.20251201.1", + "@types/node": "^25.6.0", + "@typescript/native-preview": "^7.0.0-dev.20260424.1", "cors": "^2.8.5", - "eslint": "^9.8.0", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", - "lefthook": "^2.0.2", - "prettier": "3.6.2", - "tsdown": "^0.15.12", + "lefthook": "^2.1.6", + "prettier": "^3.8.3", + "tsdown": "^0.21.10", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "8.48.0", - "vitest": "^4.0.16" + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.5" } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz", + "integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^8.0.0-rc.3", + "@babel/types": "^8.0.0-rc.3", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^8.0.0-rc.3" }, "bin": { "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -130,9 +133,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -141,9 +144,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -158,9 +161,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -175,9 +178,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -192,9 +195,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -209,9 +212,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -226,9 +229,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -243,9 +246,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -260,9 +263,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -277,9 +280,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -294,9 +297,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -311,9 +314,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -328,9 +331,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -345,9 +348,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -362,9 +365,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -379,9 +382,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -396,9 +399,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -413,9 +416,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -430,9 +433,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -447,9 +450,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -464,9 +467,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -481,9 +484,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -498,9 +501,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -515,9 +518,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -532,9 +535,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -549,9 +552,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -566,9 +569,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -625,15 +628,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -666,20 +669,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -690,9 +693,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -714,9 +717,9 @@ "license": "MIT" }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -751,9 +754,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -854,9 +857,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -894,20 +897,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@octokit/auth-token": { @@ -938,9 +943,9 @@ } }, "node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", @@ -1013,15 +1018,16 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", "license": "MIT", "dependencies": { - "@octokit/endpoint": "^11.0.2", + "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, "engines": { @@ -1065,9 +1071,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.95.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.95.0.tgz", - "integrity": "sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -1088,9 +1094,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.45.tgz", - "integrity": "sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -1105,9 +1111,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.45.tgz", - "integrity": "sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -1122,9 +1128,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.45.tgz", - "integrity": "sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -1139,9 +1145,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.45.tgz", - "integrity": "sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -1156,9 +1162,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.45.tgz", - "integrity": "sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -1173,9 +1179,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.45.tgz", - "integrity": "sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -1190,9 +1196,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.45.tgz", - "integrity": "sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], @@ -1206,10 +1212,44 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.45.tgz", - "integrity": "sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -1224,9 +1264,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.45.tgz", - "integrity": "sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -1241,9 +1281,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.45.tgz", - "integrity": "sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -1258,9 +1298,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.45.tgz", - "integrity": "sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -1268,16 +1308,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.7" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.45.tgz", - "integrity": "sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -1291,27 +1333,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.45.tgz", - "integrity": "sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.45.tgz", - "integrity": "sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -1326,362 +1351,12 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.45.tgz", - "integrity": "sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1778,6 +1453,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1786,19 +1468,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", "dev": true, "license": "MIT" }, @@ -1831,21 +1513,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", - "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1855,9 +1536,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1871,17 +1552,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", - "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1891,20 +1572,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", - "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1914,18 +1595,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", - "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1936,9 +1617,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", - "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", "dev": true, "license": "MIT", "engines": { @@ -1949,21 +1630,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", - "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1973,14 +1654,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", - "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -1992,21 +1673,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", - "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2016,46 +1697,59 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", - "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2065,19 +1759,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", - "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2087,29 +1781,45 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-vy52DLNMYVTizp02/Uu8TrHQrt3BU0b7foE7qqxPAZF63zXpwvGg1g4EAgFtu7ZDJlYrAlUqSdZg6INb/3iY6w==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-AExG+RjPYS/YD4n4y4B3qznONzpARzSYpZBYayXLgfDV+mIRLIFk7KWxuO9wi2Dx4zYV/W+YmM19AiP4WeTUaA==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, + "engines": { + "node": ">=16.20.0" + }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260210.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260210.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260210.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260210.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260210.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260210.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260210.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260424.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260424.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260424.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260424.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260424.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260424.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260424.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-taEYpsrCbdcyHkqNMBiVcqKR7ZHMC1jwTBM9kn3eUgOjXn68ASRrmyzYBdrujluBJMO7rl+Gm5QRT68onYt53A==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-cDlRsCddHfTB++G1jFFO13y+4WkGn5RVF5yHFjFEQ9tvhN9FUxFx+0BxWIFoGg17Lp6/55kGkBD75qlNLdNzwA==", "cpu": [ "arm64" ], @@ -2118,12 +1828,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-TSgIk2osa3UpivKybsyglBx7KBL+vTNayagmpzYvxBXbPvBnbgGOgzE/5iHkzFJYVUFxqmuj1gopmDT9X/obaQ==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-vnMTeBlKq0OvRVzp2j9cliNNJ3ReI3mn8UihTJ5cAw7dMPH+uRfZP+TrpRBFKZJMVTteILuZS9XopUmHk/M++g==", "cpu": [ "x64" ], @@ -2132,12 +1845,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-2matUA2ZU/1Zdv/pWLsdNwdzkOxBPeLa1581wgnaANrzZD3IJm4eCMfidRFTh9fVPN/eMsthYOeSnuVJa/mPmg==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-gojRlfaxqbXnWKdyuKoKj9wACY3Y6h/kdhXG/QRpLgp/K/9pwkj4AJ6zVCkqvyEULc8ESwPygiMrJHlzRXXSvg==", "cpu": [ "arm" ], @@ -2146,12 +1862,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-aSdY/1Uh+4hOpQT1jHvM16cNqXv6lihe3oZmGTV6DmgkeH9soGXRumbu+oA73E3w0Hm6PjD/aIzbvK53yjvN1Q==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-JRJAi8dCvHEn280XxwhJi4aDFwIHrwQVhsSztpGodcqFEXMJ9WZlbR9Q8pWmJnPeFFFdTMPgSs5yzF4ZBQymBQ==", "cpu": [ "arm64" ], @@ -2160,12 +1879,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-7C5mhiOFzWB+hdoCuog9roQuNFFHALw1jz0zrA9ikH18DOgnnGJpGLuekQJdXG1yQSdrALZROXLidTmVxFYSgg==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-K34ZcseVSpysIhlyF1ors76apV3wleoRhS1dM8gMoYubl2vJ8eA0y6O8YzQuzt0lLkByBwk2Ikj02DwHD7Lx0Q==", "cpu": [ "x64" ], @@ -2174,12 +1896,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-n8/tI1rOrqy+kFqrNc4xBYaVc1eGn5SYS9HHDZOPZ8E2b3Oq7RAPSZdNi+YYwMcOx3MFon0Iu6mZ1N6lqer9Dw==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-tNWQjHbdawI+6KBeaVcgU/QIu4PJ2IfW/cGGvv/N+1o6No9MxcU1HO0n2Kse6zjm3xRPNrVG/FG0q/viMFxByg==", "cpu": [ "arm64" ], @@ -2188,12 +1913,15 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260210.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260210.1.tgz", - "integrity": "sha512-wC/Aoxf/5/m/7alzb7RxLivGuYwZw3/Iq7RO73egG70LL2RLUuP306MDg1sj2TyeAe+S3zZX3rU1L6qMOW439A==", + "version": "7.0.0-dev.20260424.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260424.1.tgz", + "integrity": "sha512-kfvSYfsXKxNVzcmWudyPqJhjTcolsejDEk9hpUQvO8O6nHnE3gXeH7e1NiE/1xS52567ZQNvhSbc2vfkciC+bA==", "cpu": [ "x64" ], @@ -2202,34 +1930,37 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2238,7 +1969,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2250,26 +1981,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -2277,13 +2008,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2292,9 +2024,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -2302,14 +2034,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2329,9 +2062,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2352,9 +2085,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2428,13 +2161,14 @@ } }, "node_modules/ast-kit": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", - "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "version": "3.0.0-beta.1", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz", + "integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", + "@babel/parser": "^8.0.0-beta.4", + "estree-walker": "^3.0.3", "pathe": "^2.0.3" }, "engines": { @@ -2458,9 +2192,9 @@ "license": "Apache-2.0" }, "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", + "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", "dev": true, "license": "MIT", "funding": { @@ -2492,9 +2226,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2512,13 +2246,13 @@ } }, "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20.19.0" } }, "node_modules/call-bind-apply-helpers": { @@ -2587,22 +2321,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2661,6 +2379,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2735,9 +2460,9 @@ "license": "MIT" }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, "license": "MIT" }, @@ -2750,14 +2475,14 @@ "node": ">= 0.8" } }, - "node_modules/diff": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", - "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "Apache-2.0", "engines": { - "node": ">=0.3.1" + "node": ">=8" } }, "node_modules/dts-resolver": { @@ -2839,9 +2564,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -2858,9 +2583,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2871,32 +2596,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/escape-html": { @@ -2919,25 +2644,25 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -2956,7 +2681,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3025,9 +2750,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3144,9 +2869,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -3206,12 +2931,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3359,9 +3084,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3445,9 +3170,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3495,13 +3220,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3537,18 +3255,18 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", "license": "MIT", "engines": { "node": ">=16.9.0" } }, "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", + "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", "dev": true, "license": "MIT" }, @@ -3615,6 +3333,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-without-cache": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.3.3.tgz", + "integrity": "sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3632,9 +3363,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -3684,20 +3415,10 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3755,6 +3476,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3766,9 +3493,9 @@ } }, "node_modules/lefthook": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.0.tgz", - "integrity": "sha512-+vS+yywGQW6CN1J1hbGkez//6ixGHIQqfxDN/d3JDm531w9GfGt2lAWTDfZTw/CEl80XsN0raFcnEraR3ldw9g==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.6.tgz", + "integrity": "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3776,22 +3503,22 @@ "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.1.0", - "lefthook-darwin-x64": "2.1.0", - "lefthook-freebsd-arm64": "2.1.0", - "lefthook-freebsd-x64": "2.1.0", - "lefthook-linux-arm64": "2.1.0", - "lefthook-linux-x64": "2.1.0", - "lefthook-openbsd-arm64": "2.1.0", - "lefthook-openbsd-x64": "2.1.0", - "lefthook-windows-arm64": "2.1.0", - "lefthook-windows-x64": "2.1.0" + "lefthook-darwin-arm64": "2.1.6", + "lefthook-darwin-x64": "2.1.6", + "lefthook-freebsd-arm64": "2.1.6", + "lefthook-freebsd-x64": "2.1.6", + "lefthook-linux-arm64": "2.1.6", + "lefthook-linux-x64": "2.1.6", + "lefthook-openbsd-arm64": "2.1.6", + "lefthook-openbsd-x64": "2.1.6", + "lefthook-windows-arm64": "2.1.6", + "lefthook-windows-x64": "2.1.6" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.0.tgz", - "integrity": "sha512-u2hjHLQXWSFfzO7ln2n/uEydSzfC9sc5cDC7tvKSuOdhvBwaJ0AQ7ZeuqqCQ4YfVIJfYOom1SVE9CBd10FVyig==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.6.tgz", + "integrity": "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==", "cpu": [ "arm64" ], @@ -3803,9 +3530,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.0.tgz", - "integrity": "sha512-zz5rcyrtOZpxon7uE+c0KC/o2ypJeLZql5CL0Y9oaTuECbmhfokm8glsGnyWstW/++PuMpZYYr/qsCJA5elxkQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.6.tgz", + "integrity": "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ==", "cpu": [ "x64" ], @@ -3817,9 +3544,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.0.tgz", - "integrity": "sha512-+mXNCNuFHNGYLrDqYWDeHH7kWCLCJFPpspx5PAAm+PD37PRMZJrTqDbaNK9qCghC1tdmT4/Lvilf/ewXHPlaKw==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.6.tgz", + "integrity": "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA==", "cpu": [ "arm64" ], @@ -3831,9 +3558,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.0.tgz", - "integrity": "sha512-+AU2HD7szuDsUdHue/E3OnF84B2ae/h7CGKpuIUHJntgoJ4kxf89oDvq2/xl8kDCn9cT76UUjgeZUgFYLRj+6Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.6.tgz", + "integrity": "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg==", "cpu": [ "x64" ], @@ -3845,9 +3572,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.0.tgz", - "integrity": "sha512-KM70eV1tsEib1/tk+3TFxIdH84EaYlIg5KTQWAg+LB1N23nTQ7lL4Dnh1je6f6KW4tf21nmoMUqsh0xvMkQk8Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.6.tgz", + "integrity": "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g==", "cpu": [ "arm64" ], @@ -3859,9 +3586,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.0.tgz", - "integrity": "sha512-6Bxmv+l7LiYq9W0IE6v2lmlRtBp6pisnlzhcouMGvH3rDwEGw11NAyRJZA3IPGEMAkIuhnlnVTUwAUzKomfJLg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.6.tgz", + "integrity": "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw==", "cpu": [ "x64" ], @@ -3873,9 +3600,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.0.tgz", - "integrity": "sha512-ppJNK0bBSPLC8gqksRw5zI/0uLeMA5cK+hmZ4ofcuGNmdrN1dfl2Tx84fdeef0NcQY0ii9Y3j3icIKngIoid/g==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.6.tgz", + "integrity": "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A==", "cpu": [ "arm64" ], @@ -3887,9 +3614,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.0.tgz", - "integrity": "sha512-8k9lQsMYqQGu4spaQ8RNSOJidxIcOyfaoF2FPZhthtBfRV3cgVFGrsQ0hbIi5pvQRGUlCqYuCN79qauXHmnL3Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.6.tgz", + "integrity": "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg==", "cpu": [ "x64" ], @@ -3901,9 +3628,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.0.tgz", - "integrity": "sha512-0WN+grrxt9zP9NGRcztoPXcz25tteem91rfLWgQFab+50csJ47zldlsB7/eOS/eHG5mUg5g5NPR4XefnXtjOcQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.6.tgz", + "integrity": "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw==", "cpu": [ "arm64" ], @@ -3915,9 +3642,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.0.tgz", - "integrity": "sha512-XbO/5nAZQLpUn0tPpgCYfFBFJHnymSglQ73jD6wymNrR1j8I5EcXGlP6YcLhnZ83yzsdLC+gup+N6IqUeiyRdw==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.6.tgz", + "integrity": "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A==", "cpu": [ "x64" ], @@ -3942,6 +3669,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4031,9 +4019,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4229,9 +4217,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -4253,9 +4241,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -4275,9 +4263,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -4314,9 +4302,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -4353,9 +4341,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4408,20 +4396,6 @@ "node": ">= 0.10" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4452,14 +4426,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-beta.45", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", - "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.95.0", - "@rolldown/pluginutils": "1.0.0-beta.45" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4468,38 +4442,40 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.45", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.45", - "@rolldown/binding-darwin-x64": "1.0.0-beta.45", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.45", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.45", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.45", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.45", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.45", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.45", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.45", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.45", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.45", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.45", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.45" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/rolldown-plugin-dts": { - "version": "0.17.8", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.8.tgz", - "integrity": "sha512-76EEBlhF00yeY6M7VpMkWKI4r9WjuoMiOGey7j4D6zf3m0BR+ZrrY9hvSXdueJ3ljxSLq4DJBKFpX/X9+L7EKw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.23.2.tgz", + "integrity": "sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/generator": "^7.28.5", - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "ast-kit": "^2.2.0", - "birpc": "^2.8.0", + "@babel/generator": "8.0.0-rc.3", + "@babel/helper-validator-identifier": "8.0.0-rc.3", + "@babel/parser": "8.0.0-rc.3", + "@babel/types": "8.0.0-rc.3", + "ast-kit": "^3.0.0-beta.1", + "birpc": "^4.0.0", "dts-resolver": "^2.1.3", - "get-tsconfig": "^4.13.0", - "magic-string": "^0.30.21", - "obug": "^2.0.0" + "get-tsconfig": "^4.13.7", + "obug": "^2.1.1", + "picomatch": "^4.0.4" }, "engines": { "node": ">=20.19.0" @@ -4509,10 +4485,10 @@ }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", - "@typescript/native-preview": ">=7.0.0-dev.20250601.1", - "rolldown": "^1.0.0-beta.44", - "typescript": "^5.0.0", - "vue-tsc": "~3.1.0" + "@typescript/native-preview": ">=7.0.0-dev.20260325.1", + "rolldown": "^1.0.0-rc.12", + "typescript": "^5.0.0 || ^6.0.0", + "vue-tsc": "~3.2.0" }, "peerDependenciesMeta": { "@ts-macro/tsc": { @@ -4529,51 +4505,6 @@ } } }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4787,9 +4718,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -4827,9 +4758,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -4837,14 +4768,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4854,9 +4785,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4883,9 +4814,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4896,26 +4827,28 @@ } }, "node_modules/tsdown": { - "version": "0.15.12", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.15.12.tgz", - "integrity": "sha512-c8VLlQm8/lFrOAg5VMVeN4NAbejZyVQkzd+ErjuaQgJFI/9MhR9ivr0H/CM7UlOF1+ELlF6YaI7sU/4itgGQ8w==", + "version": "0.21.10", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.10.tgz", + "integrity": "sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA==", "dev": true, "license": "MIT", "dependencies": { "ansis": "^4.2.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "debug": "^4.4.3", - "diff": "^8.0.2", + "cac": "^7.0.0", + "defu": "^6.1.7", "empathic": "^2.0.0", - "hookable": "^5.5.3", - "rolldown": "1.0.0-beta.45", - "rolldown-plugin-dts": "^0.17.2", - "semver": "^7.7.3", - "tinyexec": "^1.0.1", - "tinyglobby": "^0.2.15", + "hookable": "^6.1.1", + "import-without-cache": "^0.3.3", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "rolldown": "1.0.0-rc.17", + "rolldown-plugin-dts": "^0.23.2", + "semver": "^7.7.4", + "tinyexec": "^1.1.1", + "tinyglobby": "^0.2.16", "tree-kill": "^1.2.2", - "unconfig": "^7.3.3" + "unconfig-core": "^7.5.0", + "unrun": "^0.2.37" }, "bin": { "tsdown": "dist/run.mjs" @@ -4928,30 +4861,34 @@ }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.21.10", + "@tsdown/exe": "0.21.10", + "@vitejs/devtools": "*", "publint": "^0.3.0", - "typescript": "^5.0.0", - "unplugin-lightningcss": "^0.4.0", - "unplugin-unused": "^0.5.0", - "unrun": "^0.2.1" + "typescript": "^5.0.0 || ^6.0.0", + "unplugin-unused": "^0.5.0" }, "peerDependenciesMeta": { "@arethetypeswrong/core": { "optional": true }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, "publint": { "optional": true }, "typescript": { "optional": true }, - "unplugin-lightningcss": { - "optional": true - }, "unplugin-unused": { "optional": true - }, - "unrun": { - "optional": true } } }, @@ -5025,16 +4962,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", - "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.0", - "@typescript-eslint/parser": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0" + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5044,31 +4981,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/unconfig": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.4.2.tgz", - "integrity": "sha512-nrMlWRQ1xdTjSnSUqvYqJzbTBFugoqHobQj58B2bc8qxHKBBHMNNsWQFP3Cd3/JZK907voM2geYPWqD4VK3MPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@quansync/fs": "^1.0.0", - "defu": "^6.1.4", - "jiti": "^2.6.1", - "quansync": "^1.0.0", - "unconfig-core": "7.4.2" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unconfig-core": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.4.2.tgz", - "integrity": "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5080,18 +5000,18 @@ } }, "node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -5110,6 +5030,33 @@ "node": ">= 0.8" } }, + "node_modules/unrun": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.37.tgz", + "integrity": "sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rolldown": "1.0.0-rc.17" + }, + "bin": { + "unrun": "dist/cli.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/Gugustinette" + }, + "peerDependencies": { + "synckit": "^0.11.11" + }, + "peerDependenciesMeta": { + "synckit": { + "optional": true + } + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5130,18 +5077,17 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -5157,9 +5103,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -5172,15 +5119,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -5205,31 +5155,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -5245,12 +5195,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -5271,6 +5224,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -5279,6 +5238,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -5331,9 +5293,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -5359,9 +5321,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index a9f1e8f..af723a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.13", + "version": "0.2.0-alpha.11", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -14,46 +14,52 @@ "start": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", - "build": "tsdown src/index.ts --minify --clean --target node20", + "build": "tsdown src/index.ts --minify --clean --target node20 --no-fixed-extension", "lint": "eslint src/ examples/ && prettier --check .", "lint:fix": "eslint src/ examples/ --fix && prettier --write .", "lint:fix_check": "npm run lint:fix && git diff --exit-code --quiet", "tier-check": "node dist/index.js tier-check", + "traceability": "tsx src/index.ts traceability", + "sync-schema": "tsx scripts/sync-schema.ts", "check": "npm run typecheck && npm run lint", "typecheck": "tsgo --noEmit", - "prepack": "npm run build" + "prepack": "npm run build", + "prepare": "npm run build" }, "files": [ - "dist" + "dist", + "requirements" ], "bin": { "conformance": "dist/index.js" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.4", "@types/express": "^5.0.3", - "@types/node": "^22.10.2", - "@typescript/native-preview": "^7.0.0-dev.20251201.1", + "@types/node": "^25.6.0", + "@typescript/native-preview": "^7.0.0-dev.20260424.1", "cors": "^2.8.5", - "eslint": "^9.8.0", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", - "lefthook": "^2.0.2", - "prettier": "3.6.2", - "tsdown": "^0.15.12", + "lefthook": "^2.1.6", + "prettier": "^3.8.3", + "tsdown": "^0.21.10", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "8.48.0", - "vitest": "^4.0.16" + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.5" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@octokit/rest": "^22.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "commander": "^14.0.2", - "eventsource-parser": "^3.0.6", + "eventsource-parser": "^3.0.8", "express": "^5.1.0", "jose": "^6.1.2", - "undici": "^7.19.0", + "undici": "^7.25.0", "yaml": "^2.8.2", - "zod": "^3.25.76", - "@octokit/rest": "^22.0.0" + "zod": "^4.3.6" } } diff --git a/requirements/2025-11-25.yaml b/requirements/2025-11-25.yaml new file mode 100644 index 0000000..cc29607 --- /dev/null +++ b/requirements/2025-11-25.yaml @@ -0,0 +1,129 @@ +# Conformance requirements for the 2025-11-25 specification revision. +# +# This file is the canonical answer to "which scenarios must my implementation pass +# to conform to 2025-11-25". +# +# Anchor, and READ THIS BEFORE TRUSTING IT AS A SNAPSHOT: unlike the 2026-07-28 set, +# this one could not be frozen at its own ship date. The release current on 2025-11-25 +# was 0.1.7 (published 2025-11-20), which had no --spec-version flag and no concept of +# which revision a scenario belonged to, so there is nothing to snapshot. This set is +# instead derived from @modelcontextprotocol/conformance@0.2.0-alpha.10 filtered to +# 2025-11-25, and therefore contains scenarios written after 2025-11-25 shipped. It is +# frozen from here on, but it is a reconstruction rather than a contemporaneous record. +# +# `server` and `client` are named for the subcommand that runs them, and list what +# conformance to this revision requires. Scenarios run at THIS revision's wire +# version: the dated revisions through 2025-11-25 use the stateful initialize +# handshake and 2026-07-28 is stateless with per-request _meta, so a scenario that +# applies to both must be run once under each and one run does not cover the other. +# +# There is deliberately no authorization-server section: the MCP specification puts +# authorization-server implementation beyond its own scope, so those scenarios serve +# people deploying an authorization server, not implementations of MCP itself. +# +# `not_scored` is run and reported but never counts toward a pass rate; each entry +# says why. `extension` is optional by definition (SEP-1730: "Experimental features +# and protocol extensions ... are not required for any tier"). `added-after-release` +# means added to the suite AFTER THE ANCHOR RELEASE this file is derived from +# (0.2.0-alpha.10) — not after the revision's own ship date. This file is a +# reconstruction (see above), so several SCORED scenarios also postdate +# 2025-11-25 itself (ping, dns-rebinding-protection, the token-endpoint-auth +# trio, auth/pre-registration); they are scored because they entered the suite +# well before the anchor and every current implementation passes them. The line +# this file freezes is the anchor, and it draws it consistently. Promoting an +# entry into the lists above is a deliberate, reviewable change. +# +# Scenarios that were PENDING in the source release are never scored — SEP-1730 +# scores "applicable required tests" only, and pending means the suite's own +# reference fixture cannot pass them yet. They still RUN, under not_scored with +# reason: pending, because the implementation under test may well pass what the +# reference fixture cannot, and invisible coverage is how gaps hide. (The tasks +# extension suite attaches to 2026-07-28 only and has no entries here.) + +server: + - server-initialize + - logging-set-level + - ping + - completion-complete + - tools-list + - tools-call-simple-text + - tools-call-image + - tools-call-audio + - tools-call-embedded-resource + - tools-call-mixed-content + - tools-call-with-logging + - tools-call-error + - tools-call-with-progress + - tools-call-sampling + - tools-call-elicitation + - elicitation-sep1034-defaults + - server-sse-multiple-streams + - elicitation-sep1330-enums + - resources-list + - resources-read-text + - resources-read-binary + - resources-templates-read + - resources-subscribe + - resources-unsubscribe + - prompts-list + - prompts-get-simple + - prompts-get-with-args + - prompts-get-embedded-resource + - prompts-get-with-image + - dns-rebinding-protection + +client: + - initialize + - tools_call + - elicitation-sep1034-client-defaults + - sse-retry + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/basic-cimd + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/pre-registration + +not_scored: + - scenario: auth/client-credentials-jwt + leg: client + reason: extension + - scenario: auth/client-credentials-basic + leg: client + reason: extension + - scenario: auth/enterprise-managed-authorization + leg: client + reason: extension + - scenario: auth/dpop + leg: client + reason: extension + - scenario: auth/dpop-nonce + leg: client + reason: extension + - scenario: auth/wif-jwt-bearer + leg: client + reason: extension + - scenario: server-session-lifecycle + leg: server + reason: added-after-release + - scenario: json-schema-2020-12-preservation + leg: client + reason: added-after-release + - scenario: json-schema-2020-12 + leg: server + reason: pending + note: >- + the reference fixture cannot pass it yet; the implementation under test might + - scenario: server-sse-polling + leg: server + reason: pending + note: >- + on hold pending server-side SSE improvements in the reference fixture diff --git a/requirements/2026-07-28.yaml b/requirements/2026-07-28.yaml new file mode 100644 index 0000000..b0c4f85 --- /dev/null +++ b/requirements/2026-07-28.yaml @@ -0,0 +1,197 @@ +# Conformance requirements for the 2026-07-28 specification revision. +# +# This file is the canonical answer to "which scenarios must my implementation pass +# to conform to 2026-07-28". It is FROZEN: the lists below were fixed when the +# revision shipped and must not be edited afterwards. An implementation is measured +# against the suite as it stood when it was expected to conform, not against whatever +# the suite has accumulated since. +# +# Anchor: @modelcontextprotocol/conformance@0.2.0-alpha.10, published 2026-07-27, the +# release current when this revision shipped. That release could express spec-version +# applicability, so this set is a faithful snapshot of what was required at ship. +# +# `server` and `client` are named for the subcommand that runs them, and list what +# conformance to this revision requires. Scenarios run at THIS revision's wire +# version: the dated revisions through 2025-11-25 use the stateful initialize +# handshake and 2026-07-28 is stateless with per-request _meta, so a scenario that +# applies to both must be run once under each and one run does not cover the other. +# +# There is deliberately no authorization-server section: the MCP specification puts +# authorization-server implementation beyond its own scope, so those scenarios serve +# people deploying an authorization server, not implementations of MCP itself. +# +# `not_scored` is run and reported but never counts toward a pass rate; each entry +# says why. `extension` is optional by definition (SEP-1730: "Experimental features +# and protocol extensions ... are not required for any tier"). `added-after-release` +# means added to the suite after the anchor release this file is derived from +# (0.2.0-alpha.10, published the day before this revision shipped), so no +# implementation pinning a published referee could have been running it. Promoting an entry into the lists above is a deliberate, reviewable +# change. +# +# Scenarios that were PENDING in the source release are never scored — SEP-1730 +# scores "applicable required tests" only, and pending means the suite's own +# reference fixture cannot pass them yet. They still RUN, under not_scored with +# reason: pending (or extension for the tasks suite), because the implementation +# under test may well pass what the reference fixture cannot, and invisible +# coverage is how gaps hide. + +server: + - server-stateless + - completion-complete + - tools-list + - tools-call-simple-text + - tools-call-image + - tools-call-audio + - tools-call-embedded-resource + - tools-call-mixed-content + - tools-call-error + - tools-call-with-progress + - server-sse-multiple-streams + - resources-list + - resources-read-text + - resources-read-binary + - resources-templates-read + - sep-2164-resource-not-found + - prompts-list + - prompts-get-simple + - prompts-get-with-args + - prompts-get-embedded-resource + - prompts-get-with-image + - dns-rebinding-protection + - caching + - input-required-result-basic-elicitation + - input-required-result-basic-sampling + - input-required-result-basic-list-roots + - input-required-result-request-state + - input-required-result-multiple-input-requests + - input-required-result-multi-round + - input-required-result-missing-input-response + - input-required-result-non-tool-request + - input-required-result-result-type + - input-required-result-unsupported-methods + - input-required-result-tampered-state + - input-required-result-capability-check + - input-required-result-ignore-extra-params + - input-required-result-validate-input + +client: + - tools_call + - request-metadata + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/basic-cimd + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/pre-registration + - auth/resource-mismatch + - auth/offline-access-scope + - auth/offline-access-not-supported + - auth/authorization-server-migration + - auth/iss-supported + - auth/iss-not-advertised + - auth/iss-supported-missing + - auth/iss-wrong-issuer + - auth/iss-unexpected + - auth/iss-normalized + - auth/metadata-issuer-mismatch + - sep-2322-client-request-state + - http-standard-headers + - http-custom-headers + - http-invalid-tool-headers + - json-schema-ref-no-deref + +not_scored: + - scenario: auth/client-credentials-jwt + leg: client + reason: extension + - scenario: auth/client-credentials-basic + leg: client + reason: extension + - scenario: auth/enterprise-managed-authorization + leg: client + reason: extension + - scenario: auth/dpop + leg: client + reason: extension + - scenario: auth/dpop-nonce + leg: client + reason: extension + - scenario: auth/wif-jwt-bearer + leg: client + reason: extension + - scenario: json-schema-2020-12-preservation + leg: client + reason: added-after-release + - scenario: tasks-lifecycle + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-capability-negotiation + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-wire-fields + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-request-state-removal + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-mrtr-input + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-request-headers + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-dispatch-and-envelope + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-status-notifications + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-required-task-error + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: tasks-mrtr-composition + leg: server + reason: extension + note: >- + io.modelcontextprotocol/tasks (SEP-2663); pending against the reference fixture, run for visibility + - scenario: json-schema-2020-12 + leg: server + reason: pending + note: >- + the reference fixture cannot pass it yet; the implementation under test might + - scenario: http-header-validation + leg: server + reason: pending + note: >- + SEP-2243; pending against the reference fixture + - scenario: http-custom-header-server-validation + leg: server + reason: pending + note: >- + SEP-2243; pending against the reference fixture diff --git a/scripts/sync-schema.ts b/scripts/sync-schema.ts new file mode 100644 index 0000000..a5d41a0 --- /dev/null +++ b/scripts/sync-schema.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env -S npx tsx +/** + * Vendor schema/{version}/schema.ts and schema/{version}/schema.json from the + * modelcontextprotocol spec repo into src/spec-types/{version}.ts and + * src/spec-types/{version}.schema.json at a pinned SHA. + * + * Usage: npm run sync-schema -- + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync, copyFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const VERSIONS = ['2025-03-26', '2025-06-18', '2025-11-25', 'draft'] as const; +const SPEC_REPO = + 'https://github.com/modelcontextprotocol/modelcontextprotocol.git'; +const OUT_DIR = join(process.cwd(), 'src', 'spec-types'); + +const ref = process.argv[2]; +if (!ref) { + console.error('Usage: npm run sync-schema -- '); + process.exit(1); +} + +const tmp = join(process.cwd(), '.sync-schema-tmp'); +rmSync(tmp, { recursive: true, force: true }); +mkdirSync(tmp, { recursive: true }); +mkdirSync(OUT_DIR, { recursive: true }); + +const git = (args: string[]) => + execFileSync('git', args, { cwd: tmp, encoding: 'utf8' }); + +try { + console.log(`Fetching ${SPEC_REPO} @ ${ref} ...`); + git(['init', '-q']); + git(['remote', 'add', 'origin', SPEC_REPO]); + git(['fetch', '-q', '--depth', '1', 'origin', ref]); + git(['checkout', '-q', 'FETCH_HEAD']); + const sha = git(['rev-parse', 'HEAD']).trim(); + + for (const v of VERSIONS) { + copyFileSync(join(tmp, 'schema', v, 'schema.ts'), join(OUT_DIR, `${v}.ts`)); + console.log(` ${v} -> src/spec-types/${v}.ts`); + copyFileSync( + join(tmp, 'schema', v, 'schema.json'), + join(OUT_DIR, `${v}.schema.json`) + ); + console.log(` ${v} -> src/spec-types/${v}.schema.json`); + } + + writeFileSync( + join(OUT_DIR, 'SOURCE'), + `modelcontextprotocol@${sha}\n`, + 'utf8' + ); + console.log(`Pinned: modelcontextprotocol@${sha}`); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/src/checks/checks.test.ts b/src/checks/checks.test.ts index a08ba08..b82f17c 100644 --- a/src/checks/checks.test.ts +++ b/src/checks/checks.test.ts @@ -1,4 +1,5 @@ import { createClientInitializationCheck } from './client'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; describe('createClientInitializationCheck', () => { it('should return SUCCESS for a valid initialize request', () => { @@ -68,6 +69,31 @@ describe('createClientInitializationCheck', () => { expect(check.errorMessage).toContain('Client version missing'); }); + it('should accept the current draft protocol version', () => { + const request = { + protocolVersion: DRAFT_PROTOCOL_VERSION, + clientInfo: { name: 'TestClient', version: '1.0.0' } + }; + + const check = createClientInitializationCheck(request); + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + }); + + it.each(['DRAFT-2025-v1', 'draft'])( + 'should reject stale or non-canonical draft version %s', + (protocolVersion) => { + const request = { + protocolVersion, + clientInfo: { name: 'TestClient', version: '1.0.0' } + }; + + const check = createClientInitializationCheck(request); + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Version mismatch'); + } + ); + it('should support custom expected spec version', () => { const request = { protocolVersion: '2024-11-05', diff --git a/src/checks/client.ts b/src/checks/client.ts index 99834f9..89b1106 100644 --- a/src/checks/client.ts +++ b/src/checks/client.ts @@ -1,4 +1,9 @@ -import { ConformanceCheck, CheckStatus } from '../types'; +import { + ConformanceCheck, + CheckStatus, + LATEST_SPEC_VERSION, + NEGOTIABLE_PROTOCOL_VERSIONS +} from '../types'; export function createServerInfoCheck(serverInfo: { name: string; @@ -23,19 +28,18 @@ export function createServerInfoCheck(serverInfo: { }; } -// Valid MCP protocol versions -const VALID_PROTOCOL_VERSIONS = ['2025-06-18', '2025-11-25']; - export function createClientInitializationCheck( initializeRequest: any, - expectedSpecVersion: string = '2025-11-25' + expectedSpecVersion: string = LATEST_SPEC_VERSION ): ConformanceCheck { const protocolVersionSent = initializeRequest?.protocolVersion; // Accept known valid versions OR custom expected version (for backward compatibility) - const validVersions = VALID_PROTOCOL_VERSIONS.includes(expectedSpecVersion) - ? VALID_PROTOCOL_VERSIONS - : [...VALID_PROTOCOL_VERSIONS, expectedSpecVersion]; + const validVersions = NEGOTIABLE_PROTOCOL_VERSIONS.includes( + expectedSpecVersion + ) + ? NEGOTIABLE_PROTOCOL_VERSIONS + : [...NEGOTIABLE_PROTOCOL_VERSIONS, expectedSpecVersion]; const versionMatch = validVersions.includes(protocolVersionSent); const errors: string[] = []; diff --git a/src/checks/collapse.test.ts b/src/checks/collapse.test.ts new file mode 100644 index 0000000..d590d46 --- /dev/null +++ b/src/checks/collapse.test.ts @@ -0,0 +1,85 @@ +import { collapseDuplicateChecks } from './collapse'; +import type { ConformanceCheck, CheckStatus } from '../types'; + +/** Minimal check factory for the dedupe unit tests. */ +function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck { + return { + id, + name: id, + description: tag ?? id, + status, + timestamp: '2026-07-06T00:00:00.000Z', + specReferences: [] + }; +} + +describe('collapseDuplicateChecks', () => { + it('collapses duplicate SUCCESS ids to a single entry', () => { + const out = collapseDuplicateChecks([ + chk('token-request', 'SUCCESS'), + chk('pkce-code-verifier-sent', 'SUCCESS'), + chk('token-request', 'SUCCESS') + ]); + expect(out.map((c) => c.id)).toEqual([ + 'pkce-code-verifier-sent', + 'token-request' + ]); + }); + + it('never masks a FAILURE — keeps the failing occurrence over a later SUCCESS', () => { + // The regression this guards: a client that fails a check on the challenged + // POST but passes on the nonce retry must still be reported FAILURE. + const out = collapseDuplicateChecks([ + chk('pkce-verifier-matches-challenge', 'FAILURE', 'first attempt'), + chk('pkce-verifier-matches-challenge', 'SUCCESS', 'retry') + ]); + expect(out).toHaveLength(1); + expect(out[0].status).toBe('FAILURE'); + expect(out[0].description).toBe('first attempt'); + }); + + it('keeps the failing occurrence regardless of order (SUCCESS then FAILURE)', () => { + const out = collapseDuplicateChecks([ + chk('token-request', 'SUCCESS'), + chk('token-request', 'FAILURE', 'later failure') + ]); + expect(out).toHaveLength(1); + expect(out[0].status).toBe('FAILURE'); + expect(out[0].description).toBe('later failure'); + }); + + it('prefers WARNING over SUCCESS but FAILURE over WARNING', () => { + expect( + collapseDuplicateChecks([chk('x', 'SUCCESS'), chk('x', 'WARNING')])[0] + .status + ).toBe('WARNING'); + expect( + collapseDuplicateChecks([chk('x', 'WARNING'), chk('x', 'FAILURE')])[0] + .status + ).toBe('FAILURE'); + }); + + it('keeps every INFO log entry, even duplicates', () => { + const out = collapseDuplicateChecks([ + chk('incoming-request', 'INFO'), + chk('incoming-request', 'INFO'), + chk('incoming-request', 'INFO') + ]); + expect(out).toHaveLength(3); + }); + + it('preserves order and leaves a duplicate-free list untouched', () => { + const input = [ + chk('a', 'SUCCESS'), + chk('b', 'FAILURE'), + chk('incoming', 'INFO'), + chk('c', 'SUCCESS') + ]; + expect(collapseDuplicateChecks(input).map((c) => c.id)).toEqual([ + 'a', + 'b', + 'incoming', + 'c' + ]); + }); +}); diff --git a/src/checks/collapse.ts b/src/checks/collapse.ts new file mode 100644 index 0000000..f308cc7 --- /dev/null +++ b/src/checks/collapse.ts @@ -0,0 +1,35 @@ +import type { CheckStatus, ConformanceCheck } from '../types'; + +/** + * Collapse duplicate non-INFO check IDs to a single entry, preferring the + * MOST-SEVERE occurrence (FAILURE > WARNING > SUCCESS > any other status, e.g. + * SKIPPED) so a real failure is never masked. Equal-severity ties keep the LAST + * occurrence. Per-request INFO log entries are always kept. + * + * A check ID is not unique within a run: scenarios re-emit a shared ID when a + * flow repeats. The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge + * → retry), so the shared token-flow checks (`token-request`, `pkce-*`) are + * appended twice; `sep-2575-http-server-meta-invalid-400` is emitted once per + * iteration of the `_meta` test-case loop. Collapsing reports each ID once + * without hiding a failure recorded on any occurrence, which is what lets an + * expected-failures baseline address a check by ID. + */ +export function collapseDuplicateChecks( + checks: ConformanceCheck[] +): ConformanceCheck[] { + const severity = (s: CheckStatus): number => + s === 'FAILURE' ? 3 : s === 'WARNING' ? 2 : s === 'SUCCESS' ? 1 : 0; + // Winning index per non-INFO id: highest severity, ties → last occurrence. + const winner = new Map(); + checks.forEach((c, i) => { + if (c.status === 'INFO') return; + const cur = winner.get(c.id); + if ( + cur === undefined || + severity(c.status) >= severity(checks[cur].status) + ) { + winner.set(c.id, i); + } + }); + return checks.filter((c, i) => c.status === 'INFO' || winner.get(c.id) === i); +} diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts new file mode 100644 index 0000000..fa3a17d --- /dev/null +++ b/src/connection/connection.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isStatefulVersion, STATELESS_SPEC_VERSIONS } from './select'; +import { connectStateless } from './stateless'; +import { JsonRpcError } from './index'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; +import { takeWireViolations } from '../validation/wire-schema'; + +describe('STATELESS_SPEC_VERSIONS', () => { + it('contains exactly the versions isStatefulVersion rejects', () => { + expect(STATELESS_SPEC_VERSIONS.length).toBeGreaterThan(0); + for (const v of STATELESS_SPEC_VERSIONS) { + expect(isStatefulVersion(v)).toBe(false); + } + }); + it('currently contains only the draft version', () => { + expect(STATELESS_SPEC_VERSIONS).toEqual([DRAFT_PROTOCOL_VERSION]); + }); +}); + +describe('connectStateless', () => { + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + afterEach(() => mockFetch.mockReset()); + + function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); + } + + function sseResponse(events: string[]) { + return new Response(events.join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }); + } + + it('injects required _meta keys and MCP-Protocol-Version header', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + jsonrpc: '2.0', + id: 1, + result: { + tools: [], + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + } + }) + ); + const conn = await connectStateless('http://test/mcp'); + await conn.request('tools/list'); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers['MCP-Protocol-Version']).toBe('2026-07-28'); + const sent = JSON.parse(init.body); + expect(sent.params._meta['io.modelcontextprotocol/protocolVersion']).toBe( + '2026-07-28' + ); + expect( + sent.params._meta['io.modelcontextprotocol/clientInfo'] + ).toBeDefined(); + expect( + sent.params._meta['io.modelcontextprotocol/clientCapabilities'] + ).toBeDefined(); + }); + + it('throws JsonRpcError on JSON-RPC error responses', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + jsonrpc: '2.0', + id: 1, + error: { code: -32601, message: 'Method not found' } + }) + ); + const conn = await connectStateless('http://test/mcp'); + await expect(conn.request('nope')).rejects.toSatisfy( + (e) => e instanceof JsonRpcError && e.code === -32601 + ); + }); + + it('throws on non-2xx JSON without a JSON-RPC error envelope', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ detail: 'gateway rejected' }, 502) + ); + const conn = await connectStateless('http://test/mcp'); + await expect(conn.request('tools/list')).rejects.toThrow(/HTTP 502/); + // The gateway body is deliberately not a JSON-RPC message. + expect(takeWireViolations().violations).toHaveLength(1); + }); + + it('throws a useful error for non-JSON non-SSE responses', async () => { + mockFetch.mockResolvedValue( + new Response('500', { + status: 500, + headers: { 'content-type': 'text/html' } + }) + ); + const conn = await connectStateless('http://test/mcp'); + await expect(conn.request('tools/list')).rejects.toThrow(/HTTP 500/); + }); + + it('parses SSE: collects notifications and returns final result (LF)', async () => { + mockFetch.mockResolvedValue( + sseResponse([ + 'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t1","progress":1}}\n\n', + 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[],"resultType":"complete","done":true}}\n\n' + ]) + ); + const conn = await connectStateless('http://test/mcp'); + const result = await conn.request<{ done: boolean }>('tools/call', { + name: 'echo', + arguments: {} + }); + expect(result.done).toBe(true); + expect(conn.notifications).toHaveLength(1); + expect(conn.notifications[0].method).toBe('notifications/progress'); + }); + + it('parses SSE with CRLF line endings', async () => { + mockFetch.mockResolvedValue( + sseResponse([ + 'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[],"resultType":"complete","ok":true}}\r\n\r\n' + ]) + ); + const conn = await connectStateless('http://test/mcp'); + const result = await conn.request<{ ok: boolean }>('tools/call', { + name: 'echo', + arguments: {} + }); + expect(result.ok).toBe(true); + }); + + it('rejects server-to-client requests on the SSE stream', async () => { + mockFetch.mockResolvedValue( + sseResponse([ + 'event: message\ndata: {"jsonrpc":"2.0","id":99,"method":"elicitation/create","params":{"message":"Name?","requestedSchema":{"type":"object","properties":{}}}}\n\n' + ]) + ); + const conn = await connectStateless('http://test/mcp'); + await expect( + conn.request('tools/call', { name: 'echo', arguments: {} }) + ).rejects.toThrow(/MRTR/); + }); +}); diff --git a/src/connection/index.ts b/src/connection/index.ts new file mode 100644 index 0000000..47952c6 --- /dev/null +++ b/src/connection/index.ts @@ -0,0 +1,118 @@ +/** + * Version-aware connection abstraction for server-conformance scenarios. + * + * A `Connection` knows how to send JSON-RPC requests to the server-under-test + * using the lifecycle appropriate for the spec version being tested: + * + * - 2025-x: stateful (initialize handshake, Mcp-Session-Id header) + * - 2026-x: stateless (no handshake, per-request _meta + MCP-Protocol-Version) + * + * Scenarios call `ctx.connect()` and then `conn.request(method, params)`; the + * runner picks the implementation based on `--spec-version`. Scenario code is + * the same regardless of which lifecycle is in use. + */ + +import type { SpecVersion } from '../types'; +import type { JSONRPCNotification } from '../spec-types/2025-11-25'; + +/** + * Options accepted at session bootstrap. On the stateful (2025-x) wire + * these flow into the `initialize` request params; on the stateless + * (2026-x) wire they live in `_meta.io.modelcontextprotocol/*` on the + * `server/discover` request. + */ +export interface ConnectOptions { + /** + * Capabilities declared during session bootstrap (e.g. + * `{ extensions: { 'io.modelcontextprotocol/tasks': {} }, elicitation: {} }`). + */ + capabilities?: Record; + /** Client info advertised at bootstrap; defaults to the harness's own info. */ + clientInfo?: { name: string; version: string }; +} + +export interface Connection { + /** + * Send a JSON-RPC request and return its result. + * Throws `JsonRpcError` on JSON-RPC error responses. + * + * `extraHeaders` extend or override the standard headers + * (Content-Type, Accept, MCP-Protocol-Version, Mcp-Method, Mcp-Name) + * for this call only, used by SEP-2243 routing-header tests that + * inject a mismatch. Honored on the stateless wire; throws on the + * stateful wire (the SDK transport manages headers internally, so + * a per-call override would require dropping to raw fetch — silently + * dropping the header in a conformance harness would mask test + * correctness). + */ + request( + method: string, + params?: Record, + extraHeaders?: Record + ): Promise; + + /** + * All notifications received over this connection's lifetime, in arrival + * order. For the stateful impl this includes notifications from the + * standalone GET stream; for stateless it's only those on POST-response + * streams. + */ + readonly notifications: JSONRPCNotification[]; + + /** + * Return the server's advertised capabilities, serverInfo, and + * instructions. On the stateful wire this is synthesized from the + * SDK Client's post-`initialize` accessors and resolves immediately; + * on the stateless wire this issues `server/discover` (SEP-2575's + * equivalent of the missing handshake) on first call and memoizes + * the result. + * + * Scenarios that don't inspect server-side state never call this — + * SEP-2575 has no required handshake, so paying for the extra request + * is opt-in. + */ + discover(): Promise>; + + close(): Promise; +} + +/** + * Per-run context handed to `ClientScenario.run()`. The runner constructs this + * from the resolved `--spec-version` and server URL. + */ +export interface RunContext { + serverUrl: string; + specVersion: SpecVersion; + /** + * Open a version-appropriate connection to the server-under-test. + * Scenarios that test the connection mechanics themselves (initialize, + * GET-SSE, DNS rebinding) bypass this and use raw fetch. + */ + connect(opts?: ConnectOptions): Promise; +} + +export class JsonRpcError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown + ) { + super(message); + this.name = 'JsonRpcError'; + } +} + +export { connectStateful } from './stateful'; +export { + connectStateless, + sendStatelessRequest, + buildStandardHeaders, + withRequestMeta, + mcpNameForRequest, + readSseJsonRpcResponse, + CONFORMANCE_CLIENT_INFO, + DEFAULT_CLIENT_CAPABILITIES, + type JsonRpcResponse, + type StatelessResponse +} from './stateless'; +export { connectFor, isStateless } from './select'; diff --git a/src/connection/sdk-client.test.ts b/src/connection/sdk-client.test.ts new file mode 100644 index 0000000..57cf7c4 --- /dev/null +++ b/src/connection/sdk-client.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import express from 'express'; +import type { Server as HttpServer } from 'http'; +import { connectToServer } from './sdk-client'; + +// Regression guard for the session-termination behavior of close(): a bad +// merge of connectToServer must not silently drop the DELETE (issue #79). +describe('connectToServer close()', () => { + let httpServer: HttpServer | undefined; + + afterEach( + () => + new Promise((resolve) => { + if (!httpServer) return resolve(); + httpServer.closeAllConnections?.(); + httpServer.close(() => resolve()); + httpServer = undefined; + }) + ); + + function startServer(sessionId?: string): Promise<{ + url: string; + deletes: Array; + }> { + const deletes: Array = []; + const app = express(); + app.use(express.json()); + app.post('/mcp', (req, res) => { + if (req.body?.method === 'initialize') { + if (sessionId) res.set('mcp-session-id', sessionId); + res.json({ + jsonrpc: '2.0', + id: req.body.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'delete-probe', version: '1.0.0' } + } + }); + } else { + res.status(202).end(); + } + }); + app.delete('/mcp', (req, res) => { + deletes.push(req.headers['mcp-session-id'] as string | undefined); + res.status(200).end(); + }); + return new Promise((resolve, reject) => { + const server = app.listen(0); + httpServer = server; + server.on('error', reject); + server.on('listening', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ url: `http://localhost:${port}/mcp`, deletes }); + }); + }); + } + + it('sends an HTTP DELETE with the session ID to terminate the session', async () => { + const { url, deletes } = await startServer('session-abc-123'); + + const connection = await connectToServer(url, {}, '2025-11-25'); + await connection.close(); + + expect(deletes).toEqual(['session-abc-123']); + }); + + it('does not send DELETE when the server assigned no session ID', async () => { + const { url, deletes } = await startServer(); + + const connection = await connectToServer(url, {}, '2025-11-25'); + await connection.close(); + + expect(deletes).toEqual([]); + }); +}); diff --git a/src/connection/sdk-client.ts b/src/connection/sdk-client.ts new file mode 100644 index 0000000..0d7575d --- /dev/null +++ b/src/connection/sdk-client.ts @@ -0,0 +1,246 @@ +/** + * Helper utilities for creating MCP clients to test servers + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + LoggingMessageNotificationSchema, + ProgressNotificationSchema, + type JSONRPCMessage, + type MessageExtraInfo +} from '@modelcontextprotocol/sdk/types.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; + +import { type SpecVersion } from '../types'; +import { + validateWireMessage, + type WireOrigin +} from '../validation/wire-schema'; +import type { ConnectOptions } from './index'; + +const DEFAULT_CLIENT_INFO = { + name: 'conformance-test-client', + version: '1.0.0' +} as const; + +const DEFAULT_CAPABILITIES = { + sampling: {}, + elicitation: {} +} as const; + +export interface MCPClientConnection { + client: Client; + close: () => Promise; +} + +/** Hook an SDK transport so every raw wire message, both directions (handshake included, + * no bypass), is schema-validated. Outbound = harness origin, inbound = implementation; + * request ids are tracked per direction to validate responses against typed results. */ +function instrumentTransport( + transport: Transport, + specVersion: SpecVersion +): void { + // Request id → method, per direction. An entry is consumed by the response + // travelling the opposite way, so the maps stay bounded. + const harnessRequests = new Map(); + const implementationRequests = new Map(); + + const validate = (message: unknown, origin: WireOrigin): void => { + const ownRequests = + origin === 'harness' ? harnessRequests : implementationRequests; + const peerRequests = + origin === 'harness' ? implementationRequests : harnessRequests; + // `send` accepts arrays (2025-03-26 batches); walk elements for request/ + // response bookkeeping, validating singles here and batches whole below. + for (const m of Array.isArray(message) ? message : [message]) { + const msg = (typeof m === 'object' && m !== null ? m : {}) as Record< + string, + unknown + >; + const id = msg.id as string | number | undefined; + let context: string; + let requestMethod: string | undefined; + if (typeof msg.method === 'string') { + if (id !== undefined) { + ownRequests.set(id, msg.method); + context = `stateful request '${msg.method}'`; + } else { + context = `stateful notification '${msg.method}'`; + } + } else { + requestMethod = id !== undefined ? peerRequests.get(id) : undefined; + if (id !== undefined) peerRequests.delete(id); + context = `stateful response to '${requestMethod ?? `id ${String(id)}`}'`; + } + if (!Array.isArray(message)) { + validateWireMessage(specVersion, m, { origin, context, requestMethod }); + } + } + if (Array.isArray(message)) { + // One whole-array validation covers per-element errors (prefixed [i]) + // and batch legality for the version; validating the elements in the + // loop above too would record every violation twice. + validateWireMessage(specVersion, message, { + origin, + context: 'stateful batch' + }); + } + }; + + const originalSend = transport.send.bind(transport); + transport.send = (message, options) => { + validate(message, 'harness'); + return originalSend(message, options); + }; + + // The SDK's protocol layer assigns `onmessage` during `client.connect()`; + // intercept the assignment so the validator sees every inbound message + // before the handler does. + let inner: + | ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) + | undefined; + const wrapped = (message: JSONRPCMessage, extra?: MessageExtraInfo): void => { + validate(message, 'implementation'); + inner?.(message, extra); + }; + Object.defineProperty(transport, 'onmessage', { + configurable: true, + enumerable: true, + get: () => (inner === undefined ? undefined : wrapped), + set: (handler: typeof inner) => { + inner = handler; + } + }); +} + +/** + * Create and connect an MCP client to a server. `opts.capabilities` and + * `opts.clientInfo` override the harness defaults — scenarios that + * negotiate extensions (tasks, EMA, ...) pass them through to drive a + * conformant `initialize`. The transport is instrumented so every wire + * message in both directions is validated against `specVersion`'s spec + * JSON schema; scenarios that call this directly pass `ctx.specVersion`. + * + * The returned `close()` sends an HTTP DELETE to terminate the server-side + * session (per the Streamable HTTP transport spec) before closing the client, + * so each scenario leaves the server hermetic. A server that responds 405 + * (DELETE not supported) is tolerated, since the spec allows that. + */ +export async function connectToServer( + serverUrl: string, + opts: ConnectOptions, + specVersion: SpecVersion +): Promise { + const client = new Client(opts.clientInfo ?? DEFAULT_CLIENT_INFO, { + capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + instrumentTransport(transport, specVersion); + + await client.connect(transport); + + return { + client, + close: async () => { + await terminateSessionBestEffort(transport, serverUrl); + await client.close(); + } + }; +} + +// Shared teardown bound so a wedged server-under-test cannot stall the suite. +const SESSION_TERMINATE_TIMEOUT_MS = 5000; + +/** + * Best-effort session termination for an SDK transport. Delegates to + * terminateSessionRaw (with the transport's negotiated protocol version) so + * the DELETE is truly bounded — the socket is aborted at the timeout rather + * than left pending against a wedged server. No-op when the server never + * issued a session. + */ +export async function terminateSessionBestEffort( + transport: StreamableHTTPClientTransport, + serverUrl: string +): Promise { + if (!transport.sessionId || !transport.protocolVersion) return; + await terminateSessionRaw( + serverUrl, + transport.sessionId, + transport.protocolVersion + ); +} + +/** + * Best-effort HTTP DELETE to terminate a raw (non-SDK) session. + * + * Used by scenarios that open sessions via raw `fetch` instead of going through + * the SDK's StreamableHTTPClientTransport. Failures are swallowed because the + * spec allows servers to respond 405, and cleanup must not derail the scenario. + */ +export async function terminateSessionRaw( + serverUrl: string, + sessionId: string, + protocolVersion: string +): Promise { + try { + await fetch(serverUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'MCP-Protocol-Version': protocolVersion + }, + signal: AbortSignal.timeout(SESSION_TERMINATE_TIMEOUT_MS) + }); + } catch { + // best-effort cleanup + } +} + +/** + * Helper to collect notifications (logging and progress) + */ +export class NotificationCollector { + private loggingNotifications: any[] = []; + private progressNotifications: any[] = []; + + constructor(client: Client) { + // Set up notification handler for logging messages + client.setNotificationHandler( + LoggingMessageNotificationSchema, + (notification) => { + this.loggingNotifications.push(notification); + } + ); + + // Set up notification handler for progress notifications + client.setNotificationHandler( + ProgressNotificationSchema, + (notification) => { + this.progressNotifications.push(notification); + } + ); + } + + /** + * Get all collected logging notifications + */ + getLoggingNotifications(): any[] { + return this.loggingNotifications; + } + + /** + * Get all collected progress notifications + */ + getProgressNotifications(): any[] { + return this.progressNotifications; + } + + /** + * Get all notifications (for backward compatibility) + */ + getNotifications(): any[] { + return this.loggingNotifications; + } +} diff --git a/src/connection/select.ts b/src/connection/select.ts new file mode 100644 index 0000000..4667900 --- /dev/null +++ b/src/connection/select.ts @@ -0,0 +1,63 @@ +import { + DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION, + type SpecVersion +} from '../types'; +import type { Connection, ConnectOptions, RunContext } from './index'; +import { connectStateful } from './stateful'; +import { connectStateless } from './stateless'; + +/** + * Spec versions that use the stateful lifecycle (initialize handshake, + * Mcp-Session-Id). Anything not in this list uses the stateless lifecycle + * — SEP-2575 (Accepted) removed the initialize handshake on 2026-07-28 + * and later. + */ +const STATEFUL_VERSIONS: ReadonlySet = new Set([ + '2024-11-05', + '2025-03-26', + '2025-06-18', + '2025-11-25' +]); + +/** Every spec version the suite can target, in timeline order. */ +const ALL_SPEC_VERSIONS: readonly SpecVersion[] = [ + ...DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION +]; + +export function isStatefulVersion(v: SpecVersion): boolean { + return STATEFUL_VERSIONS.has(v); +} + +/** + * Spec versions that use the stateless lifecycle, derived from + * {@link isStatefulVersion} so there is a single source of truth for the + * version→lifecycle mapping. The list grows automatically when the draft is + * dated (added to `DATED_SPEC_VERSIONS` without joining `STATEFUL_VERSIONS`) + * or a second stateless version appears. + */ +export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] = + ALL_SPEC_VERSIONS.filter((v) => !isStatefulVersion(v)); + +export function connectFor( + specVersion: SpecVersion +): (serverUrl: string, opts?: ConnectOptions) => Promise { + // Pass the version through so requests declare (and are wire-schema + // validated against) the spec version the run was invoked with. + return isStatefulVersion(specVersion) + ? (serverUrl, opts) => connectStateful(serverUrl, opts ?? {}, specVersion) + : (serverUrl, opts) => connectStateless(serverUrl, specVersion, opts); +} + +/** + * True when the spec version on the context requires the SEP-2575 + * stateless wire (no initialize handshake; per-request `_meta` envelope). + * + * Mirrors `connectFor` so scenarios that drive the wire directly (not via + * the SDK-wrapped Connection) pick the wire the same way the connection + * factory does. + */ +export function isStateless(ctx: Pick): boolean { + return !isStatefulVersion(ctx.specVersion); +} diff --git a/src/connection/stateful.ts b/src/connection/stateful.ts new file mode 100644 index 0000000..3361f28 --- /dev/null +++ b/src/connection/stateful.ts @@ -0,0 +1,101 @@ +/** + * Stateful connection: 2025-x lifecycle (initialize handshake, session id). + * + * Backed by the SDK's `Client` so we don't reimplement the handshake, session + * header, or SSE response parsing. The SDK is the driver here, not the + * system-under-test; its own correctness is covered by the client-conformance + * scenarios. + */ + +import { + ResultSchema, + McpError, + ProgressNotificationSchema, + LoggingMessageNotificationSchema +} from '@modelcontextprotocol/sdk/types.js'; +import { connectToServer } from './sdk-client'; +import type { JSONRPCNotification } from '../spec-types/2025-11-25'; +import { type SpecVersion } from '../types'; +import { JsonRpcError, type Connection, type ConnectOptions } from './index'; + +export async function connectStateful( + serverUrl: string, + opts: ConnectOptions, + specVersion: SpecVersion +): Promise { + // Wire-schema validation happens inside connectToServer, which hooks the + // SDK transport's send/onmessage so the real bytes of every message in + // both directions are validated — no per-call choke points needed here. + const { client, close } = await connectToServer(serverUrl, opts, specVersion); + + const notifications: JSONRPCNotification[] = []; + const collect = (n: unknown) => { + // The SDK's Zod parsing strips the jsonrpc field; restore it so collected + // notifications match the wire shape connectStateless provides (the raw + // notification was already validated by the transport hook). + const notification = { + jsonrpc: '2.0', + ...(n as object) + } as JSONRPCNotification; + notifications.push(notification); + }; + // The SDK pre-registers a handler for notifications/progress (to drive the + // onprogress callback feature), so it never reaches the fallback. Register + // explicit collectors for the schemas the SDK claims, then a fallback for + // everything else. + client.setNotificationHandler(ProgressNotificationSchema, async (n) => + collect(n) + ); + client.setNotificationHandler(LoggingMessageNotificationSchema, async (n) => + collect(n) + ); + client.fallbackNotificationHandler = async (n) => collect(n); + + return { + notifications, + + // Synthesize the discover-shape from the SDK Client's post-`initialize` + // accessors so the stateful Connection exposes the same surface the + // stateless wire's `server/discover` produces. + async discover(): Promise> { + return { + capabilities: client.getServerCapabilities() ?? {}, + serverInfo: client.getServerVersion() ?? {}, + instructions: client.getInstructions() + }; + }, + + async request( + method: string, + params: Record = {}, + extraHeaders?: Record + ): Promise { + if (extraHeaders && Object.keys(extraHeaders).length > 0) { + // The SDK Client transport manages headers internally; per-call + // override would require dropping to raw fetch. No 2025-x + // scenario needs this today; flag loudly if one shows up. + throw new Error( + 'connectStateful.request: extraHeaders is unsupported on the stateful wire (per-call header overrides require raw fetch on the stateless wire only)' + ); + } + try { + const result = await client.request({ method, params }, ResultSchema); + return result as R; + } catch (e) { + // Normalize so scenarios always see JsonRpcError regardless of impl. + // The SDK prefixes messages with "MCP error : "; strip it so + // the message matches what connectStateless surfaces. + if (e instanceof McpError) { + throw new JsonRpcError( + e.code, + e.message.replace(/^MCP error -?\d+: /, ''), + e.data + ); + } + throw e; + } + }, + + close + }; +} diff --git a/src/connection/stateless.test.ts b/src/connection/stateless.test.ts new file mode 100644 index 0000000..7943696 --- /dev/null +++ b/src/connection/stateless.test.ts @@ -0,0 +1,171 @@ +/** + * Unit tests for the shared stateless request helper (SEP-2575 + SEP-2243): + * standard header defaults/overrides, `_meta` injection, and JSON parsing. + */ +import http from 'http'; +import { describe, test, expect } from 'vitest'; +import { + buildStandardHeaders, + withRequestMeta, + sendStatelessRequest, + connectStateless, + CONFORMANCE_CLIENT_INFO, + DEFAULT_CLIENT_CAPABILITIES +} from './stateless'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; +import { takeWireViolations } from '../validation/wire-schema'; + +describe('buildStandardHeaders', () => { + test('sets the standard headers pinned to the draft protocol version', () => { + const headers = buildStandardHeaders('tools/list'); + expect(headers['Mcp-Method']).toBe('tools/list'); + expect(headers['MCP-Protocol-Version']).toBe(DRAFT_PROTOCOL_VERSION); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Accept).toContain('application/json'); + expect(headers.Accept).toContain('text/event-stream'); + expect(headers['Mcp-Name']).toBeUndefined(); + }); + + test('sets Mcp-Name from params.name (tools/call) and params.uri (resources/read)', () => { + expect( + buildStandardHeaders('tools/call', { name: 'echo' })['Mcp-Name'] + ).toBe('echo'); + expect( + buildStandardHeaders('resources/read', { uri: 'file:///a.txt' })[ + 'Mcp-Name' + ] + ).toBe('file:///a.txt'); + }); + + test('overrides replace defaults case-insensitively', () => { + const headers = buildStandardHeaders('tools/list', undefined, { + headers: { 'mcp-protocol-version': '2025-06-18' } + }); + expect(headers['MCP-Protocol-Version']).toBeUndefined(); + expect(headers['mcp-protocol-version']).toBe('2025-06-18'); + }); +}); + +describe('withRequestMeta', () => { + test('injects the required _meta fields', () => { + const params = withRequestMeta({ name: 'echo' }); + const meta = params._meta as Record; + expect(meta['io.modelcontextprotocol/protocolVersion']).toBe( + DRAFT_PROTOCOL_VERSION + ); + expect(meta['io.modelcontextprotocol/clientInfo']).toEqual( + CONFORMANCE_CLIENT_INFO + ); + expect(meta['io.modelcontextprotocol/clientCapabilities']).toEqual( + DEFAULT_CLIENT_CAPABILITIES + ); + expect(params.name).toBe('echo'); + }); + + test('keys already present in params._meta win over the defaults', () => { + const params = withRequestMeta({ + _meta: { 'io.modelcontextprotocol/protocolVersion': '2025-06-18' } + }); + const meta = params._meta as Record; + expect(meta['io.modelcontextprotocol/protocolVersion']).toBe('2025-06-18'); + expect(meta['io.modelcontextprotocol/clientInfo']).toEqual( + CONFORMANCE_CLIENT_INFO + ); + }); +}); + +describe('sendStatelessRequest', () => { + test('parses a plain JSON response', async () => { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const request = JSON.parse(body); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + tools: [], + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + } + }) + ); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as { port: number }).port; + try { + const response = await sendStatelessRequest( + `http://localhost:${port}/`, + 'tools/list' + ); + expect(response.status).toBe(200); + expect(response.body?.result).toEqual({ + tools: [], + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); + +describe('spec version plumbing', () => { + test('buildStandardHeaders sends the requested spec version', () => { + const headers = buildStandardHeaders('tools/list', undefined, { + specVersion: '2025-11-25' + }); + expect(headers['MCP-Protocol-Version']).toBe('2025-11-25'); + }); + + test('buildStandardHeaders defaults to the draft version', () => { + const headers = buildStandardHeaders('tools/list'); + expect(headers['MCP-Protocol-Version']).toBe(DRAFT_PROTOCOL_VERSION); + }); + + test('withRequestMeta declares the requested spec version in _meta', () => { + const params = withRequestMeta({}, '2025-11-25'); + const meta = params._meta as Record; + expect(meta['io.modelcontextprotocol/protocolVersion']).toBe('2025-11-25'); + }); + + test('withRequestMeta defaults to the draft version', () => { + const params = withRequestMeta({}); + const meta = params._meta as Record; + expect(meta['io.modelcontextprotocol/protocolVersion']).toBe( + DRAFT_PROTOCOL_VERSION + ); + }); +}); + +describe('connectStateless', () => { + test('surfaces HTTP status and body when the error field is not JSON-RPC shaped', async () => { + const server = http.createServer((req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream timeout' })); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const port = (server.address() as { port: number }).port; + try { + const conn = await connectStateless(`http://localhost:${port}/`); + await expect(conn.request('tools/list')).rejects.toThrow( + /HTTP 502.*upstream timeout/ + ); + // The proxy body is deliberately not a JSON-RPC message. + expect(takeWireViolations().violations).toHaveLength(1); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/src/connection/stateless.ts b/src/connection/stateless.ts new file mode 100644 index 0000000..38890ac --- /dev/null +++ b/src/connection/stateless.ts @@ -0,0 +1,467 @@ +/** + * Stateless connection: 2026-x lifecycle (SEP-2575). + * + * No handshake. Every request carries `_meta` with protocolVersion, clientInfo, + * and clientCapabilities, plus the standard headers (MCP-Protocol-Version, + * Mcp-Method, Mcp-Name per SEP-2243). Implemented with raw fetch so the + * conformance suite can test draft spec versions before the SDK supports them. + * + * Exports two layers: + * - `sendStatelessRequest()` — low-level: returns `{status, headers, body, + * events}` and never throws on JSON-RPC errors. Scenarios that assert on + * HTTP status or error codes use this directly. + * - `connectStateless()` — high-level: a `Connection` whose `request()` calls + * `sendStatelessRequest()` and throws `JsonRpcError` on error responses. + * The runner picks this via `connectFor()` for `--spec-version draft`. + * + * Both build their requests through `buildStandardHeaders()` and + * `withRequestMeta()` so a strictly-conformant server never rejects harness + * traffic for reasons unrelated to the behaviour under test + * (issues #311, #312, #315). + */ + +import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types'; +import type { JSONRPCNotification } from '../spec-types/2025-11-25'; +import { validateWireMessage } from '../validation/wire-schema'; +import { JsonRpcError, type Connection, type ConnectOptions } from './index'; + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number | string | null; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +export const CONFORMANCE_CLIENT_INFO = { + name: 'conformance-test-client', + version: '1.0.0' +} as const; + +export const DEFAULT_CLIENT_CAPABILITIES = { + sampling: {}, + elicitation: {}, + roots: { listChanged: true } +} as const; + +export interface StatelessResponse { + status: number; + headers: Headers; + contentType?: string; + /** The parsed JSON-RPC message (for SSE: the event matching the request id). */ + body?: JsonRpcResponse; + /** All parsed events when the response was an SSE / chunked stream. */ + events?: unknown[]; + /** Raw response text when it could not be parsed as JSON. */ + text?: string; +} + +let nextRequestId = 1; + +/** + * The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and + * prompts/get, `params.uri` for resources/read, `params.taskId` for the + * SEP-2663 tasks methods (`tasks/get`, `tasks/update`, `tasks/cancel`). + * Absent otherwise. + */ +export function mcpNameForRequest( + method: string, + params?: Record +): string | undefined { + if (method === 'tools/call' || method === 'prompts/get') { + return typeof params?.name === 'string' ? params.name : undefined; + } + if (method === 'resources/read') { + return typeof params?.uri === 'string' ? params.uri : undefined; + } + if ( + method === 'tasks/get' || + method === 'tasks/update' || + method === 'tasks/cancel' + ) { + return typeof params?.taskId === 'string' ? params.taskId : undefined; + } + return undefined; +} + +/** + * Build the conformant header set for a stateless request: Content-Type, + * Accept (both content types), MCP-Protocol-Version, Mcp-Method and (when the + * method carries one) Mcp-Name. `options.headers` overrides or extends the + * defaults, replacing any default whose name matches case-insensitively. + * `options.specVersion` sets the MCP-Protocol-Version header (default: draft), + * so scenarios can send the spec version the run was invoked with. + */ +export function buildStandardHeaders( + method: string, + params?: Record, + options: { headers?: Record; specVersion?: SpecVersion } = {} +): Record { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': options.specVersion ?? DRAFT_PROTOCOL_VERSION, + 'Mcp-Method': method + }; + const name = mcpNameForRequest(method, params); + if (name !== undefined) { + headers['Mcp-Name'] = name; + } + + if (options.headers) { + for (const [key, value] of Object.entries(options.headers)) { + // Replace any default that differs only by case, then set the override. + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === key.toLowerCase()) { + delete headers[existing]; + } + } + headers[key] = value; + } + } + + return headers; +} + +/** + * Merge params with the conformant `_meta` required on every stateless + * request. Keys already present in `params._meta` win over the defaults. + * `specVersion` sets the declared protocolVersion (default: draft). + */ +export function withRequestMeta( + params?: Record, + specVersion: SpecVersion = DRAFT_PROTOCOL_VERSION +): Record { + return { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': specVersion, + 'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO, + 'io.modelcontextprotocol/clientCapabilities': DEFAULT_CLIENT_CAPABILITIES, + ...(params?._meta as Record | undefined) + } + }; +} + +function isJsonRpcResponseShaped(event: unknown): event is JsonRpcResponse { + return ( + typeof event === 'object' && + event !== null && + ('result' in event || 'error' in event) + ); +} + +function parseSseLineInto(events: unknown[], rawLine: string): void { + const line = rawLine.trim(); + if (!line) return; + const jsonText = line.startsWith('data:') + ? line.replace(/^data:\s*/, '') + : line; + try { + events.push(JSON.parse(jsonText)); + } catch { + // Non-JSON line (comments, partial frames) — ignore. + } +} + +/** + * Read an SSE / chunked-stream response incrementally and resolve as soon as + * the JSON-RPC response matching `requestId` arrives, without waiting for the + * stream to close. If the stream ends (or is aborted) first, returns whatever + * events were parsed, with `body` set to the last response-shaped event. + */ +export async function readSseJsonRpcResponse( + res: Response, + requestId: number | string | null +): Promise<{ events: unknown[]; body?: JsonRpcResponse }> { + const events: unknown[] = []; + const matchesRequest = (event: unknown): event is JsonRpcResponse => + isJsonRpcResponseShaped(event) && event.id === requestId; + const finish = (): { events: unknown[]; body?: JsonRpcResponse } => { + const match = events.find(matchesRequest); + const lastResponseShaped = [...events] + .reverse() + .find(isJsonRpcResponseShaped); + return { events, body: match ?? lastResponseShaped }; + }; + + if (!res.body) return finish(); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + for (;;) { + let value: Uint8Array | undefined; + let done = false; + try { + ({ value, done } = await reader.read()); + } catch { + // The stream was aborted (timeout) or dropped — return what arrived. + break; + } + + if (value) { + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) parseSseLineInto(events, line); + + if (events.some(matchesRequest)) { + // The response we were waiting for arrived; stop reading the stream. + await reader.cancel().catch(() => {}); + break; + } + } + + if (done) { + parseSseLineInto(events, buffer); + buffer = ''; + break; + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // Lock already released (e.g. after cancel) — nothing to do. + } + } + + return finish(); +} + +/** + * Send a single stateless JSON-RPC request with the full set of cross-cutting + * headers and `_meta`. Handles both JSON and SSE responses. + */ +export async function sendStatelessRequest( + serverUrl: string, + method: string, + params?: Record, + options: { + headers?: Record; + timeoutMs?: number; + specVersion?: SpecVersion; + /** Skip wire-schema validation of the *outgoing request* only (for intentionally + * malformed traffic). Responses stay validated, with one carve-out: JSON-RPC 2.0 + * requires `id: null` on errors for unprocessable requests, which RequestId forbids. */ + skipValidation?: boolean; + } = {} +): Promise { + const id = nextRequestId++; + const specVersion = options.specVersion ?? DRAFT_PROTOCOL_VERSION; + const headers = buildStandardHeaders(method, params, { + headers: options.headers, + specVersion: options.specVersion + }); + const request = { + jsonrpc: '2.0', + id, + method, + params: withRequestMeta(params, options.specVersion) + }; + if (!options.skipValidation) { + validateWireMessage(specVersion, request, { + origin: 'harness', + context: `stateless request '${method}'` + }); + } + const body = JSON.stringify(request); + + // See the `skipValidation` doc above: on a skip-validated request, tolerate the + // JSON-RPC 2.0 `id: null` an error response must carry when the request could not be + // processed, by validating the rest of the shape with the request's real id substituted. + const withNullIdCarveOut = (event: unknown): unknown => { + if ( + options.skipValidation && + typeof event === 'object' && + event !== null && + (event as Record).error !== undefined && + (event as Record).id === null + ) { + return { ...(event as Record), id }; + } + return event; + }; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + options.timeoutMs ?? 10000 + ); + try { + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + + const contentType = res.headers.get('content-type') ?? undefined; + + if (contentType?.includes('text/event-stream')) { + // Read the stream incrementally and resolve on the matching response — + // a server that keeps the stream open must not stall the harness. + const { events, body: matched } = await readSseJsonRpcResponse(res, id); + for (const event of events) { + validateWireMessage(specVersion, withNullIdCarveOut(event), { + origin: 'implementation', + context: `SSE event during '${method}'`, + requestMethod: event === matched ? method : undefined + }); + } + return { + status: res.status, + headers: res.headers, + contentType, + events, + body: matched + }; + } + + const text = await res.text(); + try { + const parsed = text ? (JSON.parse(text) as JsonRpcResponse) : undefined; + if (parsed !== undefined) { + validateWireMessage(specVersion, withNullIdCarveOut(parsed), { + origin: 'implementation', + context: `response to '${method}'`, + requestMethod: method + }); + } + return { + status: res.status, + headers: res.headers, + contentType, + body: parsed + }; + } catch { + return { status: res.status, headers: res.headers, contentType, text }; + } + } finally { + clearTimeout(timeout); + // Tear down any still-open SSE stream so sockets don't linger. + controller.abort(); + } +} + +/** + * `Connection` impl for the 2026-x stateless lifecycle. Thin wrapper over + * `sendStatelessRequest()`: classifies SSE-stream events into the notification + * sink, surfaces server→client *requests* on the response stream as a spec + * violation, and throws `JsonRpcError` on error responses. + * + * Session bootstrap on the stateless wire is `server/discover` (SEP-2575's + * replacement for `initialize`). The result is exposed via + * `connection.discover()` so scenarios can inspect server capabilities, + * serverInfo, and supported protocol versions. + */ +export async function connectStateless( + serverUrl: string, + specVersion: SpecVersion = DRAFT_PROTOCOL_VERSION, + opts: ConnectOptions = {} +): Promise { + const notifications: JSONRPCNotification[] = []; + const capabilities = opts.capabilities ?? DEFAULT_CLIENT_CAPABILITIES; + const clientInfo = opts.clientInfo ?? CONFORMANCE_CLIENT_INFO; + + // The Connection layer is the single place that knows about + // connect-time capabilities / clientInfo. We fold them into the + // request's `_meta` here (the trailing `params._meta` spread in + // `withRequestMeta` lets us override its defaults) so that + // `sendStatelessRequest` and `withRequestMeta` keep their upstream + // signatures untouched. + function withConnectMeta( + params?: Record + ): Record { + return { + ...params, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': capabilities, + 'io.modelcontextprotocol/clientInfo': clientInfo, + ...(params?._meta as Record | undefined) + } + }; + } + + async function send( + method: string, + params?: Record, + extraHeaders?: Record + ): Promise { + return sendStatelessRequest(serverUrl, method, withConnectMeta(params), { + specVersion, + headers: extraHeaders + }); + } + + function drainEvents(response: StatelessResponse): void { + for (const event of response.events ?? []) { + if (typeof event !== 'object' || event === null) continue; + if ('method' in event && !('id' in event)) { + notifications.push(event as JSONRPCNotification); + } else if ('method' in event && 'id' in event) { + throw new JsonRpcError( + -32600, + `Server sent request '${(event as { method: string }).method}' on response stream; stateless lifecycle forbids this (use MRTR)` + ); + } + } + } + + function unwrap(method: string, response: StatelessResponse): R { + const rpcError = response.body?.error; + // Only a properly-shaped JSON-RPC error becomes a JsonRpcError; anything + // else (e.g. a proxy's `{"error": "upstream timeout"}`) falls through so + // the HTTP status and raw body are surfaced below. + if ( + typeof rpcError === 'object' && + rpcError !== null && + typeof rpcError.code === 'number' + ) { + throw new JsonRpcError(rpcError.code, rpcError.message, rpcError.data); + } + if (response.body?.result === undefined) { + throw new Error( + `HTTP ${response.status}: ` + + `expected a JSON-RPC result for '${method}', got ` + + (response.text !== undefined + ? `non-JSON body (content-type ${response.contentType ?? '(none)'})` + : response.body !== undefined + ? `unexpected body ${JSON.stringify(response.body)}` + : 'no result in response body') + ); + } + return response.body.result as R; + } + + // SEP-2575 has no required handshake. `_meta.clientCapabilities` on + // every request is authoritative per spec, so `server/discover` is + // strictly a client-side query ("what does the server advertise?"). + // We deliberately do NOT run it eagerly — a server that ignores + // per-request `_meta` until a prior `server/discover` has registered + // the session is non-conformant, and the conformance suite's job is + // to surface that, not paper over it. + let discoverPromise: Promise> | undefined; + + async function request( + method: string, + params?: Record, + extraHeaders?: Record + ): Promise { + const response = await send(method, params, extraHeaders); + drainEvents(response); + return unwrap(method, response); + } + + return { + notifications, + discover(): Promise> { + return (discoverPromise ??= + request>('server/discover')); + }, + request, + close: async () => {} + }; +} diff --git a/src/connection/testing.ts b/src/connection/testing.ts new file mode 100644 index 0000000..f9177d1 --- /dev/null +++ b/src/connection/testing.ts @@ -0,0 +1,19 @@ +import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { connectFor } from './select'; +import type { ConnectOptions, RunContext } from './index'; + +/** + * Build a RunContext for unit tests that drive a scenario directly. + * Defaults to the latest dated spec version (stateful lifecycle) so existing + * tests keep their pre-RunContext behaviour. + */ +export function testContext( + serverUrl: string, + specVersion: SpecVersion = LATEST_SPEC_VERSION +): RunContext { + return { + serverUrl, + specVersion, + connect: (opts?: ConnectOptions) => connectFor(specVersion)(serverUrl, opts) + }; +} diff --git a/src/expected-failures.test.ts b/src/expected-failures.test.ts index 05cfc9c..78bb55b 100644 --- a/src/expected-failures.test.ts +++ b/src/expected-failures.test.ts @@ -2,9 +2,23 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { loadExpectedFailures, evaluateBaseline } from './expected-failures'; +import { + loadExpectedFailures, + evaluateBaseline, + type BaselineEntry +} from './expected-failures'; import { ConformanceCheck } from './types'; +/** A baseline entry allowing a whole scenario to fail. */ +function whole(scenario: string): BaselineEntry { + return { scenario }; +} + +/** A baseline entry allowing one check of a scenario to fail. */ +function only(scenario: string, checkId: string): BaselineEntry { + return { scenario, checkId }; +} + function makeCheck( id: string, status: 'SUCCESS' | 'FAILURE' | 'WARNING' | 'SKIPPED' | 'INFO' @@ -44,10 +58,13 @@ client: const result = await loadExpectedFailures(filePath); expect(result.server).toEqual([ - 'tools-call-with-progress', - 'resources-subscribe' + whole('tools-call-with-progress'), + whole('resources-subscribe') + ]); + expect(result.client).toEqual([ + whole('sse-retry'), + whole('auth/basic-dcr') ]); - expect(result.client).toEqual(['sse-retry', 'auth/basic-dcr']); }); it('loads a file with only server entries', async () => { @@ -60,7 +77,7 @@ client: ); const result = await loadExpectedFailures(filePath); - expect(result.server).toEqual(['tools-call-with-progress']); + expect(result.server).toEqual([whole('tools-call-with-progress')]); expect(result.client).toBeUndefined(); }); @@ -99,6 +116,73 @@ client: ); }); + it('names the mistake when a colon entry is written with a space', async () => { + // `- s: c` is a YAML mapping, not the string `- s:c`. Without a guard this + // stringifies to '[object Object]' and silently matches nothing. + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile( + filePath, + `server: + - server-stateless: sep-2575-server-implements-discover +` + ); + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + /Remove the space after the colon: '- server-stateless:sep-2575-server-implements-discover'/ + ); + }); + + it('loads a per-check entry as a plain string', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile( + filePath, + `server: + - server-stateless:sep-2575-server-implements-discover + - tasks-lifecycle +` + ); + const result = await loadExpectedFailures(filePath); + expect(result.server).toEqual([ + only('server-stateless', 'sep-2575-server-implements-discover'), + whole('tasks-lifecycle') + ]); + }); + + it('does not invent a check id for a trailing-colon entry', async () => { + // '- s:' parses to {s: null}. Advising '- s:null' would produce a check id + // named 'null' that matches nothing — the exact silent no-op this guard exists to stop. + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, `server:\n - server-stateless:\n`); + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + /takes no space after the colon/ + ); + await expect(loadExpectedFailures(filePath)).rejects.not.toThrow(/null'/); + }); + + it("rejects an empty list entry rather than coercing it to 'null'", async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, `server:\n -\n`); + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + /This one is empty/ + ); + }); + + it('rejects a scenario baselined both wholesale and per-check', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile( + filePath, + `server:\n - server-stateless\n - server-stateless:some-check\n` + ); + await expect(loadExpectedFailures(filePath)).rejects.toThrow(/redundant/); + }); + + it('rejects an entry with an empty side of the colon', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, `server:\n - ':some-check'\n`); + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + /Both sides of the colon are required/ + ); + }); + it('throws on missing file', async () => { await expect( loadExpectedFailures('/nonexistent/path.yml') @@ -119,7 +203,10 @@ describe('evaluateBaseline', () => { } ]; - const result = evaluateBaseline(results, ['scenario-a', 'scenario-b']); + const result = evaluateBaseline(results, [ + whole('scenario-a'), + whole('scenario-b') + ]); expect(result.exitCode).toBe(0); expect(result.expectedFailures).toEqual(['scenario-a', 'scenario-b']); expect(result.unexpectedFailures).toEqual([]); @@ -162,7 +249,7 @@ describe('evaluateBaseline', () => { } ]; - const result = evaluateBaseline(results, ['scenario-a']); + const result = evaluateBaseline(results, [whole('scenario-a')]); expect(result.exitCode).toBe(1); expect(result.staleEntries).toEqual(['scenario-a']); }); @@ -179,7 +266,7 @@ describe('evaluateBaseline', () => { } ]; - const result = evaluateBaseline(results, ['scenario-a']); + const result = evaluateBaseline(results, [whole('scenario-a')]); expect(result.exitCode).toBe(1); expect(result.staleEntries).toEqual(['scenario-a']); expect(result.unexpectedFailures).toEqual(['scenario-b']); @@ -199,7 +286,7 @@ describe('evaluateBaseline', () => { expect(result1.unexpectedFailures).toEqual(['scenario-a']); // In baseline → expected - const result2 = evaluateBaseline(results, ['scenario-a']); + const result2 = evaluateBaseline(results, [whole('scenario-a')]); expect(result2.exitCode).toBe(0); expect(result2.expectedFailures).toEqual(['scenario-a']); }); @@ -213,7 +300,7 @@ describe('evaluateBaseline', () => { ]; // scenario-z is in baseline but not in the results - should not be stale - const result = evaluateBaseline(results, ['scenario-z']); + const result = evaluateBaseline(results, [whole('scenario-z')]); expect(result.exitCode).toBe(0); expect(result.staleEntries).toEqual([]); }); @@ -234,7 +321,7 @@ describe('evaluateBaseline', () => { } ]; - const result = evaluateBaseline(results, ['expected-fail']); + const result = evaluateBaseline(results, [whole('expected-fail')]); expect(result.exitCode).toBe(1); expect(result.expectedFailures).toEqual(['expected-fail']); expect(result.unexpectedFailures).toEqual(['unexpected-fail']); @@ -254,8 +341,226 @@ describe('evaluateBaseline', () => { ]; // In baseline but passes (only SUCCESS/SKIPPED/INFO) → stale - const result = evaluateBaseline(results, ['scenario-a']); + const result = evaluateBaseline(results, [whole('scenario-a')]); expect(result.exitCode).toBe(1); expect(result.staleEntries).toEqual(['scenario-a']); }); }); + +describe('per-check baseline entries (scenario:check-id)', () => { + it('excuses only the named check, leaving its siblings enforced', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('sep-2575-server-implements-discover', 'FAILURE'), + makeCheck('sep-2575-server-tags-subscription-id', 'SUCCESS') + ] + } + ], + [only('server-stateless', 'sep-2575-server-implements-discover')] + ); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual([ + 'server-stateless:sep-2575-server-implements-discover' + ]); + expect(result.unexpectedFailures).toEqual([]); + }); + + it('is the whole point: an unbaselined sibling failure still fails the run', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('sep-2575-server-implements-discover', 'FAILURE'), + makeCheck('sep-2575-server-tags-subscription-id', 'FAILURE') + ] + } + ], + [only('server-stateless', 'sep-2575-server-implements-discover')] + ); + expect(result.exitCode).toBe(1); + expect(result.unexpectedFailures).toEqual([ + 'server-stateless:sep-2575-server-tags-subscription-id' + ]); + }); + + it('flags a baselined check that now passes as stale', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [makeCheck('sep-2575-server-implements-discover', 'SUCCESS')] + } + ], + [only('server-stateless', 'sep-2575-server-implements-discover')] + ); + expect(result.exitCode).toBe(1); + expect(result.staleEntries).toEqual([ + 'server-stateless:sep-2575-server-implements-discover' + ]); + }); + + it('tolerates a baselined check that was not emitted (prerequisite bailed)', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [makeCheck('other', 'SUCCESS')] + } + ], + [only('server-stateless', 'sep-2575-server-implements-discover')] + ); + expect(result.exitCode).toBe(0); + expect(result.staleEntries).toEqual([]); + }); + + it('treats a WARNING check as an expected failure, matching scenario-level semantics', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('sep-2575-request-meta-client-info-optional', 'WARNING') + ] + } + ], + [only('server-stateless', 'sep-2575-request-meta-client-info-optional')] + ); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual([ + 'server-stateless:sep-2575-request-meta-client-info-optional' + ]); + }); + + it('excuses every occurrence of a repeated id, and a failure on any of them wins', () => { + // sep-2575-http-server-meta-invalid-400 is emitted once per _meta test case. + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('sep-2575-http-server-meta-invalid-400', 'SUCCESS'), + makeCheck('sep-2575-http-server-meta-invalid-400', 'FAILURE'), + makeCheck('sep-2575-http-server-meta-invalid-400', 'SUCCESS') + ] + } + ], + [only('server-stateless', 'sep-2575-http-server-meta-invalid-400')] + ); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual([ + 'server-stateless:sep-2575-http-server-meta-invalid-400' + ]); + }); + + it('reports a repeated failing id once, not once per occurrence', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('sep-2575-http-server-meta-invalid-400', 'FAILURE'), + makeCheck('sep-2575-http-server-meta-invalid-400', 'FAILURE') + ] + } + ], + [only('server-stateless', 'sep-2575-http-server-meta-invalid-400')] + ); + expect(result.expectedFailures).toEqual([ + 'server-stateless:sep-2575-http-server-meta-invalid-400' + ]); + }); + + it('judges staleness on the most-severe occurrence, not the first', () => { + // A SKIPPED occurrence ordered ahead of a SUCCESS must not mask the pass: + // the check demonstrably succeeded, so the baseline entry is stale. + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('repeated', 'SKIPPED'), + makeCheck('repeated', 'SUCCESS') + ] + } + ], + [only('server-stateless', 'repeated')] + ); + expect(result.exitCode).toBe(1); + expect(result.staleEntries).toEqual(['server-stateless:repeated']); + }); + + it('does not let a repeated id hide a failure when it is NOT baselined', () => { + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('repeated', 'SUCCESS'), + makeCheck('repeated', 'FAILURE') + ] + } + ], + [] + ); + expect(result.exitCode).toBe(1); + expect(result.unexpectedFailures).toEqual(['server-stateless']); + }); + + it('keeps whole-scenario entries working alongside per-check ones', () => { + const result = evaluateBaseline( + [ + { + scenario: 'tasks-lifecycle', + checks: [makeCheck('a', 'FAILURE'), makeCheck('b', 'FAILURE')] + }, + { + scenario: 'server-stateless', + checks: [makeCheck('c', 'FAILURE'), makeCheck('d', 'SUCCESS')] + } + ], + [whole('tasks-lifecycle'), only('server-stateless', 'c')] + ); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual([ + 'tasks-lifecycle', + 'server-stateless:c' + ]); + }); + + it('splits on the first colon so slashed scenario names survive', () => { + const result = evaluateBaseline( + [ + { + scenario: 'auth/basic-dcr', + checks: [makeCheck('wif-grant-type', 'FAILURE')] + } + ], + [only('auth/basic-dcr', 'wif-grant-type')] + ); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual(['auth/basic-dcr:wif-grant-type']); + }); + + it('judges staleness past an INFO entry sharing the id', () => { + // collapseDuplicateChecks keeps every INFO, so an INFO can sit ahead of the + // real verdict for that id. It reports nothing, and must not mask the pass. + const result = evaluateBaseline( + [ + { + scenario: 'server-stateless', + checks: [ + makeCheck('repeated', 'INFO'), + makeCheck('repeated', 'SUCCESS') + ] + } + ], + [only('server-stateless', 'repeated')] + ); + expect(result.exitCode).toBe(1); + expect(result.staleEntries).toEqual(['server-stateless:repeated']); + }); +}); diff --git a/src/expected-failures.ts b/src/expected-failures.ts index 8d0655f..0f372f4 100644 --- a/src/expected-failures.ts +++ b/src/expected-failures.ts @@ -2,31 +2,118 @@ import { promises as fs } from 'fs'; import { parse as parseYaml } from 'yaml'; import { ConformanceCheck } from './types'; import { COLORS } from './runner/utils'; +import { collapseDuplicateChecks } from './checks/collapse'; + +/** + * One line of a baseline list, parsed. + * + * Written as `` to allow a whole scenario to fail, or + * `:` to allow only that check to. + */ +export interface BaselineEntry { + scenario: string; + /** When set, only this check may fail; the scenario's others stay enforced. */ + checkId?: string; +} export interface ExpectedFailures { - server?: string[]; - client?: string[]; + server?: BaselineEntry[]; + client?: BaselineEntry[]; } export interface BaselineResult { /** Exit code: 0 if only expected failures, 1 if unexpected failures or stale baseline */ exitCode: number; - /** Scenarios that failed unexpectedly (not in baseline) */ + /** Entries that failed unexpectedly (not in baseline), formatted as written */ unexpectedFailures: string[]; - /** Scenarios in baseline that now pass (stale entries) */ + /** Entries in baseline that now pass (stale entries), formatted as written */ staleEntries: string[]; - /** Scenarios that failed as expected */ + /** Entries that failed as expected, formatted as written */ expectedFailures: string[]; } +/** Render an entry back in the form a user writes it. */ +export function formatEntry(entry: BaselineEntry): string { + return entry.checkId ? `${entry.scenario}:${entry.checkId}` : entry.scenario; +} + +/** + * Parse and validate one baseline list. This is the only place that knows the + * entry grammar: everything downstream consumes `BaselineEntry`. + * + * Scenario names contain slashes but never colons (`auth/basic-dcr`), and no + * check id contains one, so the first colon is an unambiguous delimiter. + */ +function parseEntryList(entries: unknown[], section: string): BaselineEntry[] { + const reject = (entry: unknown, why: string): never => { + throw new Error( + `Invalid expected-failures file: '${section}' entry ${JSON.stringify(entry)} ` + + `is not '' or ':'. ${why}` + ); + }; + + const parsed = entries.map((entry): BaselineEntry => { + if (entry === null || entry === undefined) { + return reject(entry, 'This one is empty.'); + } + if (typeof entry === 'object') { + // A mapping means a space crept in after the colon. Only say so when the + // parse is actually consistent with that — one key, one non-empty scalar + // value. Reconstructing the user's line from anything else quotes a line + // they never wrote. + const pairs = Array.isArray(entry) + ? [] + : Object.entries(entry as Record); + const [key, value] = pairs[0] ?? []; + const isSpaceTypo = + pairs.length === 1 && + value !== null && + value !== undefined && + typeof value !== 'object' && + String(value) !== ''; + return reject( + entry, + isSpaceTypo + ? `Did you write '- ${key}: ${value}'? Remove the space after the colon: '- ${key}:${value}'` + : 'A per-check entry takes no space after the colon.' + ); + } + + const text = String(entry); + const sep = text.indexOf(':'); + if (sep === -1) { + return { scenario: text }; + } + const scenario = text.slice(0, sep); + const checkId = text.slice(sep + 1); + if (!scenario || !checkId) { + return reject(entry, 'Both sides of the colon are required.'); + } + return { scenario, checkId }; + }); + + const wholesale = new Set( + parsed.filter((e) => !e.checkId).map((e) => e.scenario) + ); + for (const entry of parsed) { + if (entry.checkId && wholesale.has(entry.scenario)) { + throw new Error( + `Invalid expected-failures file: '${entry.scenario}' expects the whole scenario to fail, ` + + `so '${formatEntry(entry)}' is redundant. Remove one or the other.` + ); + } + } + return parsed; +} + /** * Load and parse an expected-failures YAML file. * * Expected format: * ```yaml * server: - * - scenario-name-1 - * - scenario-name-2 + * - scenario-name-1 # whole scenario may fail + * - scenario-name-2:check-id # only this check may fail * client: * - scenario-name-3 * ``` @@ -49,22 +136,15 @@ export async function loadExpectedFailures( const result: ExpectedFailures = {}; - if (parsed.server !== undefined) { - if (!Array.isArray(parsed.server)) { + for (const section of ['server', 'client'] as const) { + const list = parsed[section]; + if (list === undefined) continue; + if (!Array.isArray(list)) { throw new Error( - `Invalid expected-failures file: 'server' must be an array of scenario names` + `Invalid expected-failures file: '${section}' must be an array of scenario names` ); } - result.server = parsed.server.map(String); - } - - if (parsed.client !== undefined) { - if (!Array.isArray(parsed.client)) { - throw new Error( - `Invalid expected-failures file: 'client' must be an array of scenario names` - ); - } - result.client = parsed.client.map(String); + result[section] = parseEntryList(list, section); } return result; @@ -73,42 +153,88 @@ export async function loadExpectedFailures( /** * Evaluate scenario results against an expected-failures baseline. * - * Rules: + * Whole-scenario entry (`{ scenario }`) — unchanged from the original behaviour: * - Scenario fails and IS in baseline → expected (ok) * - Scenario fails and is NOT in baseline → unexpected failure (exit 1) * - Scenario passes and IS in baseline → stale entry (exit 1, must update baseline) * - Scenario passes and is NOT in baseline → normal pass (ok) + * + * Per-check entry (`{ scenario, checkId }`) — every failing check in that + * scenario is judged on its own, so one baselined check no longer excuses the + * other twenty: + * - Check fails and IS in baseline → expected (ok) + * - Check fails and is NOT in baseline → unexpected failure (exit 1) + * - Check passes and IS in baseline → stale entry (exit 1) + * - Check is absent or SKIPPED → no signal, tolerated + * + * A check id addresses every occurrence of that id, not one: ids repeat within + * a run (a loop, a retried flow), so the occurrences are collapsed most-severe- + * first before matching. Baselining an id that repeats therefore excuses all of + * its occurrences — coarser than ideal, still far narrower than the scenario. + * + * Absent and SKIPPED are tolerated for the same reason the runner already + * treats them as green (AGENTS.md, "Check conventions"): neither reports a + * requirement violated. The cost is that an entry naming a check that no longer + * exists — renamed, deleted, or typo'd — sits unnoticed. Whole-scenario entries + * already carry that weakness for scenarios outside the selected suite. */ export function evaluateBaseline( results: { scenario: string; checks: ConformanceCheck[] }[], - expectedScenarios: string[] + expectedEntries: BaselineEntry[] ): BaselineResult { - const expectedSet = new Set(expectedScenarios); + const scenarios = new Set( + expectedEntries.filter((e) => !e.checkId).map((e) => e.scenario) + ); + const checksByScenario = new Map>(); + for (const entry of expectedEntries) { + if (!entry.checkId) continue; + const ids = checksByScenario.get(entry.scenario) ?? new Set(); + ids.add(entry.checkId); + checksByScenario.set(entry.scenario, ids); + } + const unexpectedFailures: string[] = []; const staleEntries: string[] = []; const expectedFailures: string[] = []; - const seenScenarios = new Set(); + const isFailed = (c: ConformanceCheck) => + c.status === 'FAILURE' || c.status === 'WARNING'; for (const result of results) { - seenScenarios.add(result.scenario); - const hasFailed = - result.checks.some((c) => c.status === 'FAILURE') || - result.checks.some((c) => c.status === 'WARNING'); - const isExpected = expectedSet.has(result.scenario); - - if (hasFailed && isExpected) { - expectedFailures.push(result.scenario); - } else if (hasFailed && !isExpected) { - unexpectedFailures.push(result.scenario); - } else if (!hasFailed && isExpected) { - staleEntries.push(result.scenario); + if (scenarios.has(result.scenario)) { + const hasFailed = result.checks.some(isFailed); + if (hasFailed) expectedFailures.push(result.scenario); + else staleEntries.push(result.scenario); + continue; } - // !hasFailed && !isExpected → normal pass, nothing to do - } - // Also check for baseline entries that reference scenarios not in the run - // (these are not stale - they might just not be in this suite) + const expectedChecks = checksByScenario.get(result.scenario); + if (!expectedChecks) { + if (result.checks.some(isFailed)) + unexpectedFailures.push(result.scenario); + continue; + } + + const collapsed = collapseDuplicateChecks(result.checks); + for (const c of collapsed.filter(isFailed)) { + const entry = formatEntry({ scenario: result.scenario, checkId: c.id }); + (expectedChecks.has(c.id) ? expectedFailures : unexpectedFailures).push( + entry + ); + } + for (const id of expectedChecks) { + // Only a demonstrated pass makes the entry stale. Absent and SKIPPED + // report no requirement violated, so they leave the entry alone; a + // failure was already recorded above. INFO entries are kept rather than + // collapsed, so one can sit ahead of the real verdict for this id. + const emitted = collapsed.find((c) => c.id === id && c.status !== 'INFO'); + if (emitted?.status === 'SUCCESS') { + staleEntries.push( + formatEntry({ scenario: result.scenario, checkId: id }) + ); + } + } + } const exitCode = unexpectedFailures.length > 0 || staleEntries.length > 0 ? 1 : 0; @@ -124,8 +250,8 @@ export function printBaselineResults(result: BaselineResult): void { console.log( `\n${COLORS.YELLOW}Expected failures (in baseline):${COLORS.RESET}` ); - for (const scenario of result.expectedFailures) { - console.log(` ~ ${scenario}`); + for (const entry of result.expectedFailures) { + console.log(` ~ ${entry}`); } } @@ -133,8 +259,8 @@ export function printBaselineResults(result: BaselineResult): void { console.log( `\n${COLORS.RED}Stale baseline entries (now passing - remove from baseline):${COLORS.RESET}` ); - for (const scenario of result.staleEntries) { - console.log(` ✓ ${scenario}`); + for (const entry of result.staleEntries) { + console.log(` ✓ ${entry}`); } } @@ -142,8 +268,8 @@ export function printBaselineResults(result: BaselineResult): void { console.log( `\n${COLORS.RED}Unexpected failures (not in baseline):${COLORS.RESET}` ); - for (const scenario of result.unexpectedFailures) { - console.log(` ✗ ${scenario}`); + for (const entry of result.unexpectedFailures) { + console.log(` ✗ ${entry}`); } } @@ -154,12 +280,12 @@ export function printBaselineResults(result: BaselineResult): void { } else { if (result.staleEntries.length > 0) { console.log( - `\n${COLORS.RED}Baseline is stale: update your expected-failures file to remove passing scenarios.${COLORS.RESET}` + `\n${COLORS.RED}Baseline is stale: update your expected-failures file to remove passing entries.${COLORS.RESET}` ); } if (result.unexpectedFailures.length > 0) { console.log( - `\n${COLORS.RED}Unexpected failures detected: these scenarios are not in your expected-failures baseline.${COLORS.RESET}` + `\n${COLORS.RED}Unexpected failures detected: these are not in your expected-failures baseline.${COLORS.RESET}` ); } } diff --git a/src/index.ts b/src/index.ts index 537fb16..b376644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { ZodError } from 'zod'; +import { promises as fs } from 'fs'; import { runConformanceTest, printClientResults, @@ -10,6 +11,11 @@ import { printServerSummary, runInteractiveMode } from './runner'; +import { + printAuthorizationServerResults, + printAuthorizationServerSummary, + runAuthorizationServerConformanceTest +} from './runner/authorization-server'; import { listScenarios, listClientScenarios, @@ -20,44 +26,182 @@ import { listCoreScenarios, listExtensionScenarios, listBackcompatScenarios, + listDraftScenarios, + listDraftClientScenarios, listScenariosForSpec, listClientScenariosForSpec, getScenarioSpecVersions, - ALL_SPEC_VERSIONS + listClientScenariosForAuthorizationServer, + listClientScenariosForAuthorizationServerForSpec, + resolveSpecVersion } from './scenarios'; import type { SpecVersion } from './scenarios'; import { ConformanceCheck } from './types'; -import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; +import { + AuthorizationServerOptionsSchema, + ClientOptionsSchema, + ServerOptionsSchema +} from './schemas'; +import type { AuthorizationServerOptions } from './schemas'; +import { withWireRecorder } from './validation/wire-schema'; +import { + filterScenariosByRequirements, + Leg, + listRequirementRevisions, + loadRequirements, + notScoredScenarios, + REASONS, + RequirementSet, + scoredScenarios +} from './requirements'; import { loadExpectedFailures, evaluateBaseline, printBaselineResults } from './expected-failures'; import { createTierCheckCommand } from './tier-check'; +import { createNewSepCommand } from './new-sep'; +import { createSdkCommand } from './sdk-runner'; +import { createTraceabilityCommand } from './traceability'; import packageJson from '../package.json'; -function resolveSpecVersion(value: string): SpecVersion { - if (ALL_SPEC_VERSIONS.includes(value as SpecVersion)) { - return value as SpecVersion; - } - console.error(`Unknown spec version: ${value}`); - console.error(`Valid versions: ${ALL_SPEC_VERSIONS.join(', ')}`); - process.exit(1); -} - // Note on naming: `command` refers to which CLI command is calling this. // The `client` command tests Scenario objects (which test clients), // and the `server` command tests ClientScenario objects (which test servers). // This matches the inverted naming in scenarios/index.ts. +/** + * Resolve `--requirements`. A requirement set replaces suite and spec-version + * selection: it already names exactly the scenarios that revision requires. + */ +function resolveRequirements( + revision: string | undefined, + conflicts: { specVersion?: string; suiteFromCli?: boolean; scenario?: string } +): RequirementSet | undefined { + if (revision === undefined) return undefined; + // An explicitly passed empty value is a script whose variable did not expand. + // Ignoring it would silently score against the whole suite, which is the + // failure this flag exists to prevent. + if (revision === '') { + console.error( + '--requirements needs a revision, such as 2026-07-28. Run `conformance list` to see which are available.' + ); + process.exit(1); + } + const combined = conflicts.specVersion + ? '--spec-version' + : conflicts.suiteFromCli + ? '--suite' + : conflicts.scenario + ? '--scenario' + : undefined; + if (combined) { + console.error( + `--requirements cannot be combined with ${combined}: a requirement set already fixes which scenarios run.` + ); + process.exit(1); + } + try { + return loadRequirements(revision); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +/** + * Exit status under a requirement set. Scenarios the set runs without scoring + * (extensions, post-release additions) are reported but must not fail the run: + * otherwise the documented command red-flags an implementation that meets every + * requirement the revision actually imposes. + */ +function requirementsExitCode( + requirements: RequirementSet, + leg: Leg, + results: { scenario: string; checks: ConformanceCheck[] }[] +): number { + const scored = new Set(scoredScenarios(requirements, leg)); + const unscored = results.filter((r) => !scored.has(r.scenario)); + if (unscored.length > 0) { + const failing = unscored.filter((r) => + r.checks.some((c) => c.status === 'FAILURE') + ); + console.log( + `\nNot scored for ${requirements.revision}: ${unscored.length} scenario(s) run, ${failing.length} failing. These do not affect conformance.` + ); + for (const r of unscored) { + const why = notScoredScenarios(requirements, leg).find( + (e) => e.scenario === r.scenario + ); + const failed = r.checks.some((c) => c.status === 'FAILURE'); + console.log( + ` ${failed ? '\u2717' : '\u2713'} ${r.scenario} (${why?.reason ?? 'not scored'})` + ); + } + } + const scoredFailed = results + .filter((r) => scored.has(r.scenario)) + .some((r) => r.checks.some((c) => c.status === 'FAILURE')); + return scoredFailed ? 1 : 0; +} + +/** Print one revision's requirement set. `list` is display-only, so it can show several. */ +function listOneRequirementSet(revision: string, specVersion?: string): void { + const requirements = resolveRequirements(revision, { specVersion })!; + const legs: [string, string[]][] = [ + ['Server scenarios (test against a server)', requirements.server], + ['Client scenarios (test against a client)', requirements.client] + ]; + const total = legs.reduce((n, [, names]) => n + names.length, 0); + console.log( + `Required for ${requirements.revision} (${total} scenarios, frozen; run at the ${requirements.revision} wire):\n` + ); + for (const [title, names] of legs) { + if (names.length === 0) continue; + console.log(`${title}:`); + names.forEach((name) => console.log(` - ${name}`)); + console.log(''); + } + if (requirements.notScored.length > 0) { + console.log('Run and reported, but never scored:'); + for (const reason of REASONS) { + const entries = requirements.notScored.filter((e) => e.reason === reason); + if (entries.length === 0) continue; + console.log(` ${reason} (${entries.length}):`); + entries.forEach((e) => console.log(` - ${e.scenario} [${e.leg}]`)); + } + console.log(''); + } +} + +function requiredScenariosOrExit( + allScenarios: string[], + requirements: RequirementSet, + command: Leg +): string[] { + try { + return filterScenariosByRequirements(allScenarios, requirements, command); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + function filterScenariosBySpecVersion( allScenarios: string[], version: SpecVersion, - command: 'client' | 'server' + command: 'client' | 'server' | 'authorization' ): string[] { - const versionScenarios = - command === 'client' - ? listScenariosForSpec(version) - : listClientScenariosForSpec(version); + let versionScenarios: string[]; + if (command === 'client') { + versionScenarios = listScenariosForSpec(version); + } else if (command === 'server') { + versionScenarios = listClientScenariosForSpec(version); + } else if (command === 'authorization') { + versionScenarios = + listClientScenariosForAuthorizationServerForSpec(version); + } else { + versionScenarios = []; + } const allowed = new Set(versionScenarios); return allScenarios.filter((s) => allowed.has(s)); } @@ -88,18 +232,40 @@ program '--spec-version ', 'Filter scenarios by spec version (cumulative for date versions)' ) + .option( + '--force', + 'Run a scenario even if it is not applicable at the requested --spec-version' + ) + .option( + '--requirements ', + 'Run exactly the scenarios a spec revision requires, frozen at its release (e.g. 2026-07-28). Replaces --suite and --spec-version' + ) .option('--verbose', 'Show verbose output') - .action(async (options) => { + .action(async (options, cmd) => { try { const timeout = parseInt(options.timeout, 10); const verbose = options.verbose ?? false; const outputDir = options.outputDir; - const specVersionFilter = options.specVersion - ? resolveSpecVersion(options.specVersion) - : undefined; + const requirements = resolveRequirements(options.requirements, { + specVersion: options.specVersion, + suiteFromCli: cmd.getOptionValueSource('suite') === 'cli', + scenario: options.scenario + }); + // A requirement set decides two different things, and only the first is + // its scenario list. The revision it names is also the wire version those + // scenarios must speak: 2025-11-25 and 2026-07-28 are different protocols + // (stateful initialize handshake vs stateless per-request _meta), and a + // scenario emits different checks under each. Leaving this unset ran the + // right set against LATEST_SPEC_VERSION, which is a different wire than + // the one named on the flag. + const specVersionFilter = requirements + ? resolveSpecVersion(requirements.revision) + : options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; // Handle suite mode - if (options.suite) { + if (options.suite || options.requirements !== undefined) { if (!options.command) { console.error('--command is required when using --suite'); process.exit(1); @@ -112,41 +278,65 @@ program backcompat: listBackcompatScenarios, auth: listAuthScenarios, metadata: listMetadataScenarios, + draft: listDraftScenarios, 'sep-835': () => listAuthScenarios().filter((name) => name.startsWith('auth/scope-')) }; - const suiteName = options.suite.toLowerCase(); - if (!suites[suiteName]) { - console.error(`Unknown suite: ${suiteName}`); - console.error(`Available suites: ${Object.keys(suites).join(', ')}`); - process.exit(1); - } - - let scenarios = suites[suiteName](); - if (specVersionFilter) { - scenarios = filterScenariosBySpecVersion( - scenarios, - specVersionFilter, + let scenarios: string[]; + let selection: string; + if (requirements) { + scenarios = requiredScenariosOrExit( + listScenarios(), + requirements, 'client' ); + selection = `requirements ${requirements.revision}`; + } else { + const suiteName = options.suite.toLowerCase(); + if (!suites[suiteName]) { + console.error(`Unknown suite: ${suiteName}`); + console.error( + `Available suites: ${Object.keys(suites).join(', ')}` + ); + process.exit(1); + } + + scenarios = suites[suiteName](); + if (specVersionFilter) { + scenarios = filterScenariosBySpecVersion( + scenarios, + specVersionFilter, + 'client' + ); + } + selection = `${suiteName} suite`; } console.log( - `Running ${suiteName} suite (${scenarios.length} scenarios) in parallel...\n` + `Running ${selection} (${scenarios.length} scenarios) in parallel...\n` ); const results = await Promise.all( scenarios.map(async (scenarioName) => { try { - const result = await runConformanceTest( - options.command, - scenarioName, - timeout, - outputDir + // Each concurrent scenario records wire violations into its own + // AsyncLocalStorage-scoped recorder (see withWireRecorder). + const result = await withWireRecorder(() => + runConformanceTest( + options.command, + scenarioName, + timeout, + outputDir, + specVersionFilter, + // a requirement set decides membership, so its choice outranks a + // scenario's own applicability window at the pinned revision + options.force || Boolean(requirements) + ) ); return { scenario: scenarioName, checks: result.checks, + skipped: result.skipped, error: null }; } catch (error) { @@ -163,6 +353,7 @@ program error instanceof Error ? error.message : String(error) } ], + skipped: undefined, error }; } @@ -174,8 +365,17 @@ program let totalPassed = 0; let totalFailed = 0; let totalWarnings = 0; + let totalSkipped = 0; for (const result of results) { + // Inapplicable scenario/spec-version combination (already logged by + // the runner). Not a failure: report distinctly. + if (result.skipped) { + totalSkipped++; + console.log(`- ${result.scenario}: skipped`); + continue; + } + const passed = result.checks.filter( (c) => c.status === 'SUCCESS' ).length; @@ -207,8 +407,9 @@ program } } + const skippedStr = totalSkipped > 0 ? `, ${totalSkipped} skipped` : ''; console.log( - `\nTotal: ${totalPassed} passed, ${totalFailed} failed, ${totalWarnings} warnings` + `\nTotal: ${totalPassed} passed, ${totalFailed} failed, ${totalWarnings} warnings${skippedStr}` ); if (options.expectedFailures) { @@ -216,12 +417,33 @@ program options.expectedFailures ); const baselineScenarios = expectedFailuresConfig.client ?? []; - const baselineResult = evaluateBaseline(results, baselineScenarios); + // Under a requirement set, the baseline judges scored scenarios only: + // a not_scored scenario cannot fail the run, so it must not surface + // as an "unexpected failure" either. + const scoredOnly = requirements + ? new Set(scoredScenarios(requirements, 'client')) + : undefined; + const baselineResult = evaluateBaseline( + scoredOnly + ? results.filter((r) => scoredOnly.has(r.scenario)) + : results, + baselineScenarios + ); printBaselineResults(baselineResult); process.exit(baselineResult.exitCode); } - process.exit(totalFailed > 0 || totalWarnings > 0 ? 1 : 0); + process.exit( + requirements + ? requirementsExitCode( + requirements, + 'client', + results.map((r) => ({ scenario: r.scenario, checks: r.checks })) + ) + : totalFailed > 0 || totalWarnings > 0 + ? 1 + : 0 + ); } // Require either --scenario or --suite @@ -230,7 +452,7 @@ program console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); console.error( - '\nAvailable suites: all, core, extensions, backcompat, auth, metadata, sep-835' + '\nAvailable suites: all, core, extensions, backcompat, auth, metadata, draft, sep-835' ); process.exit(1); } @@ -240,7 +462,13 @@ program // If no command provided, run in interactive mode if (!validated.command) { - await runInteractiveMode(validated.scenario, verbose, outputDir); + await runInteractiveMode( + validated.scenario, + verbose, + outputDir, + specVersionFilter, + options.force ?? false + ); process.exit(0); } @@ -249,9 +477,17 @@ program validated.command, validated.scenario, timeout, - outputDir + outputDir, + specVersionFilter, + options.force ?? false ); + // Inapplicable scenario/spec-version combination (already logged by + // the runner). Not a failure: exit 0. + if (result.skipped) { + process.exit(0); + } + const { overallFailure } = printClientResults( result.checks, verbose, @@ -276,7 +512,7 @@ program } catch (error) { if (error instanceof ZodError) { console.error('Validation error:'); - error.errors.forEach((err) => { + error.issues.forEach((err) => { console.error(` ${err.path.join('.')}: ${err.message}`); }); console.error('\nAvailable client scenarios:'); @@ -299,7 +535,7 @@ program ) .option( '--suite ', - 'Suite to run: "active" (default, excludes pending), "all", or "pending"', + 'Suite to run: "active" (default, excludes pending and draft), "all", "draft", or "pending"', 'active' ) .option( @@ -311,26 +547,59 @@ program '--spec-version ', 'Filter scenarios by spec version (cumulative for date versions)' ) + .option( + '--force', + 'Run a scenario even if it is not applicable at the requested --spec-version' + ) + .option( + '--requirements ', + 'Run exactly the scenarios a spec revision requires, frozen at its release (e.g. 2026-07-28). Replaces --suite and --spec-version' + ) + .option('--timeout ', 'Per-scenario timeout in milliseconds', '30000') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') - .action(async (options) => { + .action(async (options, cmd) => { try { // Validate options with Zod const validated = ServerOptionsSchema.parse(options); + const timeout = parseInt(options.timeout, 10); const verbose = options.verbose ?? false; const outputDir = options.outputDir; - const specVersionFilter = options.specVersion - ? resolveSpecVersion(options.specVersion) - : undefined; + const requirements = resolveRequirements(options.requirements, { + specVersion: options.specVersion, + suiteFromCli: cmd.getOptionValueSource('suite') === 'cli', + scenario: options.scenario + }); + // A requirement set decides two different things, and only the first is + // its scenario list. The revision it names is also the wire version those + // scenarios must speak: 2025-11-25 and 2026-07-28 are different protocols + // (stateful initialize handshake vs stateless per-request _meta), and a + // scenario emits different checks under each. Leaving this unset ran the + // right set against LATEST_SPEC_VERSION, which is a different wire than + // the one named on the flag. + const specVersionFilter = requirements + ? resolveSpecVersion(requirements.revision) + : options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; // If a single scenario is specified, run just that one if (validated.scenario) { const result = await runServerConformanceTest( validated.url, validated.scenario, - outputDir + outputDir, + specVersionFilter, + options.force ?? false, + timeout ); + // Inapplicable scenario/spec-version combination (already logged by + // the runner). Not a failure: exit 0. + if (result.skipped) { + process.exit(0); + } + const { failed } = printServerResults( result.checks, result.scenarioDescription, @@ -355,21 +624,33 @@ program // Run scenarios based on suite const suite = options.suite?.toLowerCase() || 'active'; let scenarios: string[]; + let selection = `${suite} suite`; - if (suite === 'all') { + if (requirements) { + scenarios = requiredScenariosOrExit( + listClientScenarios(), + requirements, + 'server' + ); + selection = `requirements ${requirements.revision}`; + } else if (suite === 'all') { scenarios = listClientScenarios(); } else if (suite === 'active' || suite === 'core') { // 'core' is an alias for 'active' - tier 1 requirements scenarios = listActiveClientScenarios(); } else if (suite === 'pending') { scenarios = listPendingClientScenarios(); + } else if (suite === 'draft') { + // Scenarios targeting the in-progress draft spec; excluded from + // 'active' until the draft is published as a dated release. + scenarios = listDraftClientScenarios(); } else { console.error(`Unknown suite: ${suite}`); - console.error('Available suites: active, all, core, pending'); + console.error('Available suites: active, all, core, draft, pending'); process.exit(1); } - if (specVersionFilter) { + if (specVersionFilter && !requirements) { scenarios = filterScenariosBySpecVersion( scenarios, specVersionFilter, @@ -378,7 +659,7 @@ program } console.log( - `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` + `Running ${selection} (${scenarios.length} scenarios) against ${validated.url}\n` ); const allResults: { scenario: string; checks: ConformanceCheck[] }[] = @@ -387,10 +668,19 @@ program for (const scenarioName of scenarios) { console.log(`\n=== Running scenario: ${scenarioName} ===`); try { - const result = await runServerConformanceTest( - validated.url, - scenarioName, - outputDir + // Sequential, but a failed scenario can leak a connection that + // emits late traffic; scoping keeps it out of the next scenario. + const result = await withWireRecorder(() => + runServerConformanceTest( + validated.url, + scenarioName, + outputDir, + specVersionFilter, + // a requirement set decides membership, so its choice outranks a + // scenario's own applicability window at the pinned revision + Boolean(requirements), + timeout + ) ); allResults.push({ scenario: scenarioName, checks: result.checks }); } catch (error) { @@ -419,20 +709,33 @@ program options.expectedFailures ); const baselineScenarios = expectedFailuresConfig.server ?? []; + // Same scored-only rule as the client leg: not_scored scenarios are + // outside both the pass rate and the baseline. + const scoredOnly = requirements + ? new Set(scoredScenarios(requirements, 'server')) + : undefined; const baselineResult = evaluateBaseline( - allResults, + scoredOnly + ? allResults.filter((r) => scoredOnly.has(r.scenario)) + : allResults, baselineScenarios ); printBaselineResults(baselineResult); process.exit(baselineResult.exitCode); } - process.exit(totalFailed > 0 ? 1 : 0); + process.exit( + requirements + ? requirementsExitCode(requirements, 'server', allResults) + : totalFailed > 0 + ? 1 + : 0 + ); } } catch (error) { if (error instanceof ZodError) { console.error('Validation error:'); - error.errors.forEach((err) => { + error.issues.forEach((err) => { console.error(` ${err.path.join('.')}: ${err.message}`); }); console.error('\nAvailable server scenarios:'); @@ -444,25 +747,221 @@ program } }); +// Authorization command - tests an authorization server implementation +program + .command('authorization') + .description( + 'Run conformance tests against an authorization server implementation' + ) + .option( + '--file ', + 'Path to JSON settings file (see examples/authorization-server-settings.example.json)' + ) + .option('--url ', 'URL of the authorization server issuer') + .option('--scenario ', 'Test scenario to run') + .option( + '--client-id ', + 'OAuth client ID registered with the authorization server' + ) + .option( + '--client-secret ', + 'OAuth client secret (omit for public/PKCE-only clients)' + ) + .option( + '--resource ', + 'Canonical URI of the MCP server the token is for (RFC 8707 resource parameter)' + ) + .option( + '-p, --port ', + 'Port for the local OAuth callback server; register http://127.0.0.1:/callback as a redirect URI', + (value) => Number(value), + 3000 + ) + .option('-o, --output-dir ', 'Save results to this directory') + .option( + '--spec-version ', + 'Filter scenarios by spec version (cumulative for date versions)' + ) + .option('--verbose', 'Show verbose output (JSON instead of pretty print)') + .action(async (options) => { + try { + let fileOptions: AuthorizationServerOptions | undefined; + if (options.file) { + try { + const raw = JSON.parse(await fs.readFile(options.file, 'utf-8')); + // The file must be a complete, valid config on its own; CLI flags + // are optional overrides. .strict() rejects unknown keys so typos + // surface instead of being silently ignored. + fileOptions = AuthorizationServerOptionsSchema.strict().parse(raw); + } catch (error) { + if (error instanceof ZodError) { + const details = error.issues + .map((e) => ` ${e.path.join('.') || '(root)'}: ${e.message}`) + .join('\n'); + console.error( + `Invalid settings file '${options.file}':\n${details}` + ); + } else { + console.error( + `Failed to read settings file '${options.file}': ` + + (error instanceof Error ? error.message : String(error)) + ); + } + process.exit(1); + } + } + if (!fileOptions && !options.url) { + console.error('error: must provide --url or --file'); + process.exit(1); + } + // CLI flags override file values; undefined CLI values must not clobber file values + const merged = { + ...fileOptions, + ...Object.fromEntries( + Object.entries(options).filter(([, v]) => v !== undefined) + ) + }; + const validated = AuthorizationServerOptionsSchema.parse(merged); + const verbose = options.verbose ?? false; + const outputDir = options.outputDir; + const specVersionFilter = options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; + + // If a single scenario is specified, run just that one + if (validated.scenario) { + const details: Record = {}; + const result = await runAuthorizationServerConformanceTest( + validated, + validated.scenario, + details, + outputDir, + specVersionFilter + ); + + const { failed } = printAuthorizationServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + + process.exit(failed > 0 ? 1 : 0); + } + + let scenarios: string[]; + scenarios = listClientScenariosForAuthorizationServer(); + if (specVersionFilter) { + scenarios = filterScenariosBySpecVersion( + scenarios, + specVersionFilter, + 'authorization' + ); + } + console.log( + `Running test (${scenarios.length} scenarios) against ${validated.url}\n` + ); + + const allResults: { scenario: string; checks: ConformanceCheck[] }[] = []; + const details: Record = {}; + for (const scenarioName of scenarios) { + console.log(`\n=== Running scenario: ${scenarioName} ===`); + try { + const result = await runAuthorizationServerConformanceTest( + validated, + scenarioName, + details, + outputDir, + specVersionFilter + ); + if ( + result.checks[0].status === 'SUCCESS' && + result.checks[0].details + ) { + details[scenarioName] = result.checks[0].details; + } + allResults.push({ scenario: scenarioName, checks: result.checks }); + } catch (error) { + console.error(`Failed to run scenario ${scenarioName}:`, error); + allResults.push({ + scenario: scenarioName, + checks: [ + { + id: scenarioName, + name: scenarioName, + description: 'Failed to run scenario', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + error instanceof Error ? error.message : String(error) + } + ] + }); + } + } + const { totalFailed } = printAuthorizationServerSummary(allResults); + process.exit(totalFailed > 0 ? 1 : 0); + } catch (error) { + if (error instanceof ZodError) { + console.error('Validation error:'); + error.issues.forEach((err) => { + console.error(` ${err.path.join('.')}: ${err.message}`); + }); + console.error('\nAvailable authorization server scenarios:'); + listClientScenariosForAuthorizationServer().forEach((s) => + console.error(` - ${s}`) + ); + process.exit(1); + } + console.error('Authorization server test error:', error); + process.exit(1); + } + }); + // Tier check command program.addCommand(createTierCheckCommand()); +// New SEP scaffolding command +program.addCommand(createNewSepCommand()); + +// SDK command - run local conformance against an SDK at a specific ref +program.addCommand(createSdkCommand()); + +// SEP traceability manifest command +program.addCommand(createTraceabilityCommand()); + // List scenarios command program .command('list') - .description('List available test scenarios') + .description( + `List available test scenarios. Requirement sets available: ${listRequirementRevisions().join(', ') || 'none'}` + ) .option('--client', 'List client scenarios') .option('--server', 'List server scenarios') + .option('--authorization', 'List authorization server scenarios') .option( '--spec-version ', 'Filter scenarios by spec version (cumulative for date versions)' ) + .option( + '--requirements ', + 'List exactly what a spec revision requires, frozen at its release' + ) .action((options) => { const specVersionFilter = options.specVersion ? resolveSpecVersion(options.specVersion) : undefined; - if (options.server || (!options.client && !options.server)) { + if (options.requirements !== undefined) { + for (const rev of String(options.requirements).split(',')) { + listOneRequirementSet(rev.trim(), options.specVersion); + } + return; + } + + if ( + options.server || + (!options.client && !options.server && !options.authorization) + ) { console.log('Server scenarios (test against a server):'); let serverScenarios = listClientScenarios(); if (specVersionFilter) { @@ -478,7 +977,10 @@ program }); } - if (options.client || (!options.client && !options.server)) { + if ( + options.client || + (!options.client && !options.server && !options.authorization) + ) { if (options.server || (!options.client && !options.server)) { console.log(''); } @@ -496,6 +998,31 @@ program console.log(` - ${s}${v ? ` [${v}]` : ''}`); }); } + + if ( + options.authorization || + (!options.authorization && !options.server && !options.client) + ) { + if (!(options.authorization && !options.server && !options.client)) { + console.log(''); + } + console.log( + 'Authorization server scenarios (test against an authorization server):' + ); + let authorizationServerScenarios = + listClientScenariosForAuthorizationServer(); + if (specVersionFilter) { + authorizationServerScenarios = filterScenariosBySpecVersion( + authorizationServerScenarios, + specVersionFilter, + 'authorization' + ); + } + authorizationServerScenarios.forEach((s) => { + const v = getScenarioSpecVersions(s); + console.log(` - ${s}${v ? ` [${v}]` : ''}`); + }); + } }); program.parse(); diff --git a/src/mock-server/index.ts b/src/mock-server/index.ts new file mode 100644 index 0000000..7c8470e --- /dev/null +++ b/src/mock-server/index.ts @@ -0,0 +1,65 @@ +/** + * Version-aware mock-server abstraction for client-conformance scenarios. + * + * A `MockServer` is the HTTP server a client-under-test connects to. The + * lifecycle scaffold (initialize handshake vs per-request `_meta` validation) + * is supplied by the runner based on `--spec-version`; the scenario only + * provides per-method handlers and asserts on the recorded requests. + * + * This is the client-conformance mirror of `Connection` in `../connection`. + */ + +import type { SpecVersion } from '../types'; +import type { JSONRPCRequest } from '../spec-types/2025-11-25'; + +/** + * Per-method response handlers. Called with the request `params` object; + * return value becomes the JSON-RPC `result`. Throw to produce an error + * response. + */ +export type RequestHandlers = Record< + string, + ( + params: Record, + request: JSONRPCRequest + ) => unknown | Promise +>; + +export interface MockServer { + /** Full URL of the `/mcp` endpoint. */ + url: string; + /** Base URL (no `/mcp` suffix), for scenarios that serve sibling routes. */ + baseUrl: string; + /** + * Every JSON-RPC request the client sent, in arrival order, excluding the + * lifecycle preamble (`initialize` / `notifications/initialized` under the + * stateful impl; `server/discover` under stateless). Recording happens + * before validation, so requests the server rejects (e.g. missing header + * or `_meta`) still appear here. + */ + readonly recorded: JSONRPCRequest[]; + close(): Promise; +} + +/** + * Per-run context handed to `Scenario.start()`. The runner constructs this + * from the resolved `--spec-version`. + */ +export interface ScenarioContext { + specVersion: SpecVersion; + /** + * Create a version-appropriate mock server. Scenarios that test the + * lifecycle itself (initialize, SSE-retry) bypass this and build a raw + * `http.createServer`. + */ + createServer(handlers: RequestHandlers): Promise; +} + +export { createServerStateful } from './stateful'; +export { + createServerStateless, + validateStatelessRequest, + withRequiredDraftResultFields, + CACHEABLE_RESULT_METHODS +} from './stateless'; +export { createServerFor } from './select'; diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts new file mode 100644 index 0000000..43ecca6 --- /dev/null +++ b/src/mock-server/mock-server.test.ts @@ -0,0 +1,640 @@ +import { describe, it, expect } from 'vitest'; +import { createServerFor } from './select'; +import { createServerStateful } from './stateful'; +import { + createServerStateless, + validateStatelessRequest, + withRequiredDraftResultFields, + CACHEABLE_RESULT_METHODS +} from './stateless'; +import { STATELESS_SPEC_VERSIONS } from '../connection/select'; +import { LATEST_SPEC_VERSION, DRAFT_PROTOCOL_VERSION } from '../types'; +import { takeWireViolations } from '../validation/wire-schema'; + +const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 't', version: '1' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; +const headers = { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION }; + +async function post(url: string, body: object, headers: object = {}) { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body) + }); + return { status: r.status, body: await r.json() }; +} + +describe('validateStatelessRequest', () => { + it('returns reject for invalid requests', () => { + const v = validateStatelessRequest( + { + headers: {}, + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + } + }, + {}, + [DRAFT_PROTOCOL_VERSION] + ); + expect(v).toMatchObject({ kind: 'reject', status: 400 }); + }); + + it('returns handled for server/discover', () => { + const v = validateStatelessRequest( + { + headers, + body: { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + } + }, + {}, + [DRAFT_PROTOCOL_VERSION] + ); + expect(v).toMatchObject({ kind: 'handled', status: 200 }); + }); + + it('returns route for valid non-discover requests', () => { + const v = validateStatelessRequest( + { + headers, + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + } + }, + {}, + [DRAFT_PROTOCOL_VERSION] + ); + expect(v).toMatchObject({ kind: 'route', id: 1, method: 'tools/list' }); + }); + + it('includes the draft-required result members on the server/discover result', () => { + const v = validateStatelessRequest( + { + headers, + body: { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + } + }, + {}, + [DRAFT_PROTOCOL_VERSION] + ); + expect(v).toMatchObject({ + kind: 'handled', + body: { + result: { resultType: 'complete', ttlMs: 0, cacheScope: 'private' } + } + }); + }); + + it('rejects versions outside the supported list with -32022 and echoes it', () => { + const v = validateStatelessRequest( + { + headers: { 'mcp-protocol-version': '2099-01-01' }, + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + ...meta, + 'io.modelcontextprotocol/protocolVersion': '2099-01-01' + } + } + } + }, + {}, + [DRAFT_PROTOCOL_VERSION] + ); + expect(v).toMatchObject({ + kind: 'reject', + status: 400, + body: { + error: { + code: -32022, + data: { supported: [DRAFT_PROTOCOL_VERSION], requested: '2099-01-01' } + } + } + }); + }); +}); + +describe('withRequiredDraftResultFields', () => { + it('stamps resultType "complete" when the handler omitted it', () => { + expect( + withRequiredDraftResultFields('tools/call', { content: [] }) + ).toEqual({ resultType: 'complete', content: [] }); + }); + + it('adds ttlMs and cacheScope for every cacheable method', () => { + for (const method of CACHEABLE_RESULT_METHODS) { + expect(withRequiredDraftResultFields(method, {})).toEqual({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + }); + } + }); + + it('does not add caching hints to non-cacheable results', () => { + const result = withRequiredDraftResultFields('tools/call', { + content: [] + }); + expect(result).not.toHaveProperty('ttlMs'); + expect(result).not.toHaveProperty('cacheScope'); + }); + + it('preserves members the handler set itself', () => { + expect( + withRequiredDraftResultFields('tools/call', { + resultType: 'input_required', + inputRequests: {} + }) + ).toMatchObject({ resultType: 'input_required' }); + expect( + withRequiredDraftResultFields('tools/list', { + ttlMs: 5000, + cacheScope: 'public', + tools: [] + }) + ).toMatchObject({ ttlMs: 5000, cacheScope: 'public' }); + }); + + it('passes non-object results through untouched', () => { + expect(withRequiredDraftResultFields('tools/call', null)).toBeNull(); + expect(withRequiredDraftResultFields('tools/call', [1])).toEqual([1]); + }); +}); + +describe('createServerFor', () => { + it('returns a stateful factory for dated 2025-x versions', async () => { + const srv = await createServerFor('2025-11-25')({ + 'tools/list': () => ({ tools: [] }) + }); + try { + // Only the stateful (initialize handshake) impl answers initialize. + const r = await fetch(srv.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 't', version: '1' } + } + }) + }); + expect(r.status).toBe(200); + } finally { + await srv.close(); + } + }); + it('returns a stateless factory bound to the requested version', async () => { + const srv = await createServerFor(DRAFT_PROTOCOL_VERSION)({}); + try { + const { status, body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + }, + headers + ); + expect(status).toBe(200); + expect(body.result.supportedVersions).toEqual([DRAFT_PROTOCOL_VERSION]); + } finally { + await srv.close(); + } + }); +}); + +describe('createServerStateless', () => { + it('rejects requests missing the version header', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post(srv.url, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }); + expect(status).toBe(400); + expect(body.error.code).toBe(-32020); + } finally { + await srv.close(); + } + }); + + it('rejects requests missing required _meta keys', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post( + srv.url, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(400); + expect(body.error.code).toBe(-32602); + // The request is deliberately schema-invalid (missing params._meta). + expect(takeWireViolations().violations).toHaveLength(1); + } finally { + await srv.close(); + } + }); + + it('serves server/discover, defaulting to every known stateless version', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(200); + expect(body.result.supportedVersions).toEqual(STATELESS_SPEC_VERSIONS); + // Spec PR #3002: server identity lives in the result `_meta`. + expect(body.result._meta['io.modelcontextprotocol/serverInfo'].name).toBe( + 'conformance-mock-server' + ); + } finally { + await srv.close(); + } + }); + + it('accepts the version it was created for and rejects others with -32022', async () => { + const srv = await createServerStateless( + { 'tools/list': () => ({ tools: [] }) }, + DRAFT_PROTOCOL_VERSION + ); + try { + const accepted = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(accepted.status).toBe(200); + + const rejected = await post( + srv.url, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { + _meta: { + ...meta, + 'io.modelcontextprotocol/protocolVersion': '2099-01-01' + } + } + }, + { 'mcp-protocol-version': '2099-01-01' } + ); + expect(rejected.status).toBe(400); + expect(rejected.body.error.code).toBe(-32022); + expect(rejected.body.error.data.supported).toEqual([ + DRAFT_PROTOCOL_VERSION + ]); + expect(rejected.body.error.data.requested).toBe('2099-01-01'); + } finally { + await srv.close(); + } + }); + + it('routes to handlers and records requests', async () => { + const srv = await createServerStateless({ + 'tools/list': () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' } }] + }) + }); + try { + const { body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(body.result.tools[0].name).toBe('x'); + expect(srv.recorded).toHaveLength(1); + expect(srv.recorded[0].method).toBe('tools/list'); + } finally { + await srv.close(); + } + }); + + it('records requests rejected by validation (missing _meta)', async () => { + const srv = await createServerStateless({}); + try { + const { status } = await post( + srv.url, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(400); + expect(srv.recorded.map((r) => r.method)).toEqual(['tools/list']); + // The request is deliberately schema-invalid (missing params._meta). + expect(takeWireViolations().violations).toHaveLength(1); + } finally { + await srv.close(); + } + }); + + it('does not record the server/discover preamble', async () => { + const srv = await createServerStateless({}); + try { + const { status } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(200); + expect(srv.recorded).toHaveLength(0); + } finally { + await srv.close(); + } + }); + + it('stamps the draft-required result members onto handler results', async () => { + const srv = await createServerStateless({ + 'tools/list': () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' } }] + }), + 'tools/call': () => ({ content: [{ type: 'text', text: 'ok' }] }) + }); + try { + const list = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(list.body.result).toMatchObject({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [{ name: 'x' }] + }); + + const call = await post( + srv.url, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { _meta: meta, name: 'x' } + }, + headers + ); + expect(call.body.result).toMatchObject({ resultType: 'complete' }); + expect(call.body.result).not.toHaveProperty('ttlMs'); + expect(call.body.result).not.toHaveProperty('cacheScope'); + } finally { + await srv.close(); + } + }); + + it('preserves a resultType the handler set itself', async () => { + const srv = await createServerStateless({ + 'tools/call': () => ({ resultType: 'input_required', inputRequests: {} }) + }); + try { + const { body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { _meta: meta, name: 'x' } + }, + headers + ); + expect(body.result.resultType).toBe('input_required'); + } finally { + await srv.close(); + } + }); + + it('returns -32601 for unknown methods', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post( + srv.url, + { jsonrpc: '2.0', id: 1, method: 'nope', params: { _meta: meta } }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(404); + expect(body.error.code).toBe(-32601); + } finally { + await srv.close(); + } + }); +}); + +describe('createServerStateful', () => { + async function postInit(url: string) { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 't', version: '1' } + } + }) + }); + return { + status: r.status, + contentType: r.headers.get('content-type') ?? '' + }; + } + + it('accepts initialize and routes to handlers, recording non-preamble', async () => { + const srv = await createServerStateful( + { 'tools/list': () => ({ tools: [] }) }, + LATEST_SPEC_VERSION + ); + try { + // SDK transport in sessionless mode handles initialize internally; we + // can drive it via the SDK Client. + const { Client } = + await import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = + await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const client = new Client( + { name: 't', version: '1' }, + { capabilities: {} } + ); + await client.connect(new StreamableHTTPClientTransport(new URL(srv.url))); + await client.listTools(); + await client.close(); + expect(srv.recorded.map((r) => r.method)).toEqual(['tools/list']); + } finally { + await srv.close(); + } + }); + + it('derives capabilities from handler keys; non-tools handler does not 500 initialize', async () => { + const srv = await createServerStateful( + { 'prompts/list': () => ({ prompts: [] }) }, + LATEST_SPEC_VERSION + ); + try { + const { status, contentType } = await postInit(srv.url); + expect(status).toBe(200); + expect(contentType).not.toContain('text/html'); + } finally { + await srv.close(); + } + }); + + it('records requests for unregistered methods (parity with stateless)', async () => { + const srv = await createServerStateful( + { 'tools/list': () => ({ tools: [] }) }, + LATEST_SPEC_VERSION + ); + try { + const { Client } = + await import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = + await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const { ResultSchema } = + await import('@modelcontextprotocol/sdk/types.js'); + const client = new Client( + { name: 't', version: '1' }, + { capabilities: {} } + ); + await client.connect(new StreamableHTTPClientTransport(new URL(srv.url))); + await client + .request( + { method: 'tools/call', params: { name: 'nope' } }, + ResultSchema + ) + .catch(() => {}); + await client.close(); + expect(srv.recorded.map((r) => r.method)).toContain('tools/call'); + } finally { + await srv.close(); + } + }); +}); + +describe('wire violation attribution', () => { + it('does not blame the harness for the JSON-RPC-mandated id:null on error replies', async () => { + const srv = await createServerStateless({}, DRAFT_PROTOCOL_VERSION); + try { + // An id-less, _meta-less message forces a reject reply carrying + // `id: null` (JSON-RPC 2.0 when the request id cannot be determined). + await post(srv.url, { jsonrpc: '2.0', method: 'tools/call' }, headers); + const { violations } = takeWireViolations(); + expect(violations.filter((v) => v.origin === 'harness')).toEqual([]); + } finally { + await srv.close(); + } + }); + + it('does not record an implementation violation for an unparsed body (wrong Content-Type)', async () => { + const stateless = await createServerStateless({}, DRAFT_PROTOCOL_VERSION); + const stateful = await createServerStateful({}, LATEST_SPEC_VERSION); + try { + for (const url of [stateless.url, stateful.url]) { + await fetch(url, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: '{"jsonrpc":"2.0","id":1,"method":"ping"}' + }); + } + const { violations } = takeWireViolations(); + expect(violations.filter((v) => v.origin === 'implementation')).toEqual( + [] + ); + } finally { + await stateless.close(); + await stateful.close(); + } + }); + + it('validates stateful client traffic against the version the client declares, not the run version', async () => { + const srv = await createServerStateful({}, '2025-11-25'); + try { + const batch = [{ jsonrpc: '2.0', id: 1, method: 'ping' }]; + // Batch arrays are only legal at 2025-03-26; a client that negotiated + // that version declares it on the request. + await post(srv.url, batch, { 'mcp-protocol-version': '2025-03-26' }); + const negotiated = takeWireViolations(); + expect( + negotiated.violations.filter((v) => v.origin === 'implementation') + ).toEqual([]); + + // No header: the header only exists from 2025-06-18, whose spec says + // to assume 2025-03-26 — where the batch is legal. + await post(srv.url, batch); + const headerless = takeWireViolations(); + expect( + headerless.violations.filter((v) => v.origin === 'implementation') + ).toEqual([]); + + await post(srv.url, batch, { 'mcp-protocol-version': '2025-11-25' }); + const runVersion = takeWireViolations(); + expect( + runVersion.violations.some( + (v) => + v.origin === 'implementation' && + v.errors.some((e) => e.includes('batch')) + ) + ).toBe(true); + } finally { + await srv.close(); + } + }); +}); diff --git a/src/mock-server/select.ts b/src/mock-server/select.ts new file mode 100644 index 0000000..2a836f1 --- /dev/null +++ b/src/mock-server/select.ts @@ -0,0 +1,13 @@ +import type { SpecVersion } from '../types'; +import type { MockServer, RequestHandlers } from './index'; +import { isStatefulVersion } from '../connection/select'; +import { createServerStateful } from './stateful'; +import { createServerStateless } from './stateless'; + +export function createServerFor( + specVersion: SpecVersion +): (handlers: RequestHandlers) => Promise { + return isStatefulVersion(specVersion) + ? (handlers) => createServerStateful(handlers, specVersion) + : (handlers) => createServerStateless(handlers, specVersion); +} diff --git a/src/mock-server/stateful.ts b/src/mock-server/stateful.ts new file mode 100644 index 0000000..0a80b7f --- /dev/null +++ b/src/mock-server/stateful.ts @@ -0,0 +1,181 @@ +/** + * Stateful mock server: 2025-x lifecycle (initialize handshake). + * + * Backed by the SDK's `Server` + `StreamableHTTPServerTransport` so we don't + * reimplement the handshake or SSE response framing. The SDK is the scaffold + * here, not the system-under-test; the client-under-test connecting to this + * mock is what's being verified. + */ + +import express from 'express'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { JSONRPCRequest } from '../spec-types/2025-11-25'; +import { isSpecVersion, type SpecVersion } from '../types'; +import { validateWireMessage } from '../validation/wire-schema'; +import type { MockServer, RequestHandlers } from './index'; + +const CAPABILITY_BY_PREFIX: Record = { + tools: 'tools', + prompts: 'prompts', + resources: 'resources', + completion: 'completions', + logging: 'logging' +}; + +/** + * Derive the server `capabilities` object from the registered handler method + * names so the SDK's `assertRequestHandlerCapability` gate is always satisfied. + * Shared with the stateless impl for `server/discover`. + */ +export function capabilitiesFromHandlers( + handlers: RequestHandlers +): Record { + const out: Record = {}; + for (const method of Object.keys(handlers)) { + const cap = CAPABILITY_BY_PREFIX[method.split('/')[0]]; + if (cap) out[cap] = {}; + } + return out; +} + +export async function createServerStateful( + handlers: RequestHandlers, + specVersion: SpecVersion +): Promise { + const recorded: JSONRPCRequest[] = []; + const capabilities = capabilitiesFromHandlers(handlers); + + // Fresh SDK Server per HTTP request (the SDK transport is single-shot in + // sessionless mode after GHSA-345p-7cg4-v4c7). + function newServer(): Server { + const server = new Server( + { name: 'conformance-mock-server', version: '1.0.0' }, + { capabilities } + ); + for (const [method, handler] of Object.entries(handlers)) { + // The SDK's setRequestHandler matches by parsing against the schema's + // method literal; build a minimal schema so any method string works. + const schema = z.object({ + method: z.literal(method), + params: z.unknown().optional() + }); + server.setRequestHandler(schema, async (request) => { + try { + const result = (await handler( + (request.params ?? {}) as Record, + request as JSONRPCRequest + )) as Record; + // The SDK builds the response envelope; validate the + // harness-authored result against its typed definition. + validateWireMessage( + specVersion, + { jsonrpc: '2.0', id: 0, result }, + { + origin: 'harness', + context: `stateful mock result for '${method}'`, + requestMethod: method + } + ); + return result; + } catch (e) { + if (e instanceof McpError) throw e; + throw new McpError( + ErrorCode.InternalError, + e instanceof Error ? e.message : String(e) + ); + } + }); + } + return server; + } + + const app = express(); + app.use(express.json()); + + app.post('/mcp', async (req, res) => { + // Record every JSON-RPC request the client sends (excluding the lifecycle + // preamble) at the HTTP layer so unregistered methods are captured too, + // matching the stateless impl and the MockServer.recorded contract. + const body = req.body; + // An unparsed body (wrong or missing Content-Type) is a transport fault + // the SDK transport rejects itself, not a JSON-RPC message to validate. + if (body !== undefined) { + // Clients may legitimately negotiate a different protocol version than + // the run's (the README permits ignoring the forwarded --spec-version), + // and they declare it on every post-initialize request; validate their + // traffic against the version they actually speak. + const headerVersion = req.headers['mcp-protocol-version']; + // Absent header: the header only exists from 2025-06-18, whose spec + // says to assume 2025-03-26 — a header-less legacy client is not a + // violation of the run version. + const inboundVersion = isSpecVersion(headerVersion) + ? headerVersion + : headerVersion === undefined + ? '2025-03-26' + : specVersion; + validateWireMessage(inboundVersion, body, { + origin: 'implementation', + context: `client request '${body?.method ?? '(unknown)'}' to stateful mock` + }); + } + if ( + body?.method && + body.method !== 'initialize' && + body.method !== 'notifications/initialized' + ) { + recorded.push(body as JSONRPCRequest); + } + try { + const server = newServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + // Register cleanup before handing the request to the transport so the + // pair is torn down even when handleRequest throws. + res.on('close', () => { + transport.close(); + server.close(); + }); + await transport.handleRequest(req, res, req.body); + } catch (e) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + id: req.body?.id ?? null, + error: { code: -32603, message: String(e) } + }); + } + } + }); + + return listen(app, recorded); +} + +function listen( + app: express.Application, + recorded: JSONRPCRequest[] +): Promise { + return new Promise((resolve, reject) => { + const httpServer = app.listen(0); + httpServer.on('error', reject); + httpServer.on('listening', () => { + const addr = httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const baseUrl = `http://localhost:${port}`; + resolve({ + url: `${baseUrl}/mcp`, + baseUrl, + recorded, + close: () => + new Promise((res) => { + httpServer.closeAllConnections?.(); + httpServer.close(() => res()); + }) + }); + }); + }); +} diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts new file mode 100644 index 0000000..3d06c86 --- /dev/null +++ b/src/mock-server/stateless.ts @@ -0,0 +1,285 @@ +/** + * Stateless mock server: 2026-x lifecycle (SEP-2575). + * + * No initialize handshake. Validates `_meta` (protocolVersion / + * clientCapabilities — `clientInfo` is a SHOULD since spec PR #3002 and is + * never required) and the `MCP-Protocol-Version` header on every request, + * serves `server/discover`, and routes other methods to the supplied handlers. + * Implemented with raw express so it can front-run SDK support. + */ + +import express from 'express'; +import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types'; +import type { JSONRPCRequest } from '../spec-types/2025-11-25'; +import type { MockServer, RequestHandlers } from './index'; +import { STATELESS_SPEC_VERSIONS } from '../connection/select'; +import { validateWireMessage } from '../validation/wire-schema'; +import { capabilitiesFromHandlers } from './stateful'; + +/** + * The required per-request `_meta` keys. `io.modelcontextprotocol/clientInfo` + * is deliberately absent: spec PR #3002 demoted it to SHOULD, so a request + * without it is valid. + */ +const META_KEYS = [ + 'io.modelcontextprotocol/protocolVersion', + 'io.modelcontextprotocol/clientCapabilities' +] as const; + +/** + * Operations whose results the 2026-07-28 revision marks cacheable: servers + * MUST include the caching hints `ttlMs` and `cacheScope` on these results + * (draft `CacheableResult`, server/utilities/caching). + */ +export const CACHEABLE_RESULT_METHODS: ReadonlySet = new Set([ + 'server/discover', + 'tools/list', + 'prompts/list', + 'resources/list', + 'resources/templates/list', + 'resources/read' +]); + +/** + * Fill in the result members the 2026-07-28 revision requires of servers when + * the handler did not set them itself: every result MUST carry `resultType`, + * and results of the cacheable operations MUST also carry `ttlMs` and + * `cacheScope`. Members the handler set are preserved (e.g. a handler may + * return `resultType: 'input_required'`); only absent (or undefined) ones are + * filled. A scenario that needs to send a deliberately non-conformant result + * must build its own server instead of routing through this mock. + */ +export function withRequiredDraftResultFields( + method: string, + result: unknown +): unknown { + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + return result; + } + const stamped: Record = { ...result }; + stamped.resultType ??= 'complete'; + if (CACHEABLE_RESULT_METHODS.has(method)) { + stamped.ttlMs ??= 0; + stamped.cacheScope ??= 'private'; + } + return stamped; +} + +type IncomingHeaders = Record; + +export type StatelessValidation = + | { kind: 'reject'; status: number; body: object } + | { kind: 'handled'; status: number; body: object } + | { + kind: 'route'; + id: string | number | null; + method: string; + params: Record; + }; + +/** + * Shared SEP-2575 request validation: header presence, `_meta` 3-key check, + * header/`_meta` version match, version-supported check, and `server/discover` + * handling. Returns `reject` when validation failed, `handled` when the + * request was valid and already answered (`server/discover`), and `route` + * when the caller should dispatch to its own handlers. Consumers write + * `res.status(v.status).json(v.body)` for `reject` and `handled` alike and + * route only on `route`. + * + * Exported so any mock server that needs a stateless `/mcp` route (e.g. + * `auth/helpers/createServer.ts`) uses the same validation as this module. + * + * `supportedVersions` is the list of wire protocolVersion strings this + * endpoint accepts; anything else is rejected with -32022 carrying + * `{ supported, requested }` in the error data, and the list is echoed in + * the `server/discover` result. + */ +export function validateStatelessRequest( + req: { headers: IncomingHeaders; body: unknown }, + capabilities: Record, + supportedVersions: readonly string[] +): StatelessValidation { + const body = (req.body ?? {}) as Record; + const id = (body.id ?? null) as string | number | null; + const method = body.method as string; + const params = (body.params ?? {}) as Record; + const meta = params._meta as Record | undefined; + + const reject = (status: number, code: number, message: string) => + ({ + kind: 'reject', + status, + body: { jsonrpc: '2.0', id, error: { code, message } } + }) as const; + + const headerVersion = req.headers['mcp-protocol-version']; + if (!headerVersion) { + return reject(400, -32020, 'Missing MCP-Protocol-Version header'); + } + const missing = META_KEYS.filter((k) => meta?.[k] === undefined); + if (missing.length > 0) { + return reject( + 400, + -32602, + `Invalid params: missing _meta keys: ${missing.join(', ')}` + ); + } + if (meta?.[META_KEYS[0]] !== headerVersion) { + return reject( + 400, + -32020, + 'MCP-Protocol-Version header does not match _meta.protocolVersion' + ); + } + if ( + typeof headerVersion !== 'string' || + !supportedVersions.includes(headerVersion) + ) { + return { + kind: 'reject', + status: 400, + body: { + jsonrpc: '2.0', + id, + error: { + code: -32022, + message: 'Unsupported protocol version', + data: { + supported: supportedVersions, + requested: String(headerVersion) + } + } + } + }; + } + if (method === 'server/discover') { + return { + kind: 'handled', + status: 200, + body: { + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, { + supportedVersions, + capabilities, + // Spec PR #3002: server identity lives in the result `_meta`, not + // the DiscoverResult body. + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'conformance-mock-server', + version: '1.0.0' + } + } + }) + } + }; + } + return { kind: 'route', id, method, params }; +} + +/** + * When `specVersion` is given (the runner's resolved `--spec-version`), the + * server accepts exactly that version. Without it, every known stateless + * version is accepted. + */ +export async function createServerStateless( + handlers: RequestHandlers, + specVersion?: SpecVersion +): Promise { + const recorded: JSONRPCRequest[] = []; + const capabilities = capabilitiesFromHandlers(handlers); + const supportedVersions: readonly string[] = specVersion + ? [specVersion] + : STATELESS_SPEC_VERSIONS; + const wireVersion = specVersion ?? DRAFT_PROTOCOL_VERSION; + + const app = express(); + app.use(express.json()); + + app.post('/mcp', async (req, res) => { + // Record every JSON-RPC request the client sends (excluding the + // `server/discover` lifecycle preamble) before validation so rejected + // requests are captured too, matching the stateful impl and the + // MockServer.recorded contract. + const body = req.body as Record | undefined; + // An unparsed body (wrong or missing Content-Type) is a transport fault + // surfaced by the HTTP-level checks, not a JSON-RPC message to validate. + if (body !== undefined) { + validateWireMessage(wireVersion, req.body, { + origin: 'implementation', + context: `client request '${body?.method ?? '(unknown)'}' to stateless mock` + }); + } + if (body?.method && body.method !== 'server/discover') { + recorded.push(req.body as JSONRPCRequest); + } + const sendJson = (status: number, payload: object, method?: string) => { + // JSON-RPC 2.0 mandates `id: null` on error replies when the request id + // could not be determined, but the spec's RequestId is string|integer; + // validate with a placeholder id so the mandated null is not recorded + // as a harness violation (mirrors connection/stateless.ts's + // withNullIdCarveOut). + const raw = payload as Record; + const validated = + raw.error !== undefined && raw.id === null + ? { ...raw, id: 0 } + : payload; + validateWireMessage(wireVersion, validated, { + origin: 'harness', + context: `stateless mock response${method ? ` to '${method}'` : ''}`, + requestMethod: method + }); + return res.status(status).json(payload); + }; + const v = validateStatelessRequest(req, capabilities, supportedVersions); + if (v.kind !== 'route') { + return sendJson( + v.status, + v.body, + v.kind === 'handled' ? (body?.method as string) : undefined + ); + } + const { id, method, params } = v; + const error = (status: number, code: number, message: string) => + sendJson(status, { jsonrpc: '2.0', id, error: { code, message } }); + + const handler = handlers[method]; + if (!handler) { + return error(404, -32601, `Method not found: ${method}`); + } + try { + const result = await handler(params, req.body as JSONRPCRequest); + return sendJson( + 200, + { + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, result) + }, + method + ); + } catch (e) { + return error(500, -32603, e instanceof Error ? e.message : String(e)); + } + }); + + return new Promise((resolve, reject) => { + const httpServer = app.listen(0); + httpServer.on('error', reject); + httpServer.on('listening', () => { + const addr = httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const baseUrl = `http://localhost:${port}`; + resolve({ + url: `${baseUrl}/mcp`, + baseUrl, + recorded, + close: () => + new Promise((r) => { + httpServer.closeAllConnections?.(); + httpServer.close(() => r()); + }) + }); + }); + }); +} diff --git a/src/mock-server/testing.ts b/src/mock-server/testing.ts new file mode 100644 index 0000000..1e307b8 --- /dev/null +++ b/src/mock-server/testing.ts @@ -0,0 +1,17 @@ +import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { createServerFor } from './select'; +import type { ScenarioContext } from './index'; + +/** + * Build a ScenarioContext for unit tests that drive a Scenario directly. + * Defaults to the latest dated spec version (stateful lifecycle) so existing + * tests keep their pre-ScenarioContext behaviour. + */ +export function testScenarioContext( + specVersion: SpecVersion = LATEST_SPEC_VERSION +): ScenarioContext { + return { + specVersion, + createServer: (handlers) => createServerFor(specVersion)(handlers) + }; +} diff --git a/src/new-sep/index.test.ts b/src/new-sep/index.test.ts new file mode 100644 index 0000000..acc01bb --- /dev/null +++ b/src/new-sep/index.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { specPathToUrl, renderYaml } from './index'; + +describe('specPathToUrl', () => { + it('strips the docs/specification/draft/ prefix and .mdx suffix', () => { + expect(specPathToUrl('docs/specification/draft/server/resources.mdx')).toBe( + 'https://modelcontextprotocol.io/specification/draft/server/resources' + ); + }); + + it('handles nested paths', () => { + expect(specPathToUrl('docs/specification/draft/basic/lifecycle.mdx')).toBe( + 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle' + ); + }); + + it('rejects paths outside docs/specification/draft/', () => { + expect(() => + specPathToUrl('docs/specification/2025-11-25/server/x.mdx') + ).toThrow(/must start with/); + }); +}); + +describe('renderYaml', () => { + it('emits placeholder yaml in the sep-2164.yaml style', () => { + const out = renderYaml({ + sep: 9999, + specUrl: + 'https://modelcontextprotocol.io/specification/draft/server/resources' + }); + expect(out).toBe( + `sep: 9999 +spec_url: https://modelcontextprotocol.io/specification/draft/server/resources +requirements: + - check: sep-9999-todo + text: 'TODO: quote the normative sentence from the spec diff' + + - text: 'TODO: requirement that cannot be tested' + excluded: 'TODO: reason' + issue: https://github.com/modelcontextprotocol/conformance/issues/ +` + ); + }); + + it('matches the byte-shape of the real sep-2164.yaml when given its rows', () => { + const out = renderYaml({ + sep: 2164, + specUrl: + 'https://modelcontextprotocol.io/specification/draft/server/resources#error-handling', + requirements: [ + { + text: 'Servers MUST NOT return an empty contents array for a non-existent resource', + check: 'sep-2164-no-empty-contents' + }, + { + text: 'Servers SHOULD return standard JSON-RPC errors for common failure cases: Resource not found: -32602 (Invalid Params)', + check: 'sep-2164-error-code' + }, + { + text: 'clients SHOULD also accept -32002 as a resource not found error', + excluded: + 'Client-side error handling is implementation-defined; not protocol-observable' + } + ] + }); + expect(out).toBe( + `sep: 2164 +spec_url: https://modelcontextprotocol.io/specification/draft/server/resources#error-handling +requirements: + - check: sep-2164-no-empty-contents + text: 'Servers MUST NOT return an empty contents array for a non-existent resource' + - check: sep-2164-error-code + text: 'Servers SHOULD return standard JSON-RPC errors for common failure cases: Resource not found: -32602 (Invalid Params)' + + - text: 'clients SHOULD also accept -32002 as a resource not found error' + excluded: 'Client-side error handling is implementation-defined; not protocol-observable' +` + ); + }); + + it('emits per-row url: overrides', () => { + const out = renderYaml({ + sep: 1234, + specUrl: 'https://modelcontextprotocol.io/specification/draft/server/a', + requirements: [ + { text: 'from primary file', check: 'sep-1234-a' }, + { + text: 'from secondary file', + check: 'sep-1234-b', + url: 'https://modelcontextprotocol.io/specification/draft/server/b#x' + } + ] + }); + expect(out).toBe( + `sep: 1234 +spec_url: https://modelcontextprotocol.io/specification/draft/server/a +requirements: + - check: sep-1234-a + text: 'from primary file' + - check: sep-1234-b + text: 'from secondary file' + url: https://modelcontextprotocol.io/specification/draft/server/b#x +` + ); + }); + + it('escapes single quotes by doubling them', () => { + const out = renderYaml({ + sep: 1, + specUrl: 'https://example.com/x', + requirements: [{ text: "can't happen", check: 'sep-1-x' }] + }); + expect(out).toContain("text: 'can''t happen'"); + }); +}); diff --git a/src/new-sep/index.ts b/src/new-sep/index.ts new file mode 100644 index 0000000..aa50a3a --- /dev/null +++ b/src/new-sep/index.ts @@ -0,0 +1,256 @@ +import { Command } from 'commander'; +import { Octokit } from '@octokit/rest'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface RequirementRow { + text: string; + check?: string; + excluded?: string; + issue?: string; + /** Full spec URL for this requirement; overrides the file-level spec_url. */ + url?: string; +} + +const OUT_DIR = 'src/seps'; +const SPEC_PATH_PREFIX = 'docs/specification/draft/'; +const DEFAULT_SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; + +export function specPathToUrl(specPath: string): string { + if (!specPath.startsWith(SPEC_PATH_PREFIX)) { + throw new Error( + `spec path must start with "${SPEC_PATH_PREFIX}"; got: ${specPath}` + ); + } + const rest = specPath.slice(SPEC_PATH_PREFIX.length).replace(/\.mdx$/, ''); + return `https://modelcontextprotocol.io/specification/draft/${rest}`; +} + +function escapeSingleQuoted(s: string): string { + return s.replace(/'/g, "''"); +} + +function defaultPlaceholderRequirements(sep: number): RequirementRow[] { + return [ + { + text: 'TODO: quote the normative sentence from the spec diff', + check: `sep-${sep}-todo` + }, + { + text: 'TODO: requirement that cannot be tested', + excluded: 'TODO: reason', + issue: 'https://github.com/modelcontextprotocol/conformance/issues/' + } + ]; +} + +export function renderYaml(input: { + sep: number; + specUrl: string; + requirements?: RequirementRow[]; +}): string { + const reqs = input.requirements ?? defaultPlaceholderRequirements(input.sep); + const checkRows = reqs.filter((r) => r.check); + const excludedRows = reqs.filter((r) => !r.check); + + const lines: string[] = []; + lines.push(`sep: ${input.sep}`); + lines.push(`spec_url: ${input.specUrl}`); + lines.push('requirements:'); + + for (const r of checkRows) { + lines.push(` - check: ${r.check}`); + lines.push(` text: '${escapeSingleQuoted(r.text)}'`); + if (r.url) lines.push(` url: ${r.url}`); + } + + if (checkRows.length > 0 && excludedRows.length > 0) { + lines.push(''); + } + + for (const r of excludedRows) { + lines.push(` - text: '${escapeSingleQuoted(r.text)}'`); + if (r.excluded) { + lines.push(` excluded: '${escapeSingleQuoted(r.excluded)}'`); + } + if (r.issue) lines.push(` issue: ${r.issue}`); + } + + return lines.join('\n') + '\n'; +} + +async function resolveToken(explicit?: string): Promise { + let token = explicit || process.env.GITHUB_TOKEN; + if (!token) { + try { + const { execSync } = await import('child_process'); + token = execSync('gh auth token', { encoding: 'utf-8' }).trim(); + } catch { + // gh not installed or not authenticated + } + } + return token; +} + +interface SpecCandidate { + filename: string; + additions: number; +} + +async function lookupSpecPath(args: { + sep: number; + repo: string; + token: string; +}): Promise { + const [owner, repoName] = args.repo.split('/'); + if (!owner || !repoName) { + throw new Error(`Invalid --repo: ${args.repo} (expected owner/repo)`); + } + const octokit = new Octokit({ auth: args.token }); + + // SEP numbers are PR numbers in the spec repo by convention. + const prNumber = args.sep; + + const files = await octokit.paginate(octokit.pulls.listFiles, { + owner, + repo: repoName, + pull_number: prNumber, + per_page: 100 + }); + + const candidates: SpecCandidate[] = files + .filter( + (f) => + f.filename.startsWith(SPEC_PATH_PREFIX) && f.filename.endsWith('.mdx') + ) + .map((f) => ({ filename: f.filename, additions: f.additions })); + + if (candidates.length === 0) { + throw new Error( + `PR #${prNumber} in ${args.repo} does not change any ` + + `${SPEC_PATH_PREFIX}*.mdx file. Pass --spec-path to override.` + ); + } + candidates.sort((a, b) => b.additions - a.additions); + return candidates.map((c) => c.filename); +} + +export function createNewSepCommand(): Command { + return new Command('new-sep') + .description( + 'Scaffold a sep-NNNN.yaml requirement-traceability file for a new SEP' + ) + .argument('', 'SEP number, e.g. 2164') + .option( + '--spec-url ', + 'Use this spec URL verbatim (skips GitHub lookup)' + ) + .option( + '--spec-path ', + `${SPEC_PATH_PREFIX}... path to derive spec_url from (skips GitHub lookup)` + ) + .option( + '--repo ', + 'Spec repo to query for the SEP PR', + DEFAULT_SPEC_REPO + ) + .option( + '--token ', + 'GitHub token (defaults to GITHUB_TOKEN env or `gh auth token`)' + ) + .option('--force', 'Overwrite existing sep-NNNN.yaml') + .action(async (sepArg: string, options) => { + const sep = parseInt(sepArg, 10); + if (!Number.isFinite(sep) || sep <= 0 || String(sep) !== sepArg.trim()) { + console.error(`Invalid SEP number: ${sepArg}`); + process.exit(1); + } + + let specUrl: string | undefined = options.specUrl; + let specPath: string | undefined = options.specPath; + let otherSpecPaths: string[] = []; + + if (!specUrl && !specPath) { + const token = await resolveToken(options.token); + if (!token) { + console.error( + 'GitHub token required to look up the SEP PR. Either:\n' + + ' gh auth login\n' + + ' export GITHUB_TOKEN=$(gh auth token)\n' + + ' or pass --token \n' + + ' or pass --spec-url / --spec-path to skip the lookup' + ); + process.exit(1); + } + try { + const specPaths = await lookupSpecPath({ + sep, + repo: options.repo, + token + }); + specPath = specPaths[0]; + otherSpecPaths = specPaths.slice(1); + console.error(`Resolved spec path: ${specPath}`); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + + if (specPath && !specUrl) { + try { + specUrl = specPathToUrl(specPath); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + } + if (!specUrl) { + console.error('Could not resolve spec_url. Internal error.'); + process.exit(1); + } + + const outPath = path.join(OUT_DIR, `sep-${sep}.yaml`); + + await fs.mkdir(OUT_DIR, { recursive: true }); + + if (!options.force) { + try { + await fs.access(outPath); + console.error( + `${outPath} already exists. Pass --force to overwrite.` + ); + process.exit(1); + } catch { + // does not exist, OK + } + } + + const yaml = renderYaml({ sep, specUrl }); + await fs.writeFile(outPath, yaml, 'utf-8'); + + console.error(`Wrote ${outPath}`); + if (otherSpecPaths.length > 0) { + console.error( + `Note: PR also changes ${otherSpecPaths.length} other spec file(s):` + ); + for (const p of otherSpecPaths) { + console.error(` ${specPathToUrl(p)}`); + } + console.error( + ` Use a per-row "url:" for requirements from those files.` + ); + } + console.error('Next steps:'); + console.error( + ' 1. Edit the file to quote real normative sentences from the spec diff' + ); + console.error( + ' (and add a "#anchor" to spec_url if the requirement lives in a subsection)' + ); + console.error(' 2. Implement the TypeScript scenario'); + console.error( + ' 3. Register it in the appropriate suite list in src/scenarios/index.ts' + ); + }); +} diff --git a/src/requirements.test.ts b/src/requirements.test.ts new file mode 100644 index 0000000..5b9d966 --- /dev/null +++ b/src/requirements.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { readFileSync, writeFileSync } from 'fs'; +import { + filterScenariosByRequirements, + listRequirementRevisions, + loadRequirements, + notScoredScenarios, + scenariosToRun, + scoredScenarios +} from './requirements'; +import { listClientScenarios, listScenarios } from './scenarios'; + +const revisions = listRequirementRevisions(); + +describe('requirement sets', () => { + it('ships at least one revision', () => { + expect(revisions.length).toBeGreaterThan(0); + }); + + describe.each(revisions)('%s', (revision) => { + const requirements = loadRequirements(revision); + + // A requirement set is frozen, so a name it asks for that this build does + // not have means a scenario was renamed or removed out from under a shipped + // revision. That silently shrinks what conformance means, so it fails here. + it.each([ + ['server', requirements.server, listClientScenarios()], + ['client', requirements.client, listScenarios()] + ])( + 'every %s scenario exists in this build', + (_leg, required, available) => { + expect(required.filter((name) => !available.includes(name))).toEqual( + [] + ); + } + ); + + it('lists no scenario twice within a leg', () => { + for (const leg of [requirements.server, requirements.client]) { + expect(new Set(leg).size).toBe(leg.length); + } + }); + + it('keeps not-scored entries out of the required lists', () => { + const required = new Set([ + ...requirements.server, + ...requirements.client + ]); + const alsoRequired = requirements.notScored + .map((e) => e.scenario) + .filter((name) => required.has(name)); + expect(alsoRequired).toEqual([]); + }); + + it('runs the not-scored entries alongside the required ones', () => { + for (const leg of ['server', 'client'] as const) { + const run = scenariosToRun(requirements, leg); + const scored = scoredScenarios(requirements, leg); + const extra = notScoredScenarios(requirements, leg).map( + (e) => e.scenario + ); + expect(run).toEqual([...scored, ...extra]); + expect(extra.every((name) => !scored.includes(name))).toBe(true); + } + }); + }); +}); + +describe('loadRequirements', () => { + it('rejects anything that is not a revision date', () => { + expect(() => loadRequirements('../../etc/passwd')).toThrow( + /Invalid requirements revision/ + ); + expect(() => loadRequirements('latest')).toThrow( + /Invalid requirements revision/ + ); + }); + + it('rejects a date that is not a spec revision, since it also names the wire', () => { + expect(() => loadRequirements('1999-01-01')).toThrow( + /Unknown spec revision: 1999-01-01/ + ); + }); + + it('names the available revisions when a known one has no set', () => { + expect(() => loadRequirements('2025-03-26')).toThrow( + /No requirement set for 2025-03-26/ + ); + }); +}); + +describe('loadRequirements validation', () => { + // The files are the frozen contract: a malformed one must fail loudly, never + // silently shrink what conformance means. + const path = 'requirements/2026-07-28.yaml'; + const original = readFileSync(path, 'utf-8'); + afterEach(() => writeFileSync(path, original)); + + it('rejects an unknown top-level key instead of silently dropping a leg', () => { + writeFileSync(path, original.replace('\nserver:\n', '\nsever:\n')); + expect(() => loadRequirements('2026-07-28')).toThrow(/unknown key "sever"/); + }); + + it('rejects a scenario that is both scored and not_scored', () => { + writeFileSync( + path, + original.replace( + 'not_scored:', + 'not_scored:\n - scenario: tools-list\n leg: server\n reason: extension' + ) + ); + expect(() => loadRequirements('2026-07-28')).toThrow(/both scored/); + }); + + it('rejects duplicate scored entries', () => { + writeFileSync( + path, + original.replace(' - tools-list\n', ' - tools-list\n - tools-list\n') + ); + expect(() => loadRequirements('2026-07-28')).toThrow(/duplicate server/); + }); +}); + +describe('filterScenariosByRequirements', () => { + const requirements = loadRequirements(revisions[0]); + + it('returns what the revision runs, not the suite it was given', () => { + const selected = filterScenariosByRequirements( + listClientScenarios(), + requirements, + 'server' + ); + expect(selected).toEqual(scenariosToRun(requirements, 'server')); + }); + + it('runs the not-scored entries but keeps them out of the scored set', () => { + const selected = filterScenariosByRequirements( + listClientScenarios(), + requirements, + 'server' + ); + const extensions = notScoredScenarios(requirements, 'server').map( + (e) => e.scenario + ); + expect(extensions.length).toBeGreaterThan(0); + expect(extensions.every((name) => selected.includes(name))).toBe(true); + expect( + extensions.some((name) => + scoredScenarios(requirements, 'server').includes(name) + ) + ).toBe(false); + }); + + it('fails loudly when the build is missing a required scenario', () => { + expect(() => + filterScenariosByRequirements( + ['nothing-it-asks-for'], + requirements, + 'server' + ) + ).toThrow(/does not provide/); + }); +}); diff --git a/src/requirements.ts b/src/requirements.ts new file mode 100644 index 0000000..26101ce --- /dev/null +++ b/src/requirements.ts @@ -0,0 +1,252 @@ +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { parse as parseYaml } from 'yaml'; +import { ALL_SPEC_VERSIONS } from './scenarios'; + +/** + * A frozen requirement set for one specification revision: the scenarios an + * implementation must pass to conform to that revision, fixed when it shipped. + * + * This is the project's contract, not an implementation's configuration. It is + * the opposite of an expected-failures baseline, which lives in an + * implementation's own repository and records what that implementation knows it + * fails. A baselined failure is still a failure against a requirement set. + */ +export interface RequirementSet { + /** Revision this set belongs to, e.g. `2026-07-28`. */ + revision: string; + /** Scenarios `conformance server` runs against a server implementation. */ + server: string[]; + /** Scenarios `conformance client` runs against a client implementation. */ + client: string[]; + /** + * Scenarios run and reported alongside the required ones but never counted + * toward a pass rate. Three reasons qualify, and the report names which: + * an extension is optional by definition, a scenario added after the + * revision shipped is one no implementation could have been passing, and a + * pending scenario is one the suite's own reference fixture cannot pass yet + * — the implementation under test still might, so it runs for visibility. + */ + notScored: NotScored[]; +} + +export type NotScoredReason = 'extension' | 'added-after-release' | 'pending'; + +export interface NotScored { + scenario: string; + leg: Leg; + reason: NotScoredReason; + note?: string; +} + +/** + * Roles a requirement set covers. The MCP specification defines two: an MCP + * server acting as an OAuth resource server, and an MCP client acting as an + * OAuth client. Authorization-server implementation is explicitly beyond the + * specification's scope, so those scenarios are not a conformance requirement + * for anything the spec defines and have no place in a requirement set. + */ +export type Leg = 'client' | 'server'; + +const LEGS: Leg[] = ['client', 'server']; +export const REASONS: NotScoredReason[] = [ + 'extension', + 'added-after-release', + 'pending' +]; + +/** Requirement sets ship with the package; see the `files` entry in package.json. */ +function requirementsDir(): string { + return join(dirname(fileURLToPath(import.meta.url)), '..', 'requirements'); +} + +export function listRequirementRevisions(): string[] { + const dir = requirementsDir(); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => f.endsWith('.yaml')) + .map((f) => f.replace(/\.yaml$/, '')) + .sort(); +} + +function asNameList(value: unknown, field: string, revision: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { + throw new Error( + `requirements/${revision}.yaml: "${field}" must be a list of scenario names` + ); + } + return value as string[]; +} + +export function loadRequirements(revision: string): RequirementSet { + // A revision names a bundled file. Arbitrary paths are deliberately not + // accepted: a requirement set is the project's contract, so an implementation + // under test must not be able to supply its own. + if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(revision)) { + // A run has one exit code and one expected-failures baseline, so it targets + // one revision. tier-check is the aggregator and takes several at once. + throw new Error( + revision.includes(',') + ? `A run targets one revision at a time, because it has one exit code and one baseline. Run each of "${revision}" separately, or pass them together to \`tier-check --requirements\`.` + : `Invalid requirements revision: ${revision}. Expected a date such as 2026-07-28.` + ); + } + + // The revision is also the wire version its scenarios run at, so it has to be + // a protocol version this build knows, not merely a date-shaped string. + if (!(ALL_SPEC_VERSIONS as readonly string[]).includes(revision)) { + throw new Error( + `Unknown spec revision: ${revision}. Known revisions: ${ALL_SPEC_VERSIONS.join(', ')}` + ); + } + + const path = join(requirementsDir(), `${revision}.yaml`); + if (!existsSync(path)) { + const known = listRequirementRevisions(); + throw new Error( + `No requirement set for ${revision}.` + + (known.length + ? ` Available: ${known.join(', ')}` + : ' No requirement sets are bundled with this release.') + ); + } + + const parsed = parseYaml(readFileSync(path, 'utf-8')) ?? {}; + + // A frozen contract must fail loudly on anything it does not recognise: a + // typo'd key ("sever:") would otherwise silently empty a leg and the gate + // would pass having required nothing. + const KNOWN_KEYS = new Set(['server', 'client', 'not_scored']); + for (const key of Object.keys(parsed)) { + if (key === 'authorization') continue; // rejected below with its own reason + if (!KNOWN_KEYS.has(key)) { + throw new Error( + `requirements/${revision}.yaml: unknown key "${key}" (expected server, client, not_scored)` + ); + } + } + const notScoredRaw = parsed.not_scored ?? []; + if (!Array.isArray(notScoredRaw)) { + throw new Error( + `requirements/${revision}.yaml: "not_scored" must be a list` + ); + } + + if (parsed.authorization !== undefined) { + throw new Error( + `requirements/${revision}.yaml: requirement sets cover the client and server roles only. ` + + 'Authorization-server implementation is beyond the scope of the MCP specification, so it is not a conformance requirement.' + ); + } + + const server = asNameList(parsed.server, 'server', revision); + const client = asNameList(parsed.client, 'client', revision); + for (const [leg, names] of [ + ['server', server], + ['client', client] + ] as const) { + const dupes = names.filter((n, i) => names.indexOf(n) !== i); + if (dupes.length > 0) { + throw new Error( + `requirements/${revision}.yaml: duplicate ${leg} scenario(s): ${[...new Set(dupes)].join(', ')}` + ); + } + } + + return { + revision, + server, + client, + notScored: notScoredRaw.map((entry: unknown) => { + const e = entry as NotScored; + if (!e || typeof e.scenario !== 'string') { + throw new Error( + `requirements/${revision}.yaml: each not_scored entry needs a "scenario" name` + ); + } + if (!LEGS.includes(e.leg)) { + throw new Error( + `requirements/${revision}.yaml: ${e.scenario} needs a "leg" of ${LEGS.join(', ')}` + ); + } + if (!REASONS.includes(e.reason)) { + throw new Error( + `requirements/${revision}.yaml: ${e.scenario} needs a "reason" of ${REASONS.join(', ')}` + ); + } + // A half-finished promotion (a scenario both scored and not_scored) + // would run twice and be counted inconsistently across code paths. + const scoredNames = e.leg === 'server' ? server : client; + if (scoredNames.includes(e.scenario)) { + throw new Error( + `requirements/${revision}.yaml: ${e.scenario} is both scored (${e.leg}) and not_scored — finish the promotion by removing one entry` + ); + } + return { + scenario: e.scenario, + leg: e.leg, + reason: e.reason, + note: e.note + }; + }) + }; +} + +/** Scenarios that count toward the pass rate for a leg. */ +export function scoredScenarios( + requirements: RequirementSet, + leg: Leg +): string[] { + return requirements[leg]; +} + +/** Scenarios run and reported for a leg but excluded from the pass rate. */ +export function notScoredScenarios( + requirements: RequirementSet, + leg: Leg +): NotScored[] { + return requirements.notScored.filter((entry) => entry.leg === leg); +} + +/** Everything a leg runs: what it scores, plus what it only reports. */ +export function scenariosToRun( + requirements: RequirementSet, + leg: Leg +): string[] { + return [ + ...scoredScenarios(requirements, leg), + ...notScoredScenarios(requirements, leg).map((e) => e.scenario) + ]; +} + +/** + * Narrow a scenario list to the revision's requirements. + * + * Names the requirement set asks for that this build does not have are an + * error: the set is frozen, so a missing name means the suite renamed or + * removed a scenario out from under it, and silently scoring a smaller set + * would understate what conformance means. + */ +export function filterScenariosByRequirements( + allScenarios: string[], + requirements: RequirementSet, + leg: Leg +): string[] { + const scored = new Set(scoredScenarios(requirements, leg)); + const available = new Set(allScenarios); + const wanted = scenariosToRun(requirements, leg); + // Only the scored names are a contract violation when absent. A not-scored + // entry can legitimately postdate the build being run, which is the whole + // reason it is listed separately. + const missing = wanted.filter( + (name) => !available.has(name) && scored.has(name) + ); + if (missing.length > 0) { + throw new Error( + `requirements/${requirements.revision}.yaml lists ${leg} scenarios that this build does not provide: ${missing.join(', ')}` + ); + } + return wanted.filter((name) => available.has(name)); +} diff --git a/src/runner/authorization-server.ts b/src/runner/authorization-server.ts new file mode 100644 index 0000000..15d0ad5 --- /dev/null +++ b/src/runner/authorization-server.ts @@ -0,0 +1,128 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { ConformanceCheck, SpecVersion } from '../types'; +import { + getClientScenarioForAuthorizationServer, + matchesSpecVersion +} from '../scenarios'; +import { createResultDir, formatPrettyChecks } from './utils'; +import { AuthorizationServerOptions } from '../schemas'; + +export async function runAuthorizationServerConformanceTest( + options: AuthorizationServerOptions, + scenarioName: string, + details: Record, + outputDir?: string, + specVersion?: SpecVersion +): Promise<{ + checks: ConformanceCheck[]; + resultDir?: string; + scenarioDescription: string; +}> { + let resultDir: string | undefined; + + if (outputDir) { + resultDir = createResultDir( + outputDir, + scenarioName, + 'authorization-server' + ); + await fs.mkdir(resultDir, { recursive: true }); + } + + // Scenario is guaranteed to exist by CLI validation + const scenario = getClientScenarioForAuthorizationServer(scenarioName)!; + + console.log( + `Running client scenario for authorization server '${scenarioName}' against server: ${options.url}` + ); + + const checks = await scenario.run(options, details); + const filtered = specVersion + ? checks.filter( + (c) => !c.source || matchesSpecVersion(c.source, specVersion) + ) + : checks; + + if (resultDir) { + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(filtered, null, 2) + ); + + console.log(`Results saved to ${resultDir}`); + } + + return { + checks: filtered, + resultDir, + scenarioDescription: scenario.description + }; +} + +export function printAuthorizationServerResults( + checks: ConformanceCheck[], + scenarioDescription: string, + verbose: boolean = false +): { + passed: number; + failed: number; + denominator: number; + warnings: number; +} { + const denominator = checks.filter( + (c) => c.status === 'SUCCESS' || c.status === 'FAILURE' + ).length; + const passed = checks.filter((c) => c.status === 'SUCCESS').length; + const failed = checks.filter((c) => c.status === 'FAILURE').length; + const warnings = checks.filter((c) => c.status === 'WARNING').length; + + if (verbose) { + console.log(JSON.stringify(checks, null, 2)); + } else { + console.log(`Checks:\n${formatPrettyChecks(checks)}`); + } + + console.log(`\nTest Results:`); + console.log( + `Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings` + ); + + if (failed > 0) { + console.log('\n=== Failed Checks ==='); + checks + .filter((c) => c.status === 'FAILURE') + .forEach((c) => { + console.log(`\n - ${c.name}: ${c.description}`); + if (c.errorMessage) { + console.log(` Error: ${c.errorMessage}`); + } + }); + } + + return { passed, failed, denominator, warnings }; +} + +export function printAuthorizationServerSummary( + allResults: { scenario: string; checks: ConformanceCheck[] }[] +): { totalPassed: number; totalFailed: number } { + console.log('\n\n=== SUMMARY ==='); + let totalPassed = 0; + let totalFailed = 0; + + for (const result of allResults) { + const passed = result.checks.filter((c) => c.status === 'SUCCESS').length; + const failed = result.checks.filter((c) => c.status === 'FAILURE').length; + totalPassed += passed; + totalFailed += failed; + + const status = failed === 0 ? '✓' : '✗'; + console.log( + `${status} ${result.scenario}: ${passed} passed, ${failed} failed` + ); + } + + console.log(`\nTotal: ${totalPassed} passed, ${totalFailed} failed`); + + return { totalPassed, totalFailed }; +} diff --git a/src/runner/client.test.ts b/src/runner/client.test.ts new file mode 100644 index 0000000..8ce464a --- /dev/null +++ b/src/runner/client.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for the client-conformance runner: spec-version applicability + * skipping (an explicitly-requested version outside a scenario's window + * skips rather than silently testing something else; --force overrides) + * and version inference when --spec-version is omitted. + */ +import { describe, test, expect } from 'vitest'; +import { runConformanceTest } from './client'; +import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../types'; + +// A "client" that just prints the protocol version handed to it, so tests +// can observe which version the runner resolved. +const PRINT_VERSION_COMMAND = + 'node -e "console.log(process.env.MCP_CONFORMANCE_PROTOCOL_VERSION)"'; + +describe('runConformanceTest spec-version applicability', () => { + test('skips a draft-only scenario at an explicit dated spec version', async () => { + // http-custom-headers is introducedIn DRAFT, so a dated version + // contradicts it. The skip happens before the mock server starts and + // before the client command is spawned. + const result = await runConformanceTest( + PRINT_VERSION_COMMAND, + 'http-custom-headers', + 5000, + undefined, + LATEST_SPEC_VERSION + ); + expect(result.skipped).toBe(true); + expect(result.checks).toEqual([]); + expect(result.clientOutput).toBeUndefined(); + }); + + test('--force runs an inapplicable scenario at the requested version', async () => { + const result = await runConformanceTest( + PRINT_VERSION_COMMAND, + 'http-custom-headers', + 10000, + undefined, + LATEST_SPEC_VERSION, + true + ); + expect(result.skipped).toBeUndefined(); + expect(result.clientOutput?.stdout).toContain(LATEST_SPEC_VERSION); + }, 30000); + + test('infers the draft version for a draft-only scenario when --spec-version is omitted', async () => { + const result = await runConformanceTest( + PRINT_VERSION_COMMAND, + 'http-custom-headers', + 10000 + ); + expect(result.skipped).toBeUndefined(); + expect(result.clientOutput?.stdout).toContain(DRAFT_PROTOCOL_VERSION); + }, 30000); + + test('infers the latest dated version for a dual-version scenario when --spec-version is omitted', async () => { + // tools_call is introducedIn 2025-06-18 and still applicable at draft; + // omitting --spec-version must keep the latest dated default. + const result = await runConformanceTest( + PRINT_VERSION_COMMAND, + 'tools_call', + 10000 + ); + expect(result.skipped).toBeUndefined(); + expect(result.clientOutput?.stdout).toContain(LATEST_SPEC_VERSION); + }, 30000); +}); diff --git a/src/runner/client.ts b/src/runner/client.ts index 4525416..af10657 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -1,8 +1,19 @@ import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; -import { ConformanceCheck } from '../types'; -import { getScenario } from '../scenarios'; +import { + ConformanceCheck, + ScenarioSource, + SpecVersion, + LATEST_SPEC_VERSION, + DRAFT_PROTOCOL_VERSION +} from '../types'; +import { getScenario, isScenarioApplicableAt } from '../scenarios'; +import { createServerFor, type ScenarioContext } from '../mock-server'; +import { + resetWireValidation, + wireSchemaChecks +} from '../validation/wire-schema'; import { createResultDir, formatPrettyChecks } from './utils'; export interface ClientExecutionResult { @@ -17,7 +28,8 @@ async function executeClient( scenarioName: string, serverUrl: string, timeout: number = 30000, - context?: Record + context?: Record, + specVersion?: SpecVersion ): Promise { const commandParts = command.split(' '); const executable = commandParts[0]; @@ -34,6 +46,7 @@ async function executeClient( // 3. Semantic separation: scenario identifies "which test", context provides "test data" const env = { ...process.env }; env.MCP_CONFORMANCE_SCENARIO = scenarioName; + env.MCP_CONFORMANCE_PROTOCOL_VERSION = specVersion ?? LATEST_SPEC_VERSION; if (context) { // Include scenario name in context for discriminated union parsing env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({ @@ -88,16 +101,64 @@ async function executeClient( }); } +// An explicitly-requested spec version outside the scenario's applicability +// window is a contradiction: running anyway would test something other than +// what the flag claims. Skip (exit 0) unless --force. +function shouldSkipForSpecVersion( + source: ScenarioSource, + scenarioName: string, + specVersion: SpecVersion | undefined, + force: boolean +): boolean { + if ( + specVersion === undefined || + force || + isScenarioApplicableAt(source, specVersion) + ) { + return false; + } + const introduced = + 'introducedIn' in source + ? `introduced in ${source.introducedIn}` + + (source.removedIn !== undefined + ? `, removed in ${source.removedIn}` + : '') + : 'extension scenario, not on the spec timeline'; + console.log( + `SKIPPED: scenario '${scenarioName}' is not applicable at spec version ` + + `${specVersion} (${introduced}). Use --force to run it anyway.` + ); + return true; +} + +// When --spec-version is omitted, infer the version from the scenario's +// declared source so draft-only scenarios get the draft (stateless) mock +// server rather than the stateful latest-spec default. +function resolveScenarioSpecVersion( + source: ScenarioSource, + specVersion: SpecVersion | undefined +): SpecVersion { + return ( + specVersion ?? + ('introducedIn' in source && source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION) + ); +} + export async function runConformanceTest( clientCommand: string, scenarioName: string, timeout: number = 30000, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion, + force = false ): Promise<{ checks: ConformanceCheck[]; - clientOutput: ClientExecutionResult; + clientOutput?: ClientExecutionResult; resultDir?: string; allowClientError?: boolean; + skipped?: boolean; }> { let resultDir: string | undefined; @@ -109,8 +170,24 @@ export async function runConformanceTest( // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; + if ( + shouldSkipForSpecVersion(scenario.source, scenarioName, specVersion, force) + ) { + return { checks: [], resultDir, skipped: true }; + } + + const resolvedVersion = resolveScenarioSpecVersion( + scenario.source, + specVersion + ); + const ctx: ScenarioContext = { + specVersion: resolvedVersion, + createServer: (handlers) => createServerFor(resolvedVersion)(handlers) + }; + console.error(`Starting scenario: ${scenarioName}`); - const urls = await scenario.start(); + resetWireValidation(); + const urls = await scenario.start(ctx); console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); if (urls.context) { @@ -123,7 +200,8 @@ export async function runConformanceTest( scenarioName, urls.serverUrl, timeout, - urls.context + urls.context, + resolvedVersion ); // Print stdout/stderr if client exited with nonzero code @@ -142,6 +220,7 @@ export async function runConformanceTest( } const checks = scenario.getChecks(); + checks.push(...wireSchemaChecks(resolvedVersion)); if (resultDir) { await fs.writeFile( @@ -263,7 +342,9 @@ export function printClientResults( export async function runInteractiveMode( scenarioName: string, verbose: boolean = false, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion, + force = false ): Promise { let resultDir: string | undefined; @@ -275,8 +356,24 @@ export async function runInteractiveMode( // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; + if ( + shouldSkipForSpecVersion(scenario.source, scenarioName, specVersion, force) + ) { + return; + } + + const resolvedVersion = resolveScenarioSpecVersion( + scenario.source, + specVersion + ); + const ctx: ScenarioContext = { + specVersion: resolvedVersion, + createServer: (handlers) => createServerFor(resolvedVersion)(handlers) + }; + console.log(`Starting scenario: ${scenarioName}`); - const urls = await scenario.start(); + resetWireValidation(); + const urls = await scenario.start(ctx); console.log(`Server URL: ${urls.serverUrl}`); console.log('Press Ctrl+C to stop...'); @@ -285,6 +382,7 @@ export async function runInteractiveMode( console.log('\nShutting down...'); const checks = scenario.getChecks(); + checks.push(...wireSchemaChecks(resolvedVersion)); if (resultDir) { await fs.writeFile( diff --git a/src/runner/server.test.ts b/src/runner/server.test.ts new file mode 100644 index 0000000..2f6ac64 --- /dev/null +++ b/src/runner/server.test.ts @@ -0,0 +1,189 @@ +/** + * Tests for the server-conformance runner: spec-version applicability + * skipping (an explicitly-requested version outside a scenario's window + * skips rather than silently testing something else; --force overrides). + */ +import http from 'http'; +import type { AddressInfo } from 'net'; +import { afterEach, beforeEach, describe, test, expect } from 'vitest'; +import { runServerConformanceTest } from './server'; +import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../types'; + +// The skip decision happens before any network request, so an unreachable +// URL proves the scenario was not run. +const UNREACHABLE_URL = 'http://127.0.0.1:9/mcp'; + +describe('runServerConformanceTest spec-version applicability', () => { + test('skips a draft-only scenario at an explicit dated spec version', async () => { + const result = await runServerConformanceTest( + UNREACHABLE_URL, + 'server-stateless', + undefined, + LATEST_SPEC_VERSION + ); + expect(result.skipped).toBe(true); + expect(result.checks).toEqual([]); + }); + + test('skips a removed-in-draft scenario at the draft spec version', async () => { + // server-initialize tests the stateful handshake, which the draft + // (stateless) lifecycle removed. + const result = await runServerConformanceTest( + UNREACHABLE_URL, + 'server-initialize', + undefined, + DRAFT_PROTOCOL_VERSION + ); + expect(result.skipped).toBe(true); + expect(result.checks).toEqual([]); + }); + + test('does not skip an applicable scenario/spec-version combination', async () => { + // server-stateless at draft is applicable; the runner proceeds to run it + // (against an unreachable server, so checks exist and report failures — + // the point is only that it was not skipped). + const result = await runServerConformanceTest( + UNREACHABLE_URL, + 'server-stateless', + undefined, + DRAFT_PROTOCOL_VERSION + ); + expect(result.skipped).toBeUndefined(); + expect(result.checks.length).toBeGreaterThan(0); + }, 60000); +}); + +describe('runServerConformanceTest per-scenario timeout', () => { + // A server that completes the TCP handshake and then never writes a byte. + // Before the runner bounded `scenario.run`, this hung the whole suite: the + // scenario had no timeout of its own, so nothing downstream ever ran. + let server: http.Server; + let url: string; + + beforeEach(async () => { + server = http.createServer(() => { + // Deliberately never respond, and never destroy the socket. + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const port = (server.address() as AddressInfo).port; + url = `http://127.0.0.1:${port}/mcp`; + }); + + afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + test('fails the scenario instead of hanging when the server never responds', async () => { + const start = Date.now(); + const result = await runServerConformanceTest( + url, + 'server-initialize', + undefined, + undefined, + false, + 1000 + ); + const elapsed = Date.now() - start; + + const timeoutCheck = result.checks.find((c) => c.id === 'scenario-timeout'); + expect(timeoutCheck).toBeDefined(); + expect(timeoutCheck?.status).toBe('FAILURE'); + expect(timeoutCheck?.errorMessage).toContain('1000ms'); + + // The bound is what makes this a failure rather than a stall; without it + // the call never returns and this assertion is never reached. + expect(elapsed).toBeLessThan(15000); + }, 30000); +}); + +describe('runServerConformanceTest wire selection for draft-only scenarios', () => { + // Regression: the CLI used to silently emit the legacy initialize+session + // wire when running a draft-only scenario, producing requests with no + // `_meta.io.modelcontextprotocol/*` envelope (and `initialize` rather + // than `server/discover`). Deriving wire from spec version on the + // RunContext makes the CLI emit SEP-2575 stateless traffic on draft. + let server: http.Server; + let url: string; + const captured: Array<{ method?: string; params?: Record }> = + []; + // Bodies the mock couldn't parse as JSON. We surface these via an + // explicit assertion (rather than silently dropping) because in this + // test's scope, every body MUST be a JSON-RPC request — anything else + // is the kind of malformed-wire regression this test exists to catch. + const parseFailures: string[] = []; + + beforeEach(async () => { + captured.length = 0; + parseFailures.length = 0; + server = http.createServer((req, res) => { + let buf = ''; + req.on('data', (chunk) => { + buf += chunk; + }); + req.on('end', () => { + let id: unknown = null; + try { + const body = JSON.parse(buf); + captured.push({ method: body.method, params: body.params }); + id = body.id ?? null; + } catch { + parseFailures.push(buf); + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32603, message: 'mock server: scenario aborted' } + }) + ); + }); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const port = (server.address() as AddressInfo).port; + url = `http://127.0.0.1:${port}/mcp`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + test('emits SEP-2575 stateless wire (no initialize, _meta envelope) on tasks-lifecycle', async () => { + await runServerConformanceTest(url, 'tasks-lifecycle'); + + // Every outgoing body MUST be JSON-RPC. Hitting this assertion would + // mean the harness emitted something the mock couldn't parse — a + // significant regression in its own right, surface it loudly. + expect(parseFailures).toEqual([]); + + expect(captured.length).toBeGreaterThan(0); + + // The legacy wire opens with an `initialize` handshake; SEP-2575 + // removes it. The scenario MUST NOT have sent one. + expect(captured.some((c) => c.method === 'initialize')).toBe(false); + + // Every body MUST carry the SEP-2575 `_meta` envelope. + const first = captured[0]; + const meta = first.params?._meta as Record | undefined; + expect(meta).toBeDefined(); + expect(meta?.['io.modelcontextprotocol/protocolVersion']).toBe( + DRAFT_PROTOCOL_VERSION + ); + expect(meta?.['io.modelcontextprotocol/clientInfo']).toBeDefined(); + expect(meta?.['io.modelcontextprotocol/clientCapabilities']).toBeDefined(); + + // Scenario-passed capabilities (not just defaults) must reach the wire. + const caps = meta?.['io.modelcontextprotocol/clientCapabilities'] as + | Record + | undefined; + expect(caps?.extensions).toMatchObject({ + 'io.modelcontextprotocol/tasks': {} + }); + }, 30000); +}); diff --git a/src/runner/server.ts b/src/runner/server.ts index a7d8449..21e80ec 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -1,7 +1,17 @@ import { promises as fs } from 'fs'; import path from 'path'; -import { ConformanceCheck } from '../types'; -import { getClientScenario } from '../scenarios'; +import { + ConformanceCheck, + SpecVersion, + LATEST_SPEC_VERSION, + DRAFT_PROTOCOL_VERSION +} from '../types'; +import { getClientScenario, isScenarioApplicableAt } from '../scenarios'; +import { connectFor, type RunContext } from '../connection'; +import { + resetWireValidation, + wireSchemaChecks +} from '../validation/wire-schema'; import { createResultDir, formatPrettyChecks } from './utils'; /** @@ -17,14 +27,64 @@ function formatMarkdown(text: string): string { ); } +/** + * Bound `scenario.run` so a server that accepts connections but never answers + * fails one scenario instead of stalling the whole suite. + * + * The losing promise is left pending on purpose: a scenario blocked on a socket + * read has no cancellation channel, so there is nothing to await. Its rejection + * is swallowed to keep a late failure from surfacing as an unhandled rejection + * against whichever scenario happens to be running by then. + */ +async function runScenarioBounded( + run: Promise, + scenarioName: string, + timeout: number +): Promise { + const timedOut = Symbol('timed-out'); + let timeoutHandle: NodeJS.Timeout | undefined; + + run.catch(() => {}); + + const result = await Promise.race([ + run, + new Promise((resolve) => { + timeoutHandle = setTimeout(() => resolve(timedOut), timeout); + }) + ]); + clearTimeout(timeoutHandle); + + if (result !== timedOut) { + return result; + } + + console.log(`\nScenario timed out after ${timeout}ms`); + return [ + { + id: 'scenario-timeout', + name: 'Scenario completes within the timeout', + description: + 'The scenario must finish within the configured timeout. A server that ' + + 'accepts the connection but never responds leaves it running forever.', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Scenario '${scenarioName}' did not complete within ${timeout}ms. The server under test accepted the connection but did not finish the exchange.` + } + ]; +} + export async function runServerConformanceTest( serverUrl: string, scenarioName: string, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion, + force = false, + timeout: number = 30000 ): Promise<{ checks: ConformanceCheck[]; resultDir?: string; scenarioDescription: string; + skipped?: boolean; }> { let resultDir: string | undefined; @@ -36,11 +96,61 @@ export async function runServerConformanceTest( // Scenario is guaranteed to exist by CLI validation const scenario = getClientScenario(scenarioName)!; + // An explicitly-requested spec version outside the scenario's applicability + // window is a contradiction: running anyway would test something other than + // what the flag claims. Skip (exit 0) unless --force. + if ( + specVersion !== undefined && + !force && + !isScenarioApplicableAt(scenario.source, specVersion) + ) { + const introduced = + 'introducedIn' in scenario.source + ? `introduced in ${scenario.source.introducedIn}` + + (scenario.source.removedIn !== undefined + ? `, removed in ${scenario.source.removedIn}` + : '') + : 'extension scenario, not on the spec timeline'; + console.log( + `SKIPPED: scenario '${scenarioName}' is not applicable at spec version ` + + `${specVersion} (${introduced}). Use --force to run it anyway.` + ); + return { + checks: [], + resultDir, + scenarioDescription: scenario.description, + skipped: true + }; + } + + // When --spec-version is omitted, infer the version from the scenario's + // declared source so draft-only scenarios get the draft (stateless) + // connection rather than the stateful latest-spec default. Extension + // scenarios are off-timeline; today every extension in this repo lives + // on draft, so they fall under the same inference. + const resolvedSpecVersion = + specVersion ?? + ('extensionId' in scenario.source || + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION); + console.log( `Running client scenario '${scenarioName}' against server: ${serverUrl}` ); - const checks = await scenario.run(serverUrl); + const ctx: RunContext = { + serverUrl, + specVersion: resolvedSpecVersion, + connect: (opts) => connectFor(resolvedSpecVersion)(serverUrl, opts) + }; + resetWireValidation(); + const checks = await runScenarioBounded( + scenario.run(ctx), + scenarioName, + timeout + ); + checks.push(...wireSchemaChecks(resolvedSpecVersion)); if (resultDir) { await fs.writeFile( diff --git a/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts new file mode 100644 index 0000000..f6780dd --- /dev/null +++ b/src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts @@ -0,0 +1,57 @@ +import express from 'express'; + +export interface CallbackServer { + waitForCallback: (timeoutMs: number) => Promise; + close: () => void; +} + +export function startCallbackServer(port: number): CallbackServer { + const app = express(); + + let resolveFn: (url: string) => void; + let rejectFn: (err: Error) => void; + + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + + const server = app.listen(port, '127.0.0.1', () => { + console.log(`Callback server started: http://127.0.0.1:${port}`); + }); + + server.on('error', (err) => { + rejectFn(err instanceof Error ? err : new Error(String(err))); + }); + + app.get('/callback', (req, res) => { + // Do not derive origin from the client-supplied Host header — reconstruct + // from the bind address so a forged Host can't influence validation. + const fullUrl = `http://127.0.0.1:${port}${req.originalUrl}`; + res.send('OK. You can close this page.'); + + server.close(); + resolveFn(fullUrl); + }); + + const close = () => { + server.close(); + }; + + return { + close, + waitForCallback: (timeoutMs: number) => { + let timer: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + server.close(); + reject(new Error('Timeout: No callback received')); + }, timeoutMs); + timer.unref(); + }); + return Promise.race([promise, timeout]).finally(() => + clearTimeout(timer) + ); + } + }; +} diff --git a/src/scenarios/authorization-server/auth/spec-references.ts b/src/scenarios/authorization-server/auth/spec-references.ts new file mode 100644 index 0000000..e89ec86 --- /dev/null +++ b/src/scenarios/authorization-server/auth/spec-references.ts @@ -0,0 +1,12 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + MCP_AUTH_DISCOVERY: { + id: 'MCP-Authorization-metadata-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery' + }, + OAUTH_2_1_AUTHORIZATION_CODE_GRANT: { + id: 'OAUTH-2.1-authorization-code-grant', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1' + } +}; diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts new file mode 100644 index 0000000..e2eaf06 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AuthorizationCodeGrantScenario } from './authorization-code-grant.js'; +import { request } from 'undici'; +import { startCallbackServer } from '../authorization-server/auth/helpers/createCallbackServer'; + +vi.mock('undici', () => ({ + request: vi.fn() +})); + +vi.mock('../authorization-server/auth/helpers/createCallbackServer', () => ({ + startCallbackServer: vi.fn() +})); + +const mockedRequest = vi.mocked(request); +const mockedStartCallbackServer = vi.mocked(startCallbackServer); + +const SERVER_URL = 'https://example.com'; +const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; +const TOKEN_ENDPOINT = `${SERVER_URL}/token`; + +const RESOURCE = 'https://mcp.example.com/mcp'; + +const OPTIONS = { + url: SERVER_URL, + clientId: 'client', + clientSecret: 'secret', + port: 3000 +}; + +const METADATA = { + issuer: SERVER_URL, + authorization_endpoint: AUTHORIZATION_ENDPOINT, + token_endpoint: TOKEN_ENDPOINT, + token_endpoint_auth_methods_supported: ['client_secret_post'] +}; + +const METADATA_PRIVATE_KEY_JWT = { + issuer: SERVER_URL, + authorization_endpoint: AUTHORIZATION_ENDPOINT, + token_endpoint: TOKEN_ENDPOINT, + token_endpoint_auth_methods_supported: ['private_key_jwt'] +}; + +const DETAILS = { + 'authorization-server-metadata-endpoint': { + body: METADATA + } +}; + +const DETAILS_PRIVATE_KEY_JWT = { + 'authorization-server-metadata-endpoint': { + body: METADATA_PRIVATE_KEY_JWT + } +}; + +function mockCallbackServer( + scenario: AuthorizationCodeGrantScenario, + buildUrl: (state: string) => string +) { + mockedStartCallbackServer.mockReturnValue({ + waitForCallback: vi.fn().mockImplementation(async () => { + return buildUrl((scenario as any).state); + }), + close: vi.fn() + } as any); +} + +function mockTokenResponse(body: Record) { + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store' + }, + body: { + json: async () => body + } + } as any); +} + +describe('AuthorizationCodeGrantScenario', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns SUCCESS for valid authorization response and token response', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=${SERVER_URL}` + ); + + mockTokenResponse({ + access_token: 'eyJhbGciOiJIUzI1NiJ9.payload.sig0123', + refresh_token: 'short', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + + expect(check.details).toBeDefined(); + + expect((check.details as any).authorizationRequest).toContain( + AUTHORIZATION_ENDPOINT + ); + + expect((check.details as any).authorizationResponseUrl).toContain( + 'code=abc' + ); + + expect((check.details as any).body.access_token).toBe('eyJh…0123 (len=36)'); + expect((check.details as any).body.refresh_token).toBe('[redacted, len=5]'); + expect((check.details as any).body.token_type).toBe('Bearer'); + }); + + it('returns FAILURE when state parameter is invalid and does not request a token', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + () => 'http://127.0.0.1:3000/callback?code=abc&state=invalid' + ); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid state parameter'); + // CSRF enforcement: the token endpoint must never be called when state + // doesn't bind to the request we sent. + expect(mockedRequest).not.toHaveBeenCalled(); + }); + + it('returns FAILURE when the authorization response carries an error parameter', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + () => + 'http://127.0.0.1:3000/callback?error=access_denied&error_description=nope' + ); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Authorization error: access_denied'); + expect(mockedRequest).not.toHaveBeenCalled(); + }); + + it('returns FAILURE when code parameter is missing', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?state=${state}` + ); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid code parameter'); + }); + + it('returns FAILURE when iss parameter is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => + `http://127.0.0.1:3000/callback?code=abc&state=${state}&iss=https://evil.example.com` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid iss parameter'); + }); + + it('returns FAILURE when token response does not include access_token', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Missing access_token'); + }); + + it('returns FAILURE when token response does not include token_type', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token' + }); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Missing token_type'); + }); + + it('returns FAILURE when token response Content-Type is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'text/plain', + 'cache-control': 'no-store' + }, + body: { + json: async () => ({ + access_token: 'access-token', + token_type: 'Bearer' + }) + } + } as any); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid Content-Type'); + }); + + it('returns FAILURE when token response Cache-Control is invalid', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'public' + }, + body: { + json: async () => ({ + access_token: 'access-token', + token_type: 'Bearer' + }) + } + } as any); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('Invalid Cache-Control'); + }); + + it('sends the resource parameter in both requests when it is set', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run( + { ...OPTIONS, resource: RESOURCE }, + DETAILS + ); + + expect(checks[0].status).toBe('SUCCESS'); + + const authorizationRequest = new URL( + (checks[0].details as any).authorizationRequest + ); + expect(authorizationRequest.searchParams.get('resource')).toBe(RESOURCE); + + const tokenBody = new URLSearchParams( + mockedRequest.mock.calls[0][1]?.body as string + ); + expect(tokenBody.get('resource')).toBe(RESOURCE); + }); + + it('omits the resource parameter when it is not set', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + mockTokenResponse({ + access_token: 'access-token', + token_type: 'Bearer' + }); + + const checks = await scenario.run(OPTIONS, DETAILS); + + expect(checks[0].status).toBe('SUCCESS'); + + const authorizationRequest = new URL( + (checks[0].details as any).authorizationRequest + ); + expect(authorizationRequest.searchParams.has('resource')).toBe(false); + + const tokenBody = new URLSearchParams( + mockedRequest.mock.calls[0][1]?.body as string + ); + expect(tokenBody.has('resource')).toBe(false); + }); + + it('catches an authorization server that rejects the resource parameter', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + // An AS that 400s when `resource` is present. Before the runner sent the + // parameter this path could not be reached, so the fault was invisible. + mockedRequest.mockImplementation(async (_url, opts: any) => { + const rejected = new URLSearchParams(opts.body).has('resource'); + return { + statusCode: rejected ? 400 : 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store' + }, + body: { + json: async () => + rejected + ? { error: 'invalid_target' } + : { access_token: 'access-token', token_type: 'Bearer' } + } + } as any; + }); + + const withResource = await scenario.run( + { ...OPTIONS, resource: RESOURCE }, + DETAILS + ); + expect(withResource[0].status).toBe('FAILURE'); + expect(withResource[0].errorMessage).toContain('400'); + + const withoutResource = await scenario.run(OPTIONS, DETAILS); + expect(withoutResource[0].status).toBe('SUCCESS'); + }); + + it('returns SKIPPED when client_secret_post and client_secret_basic are missing', async () => { + const scenario = new AuthorizationCodeGrantScenario(); + + mockCallbackServer( + scenario, + (state) => `http://127.0.0.1:3000/callback?code=abc&state=${state}` + ); + + const checks = await scenario.run(OPTIONS, DETAILS_PRIVATE_KEY_JWT); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + + expect(check.status).toBe('SKIPPED'); + }); +}); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts new file mode 100644 index 0000000..13f3ba0 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -0,0 +1,382 @@ +/** + * Authorization code grant test scenarios for MCP authorization servers + */ +import { + ClientScenarioForAuthorizationServer, + ConformanceCheck +} from '../../types'; +import { startCallbackServer } from '../authorization-server/auth/helpers/createCallbackServer'; +import { request } from 'undici'; +import { createHash, randomBytes } from 'crypto'; +import { AuthorizationServerOptions } from '../../schemas'; +import { SpecReferences } from '../authorization-server/auth/spec-references'; + +const REDIRECT_URI_ORIGIN = 'http://127.0.0.1'; +const REDIRECT_URI_PATH = '/callback'; + +const REDACTED_KEYS = ['access_token', 'refresh_token', 'id_token'] as const; + +/** + * Mask live token material so it never lands in checks.json. Keep a short + * prefix/suffix so the value can still be correlated against AS logs. + * Tokens shorter than 16 chars are fully redacted. + */ +function redactTokens(body: Record): Record { + const out: Record = { ...body }; + for (const key of REDACTED_KEYS) { + const value = out[key]; + if (typeof value === 'string') { + out[key] = + value.length < 16 + ? `[redacted, len=${value.length}]` + : `${value.slice(0, 4)}…${value.slice(-4)} (len=${value.length})`; + } + } + return out; +} + +export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthorizationServer { + private state = randomBytes(32).toString('base64url'); + private codeVerifier = ''; + private codeChallenge = ''; + name = 'authorization-code-grant'; + readonly source = { introducedIn: '2025-03-26' } as const; + description = `Test authorization code grant. + +**Authorization Server Implementation Requirements:** + +**Endpoint**: \`authorization endpoint\`, \`token endpoint\` + +**Requirements**: +- The URI in the authorization response MUST match the redirect_uri parameter in the authorization request +- The code parameter MUST be present in the authorization response query parameters +- The code parameter MUST have a value +- The state parameter in the authorization response MUST match the state parameter in the authorization request query parameters if the state parameter is present in the authorization request query parameters +- The iss parameter in the authorization response MUST match the issuer claim of authorization server metadata if the iss parameter is present in the authorization response query parameters +- The code, state and iss parameters MUST NOT appear more than once +- The error parameter MUST NOT be present in the authorization response query parameters +- HTTP response status code of token response MUST be 200 OK +- Content-Type header of token response MUST be application/json +- Cache-Control header of token response MUST be no-store +- Token response MUST return a JSON response including access_token and token_type`; + + async run( + options: AuthorizationServerOptions, + details: Record + ): Promise { + try { + if (!options.clientId) { + return [ + this.skippedCheck('authorization-code-grant requires --client-id') + ]; + } + + this.state = randomBytes(32).toString('base64url'); + this.codeVerifier = randomBytes(32).toString('base64url'); + this.codeChallenge = createHash('sha256') + .update(this.codeVerifier) + .digest('base64url'); + + const resultMetadata = details[ + 'authorization-server-metadata-endpoint' + ] as { body?: Record }; + if (!resultMetadata?.body) { + throw new Error('Invalid authorization server metadata'); + } + const metadata = resultMetadata.body; + + // Decide how we'll authenticate to the token endpoint *before* + // binding a port and asking the user to open a browser. + const authMethod = this.selectTokenAuthMethod(metadata, options); + if (authMethod === null) { + return [ + this.skippedCheck( + 'Server does not support client_secret_post, client_secret_basic, or none auth methods' + ) + ]; + } + + const callback = startCallbackServer(options.port); + try { + const authorizationRequest = this.buildAuthorizationRequest( + metadata, + options + ); + console.log( + `Ensure ${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH} is registered as a redirect URI for client '${options.clientId}'.` + ); + console.log( + 'Access the following URL in your browser and complete the authentication process.' + ); + console.log(authorizationRequest); + console.log(''); + console.log( + 'Waiting up to 5 minutes for the authorization callback...' + ); + + const authorizationResponseUrl = + await callback.waitForCallback(300_000); + + const errors: string[] = []; + const code = this.validateAuthorizationResponse( + authorizationResponseUrl, + metadata, + options, + errors + ); + + const tokenResponse = await this.requestToken( + metadata, + options, + code, + authMethod + ); + this.validateTokenResponse(tokenResponse, errors); + + if (errors.length > 0) { + return [this.failureCheck(errors.join(', '))]; + } + + return [ + this.successCheck({ + authorizationRequest, + authorizationResponseUrl, + body: redactTokens(tokenResponse.body) + }) + ]; + } finally { + callback.close(); + } + } catch (error) { + return [this.failureCheck(error)]; + } + } + + private buildAuthorizationRequest( + metadata: any, + options: AuthorizationServerOptions + ): string { + if (!metadata?.authorization_endpoint) { + throw new Error('Unable to obtain authorization endpoint from metadata'); + } + + const params = new URLSearchParams({ + response_type: 'code', + client_id: options.clientId ?? '', + state: this.state, + redirect_uri: `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`, + code_challenge: this.codeChallenge, + code_challenge_method: 'S256' + }); + if (options.resource) { + params.set('resource', options.resource); + } + + return `${metadata.authorization_endpoint}?${params.toString()}`; + } + + private validateAuthorizationResponse( + responseUrl: string, + metadata: any, + options: AuthorizationServerOptions, + errors: string[] + ): string { + const url = new URL(responseUrl); + + // RFC 6749 §4.1.2.1: an error response and a code response are mutually + // exclusive. Surface the AS-reported error before any other validation. + if (url.searchParams.has('error')) { + const error = url.searchParams.get('error'); + const desc = url.searchParams.get('error_description'); + throw new Error(`Authorization error: ${error} ${desc ?? ''}`.trim()); + } + + if (url.origin !== REDIRECT_URI_ORIGIN + ':' + options.port) { + errors.push(`Invalid origin of redirect URL: ${url.origin}`); + } + if (url.pathname !== REDIRECT_URI_PATH) { + errors.push(`Invalid path of redirect URL: ${url.pathname}`); + } + + // CSRF binding: state mismatch is fatal — never proceed to token + // exchange with an unbound authorization response. + const state = url.searchParams.getAll('state'); + if (state.length !== 1 || state[0] !== this.state) { + throw new Error( + `Invalid state parameter: ${state.join(',') || 'missing'}` + ); + } + + const code = url.searchParams.getAll('code'); + if (code.length !== 1 || code[0] === '') { + throw new Error(`Invalid code parameter: ${code.join(',') || 'missing'}`); + } + + const iss = url.searchParams.getAll('iss'); + if (iss.length > 0) { + if (iss.length !== 1 || iss[0] !== metadata.issuer) { + errors.push(`Invalid iss parameter: ${iss}`); + } + } + + return code[0]; + } + + private selectTokenAuthMethod( + metadata: any, + options: AuthorizationServerOptions + ): 'none' | 'client_secret_post' | 'client_secret_basic' | null { + // RFC 8414 §2: omitted token_endpoint_auth_methods_supported means + // the default is "client_secret_basic". + const authMethods: string[] = + metadata.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + + if (!options.clientSecret || authMethods.includes('none')) { + return 'none'; + } + if (authMethods.includes('client_secret_post')) { + return 'client_secret_post'; + } + if (authMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + // client_secret_jwt / private_key_jwt / tls_client_auth are not yet + // implemented; skip rather than fail. + return null; + } + + private async requestToken( + metadata: any, + options: AuthorizationServerOptions, + code: string, + authMethod: 'none' | 'client_secret_post' | 'client_secret_basic' + ): Promise<{ body: any; headers: any }> { + if (!metadata?.token_endpoint) { + throw new Error('Unable to obtain token endpoint from metadata'); + } + + const redirectUri = `${REDIRECT_URI_ORIGIN}:${options.port}${REDIRECT_URI_PATH}`; + + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: this.codeVerifier + }); + if (options.resource) { + params.set('resource', options.resource); + } + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded' + }; + + if (authMethod === 'none') { + // Public client (PKCE-only). RFC 6749 §3.2.1: client_id in the body. + params.set('client_id', options.clientId!); + } else if (authMethod === 'client_secret_post') { + params.set('client_id', options.clientId!); + params.set('client_secret', options.clientSecret!); + } else { + // RFC 6749 §2.3.1: form-urlencode each component before base64. + const credentials = Buffer.from( + `${encodeURIComponent(options.clientId!)}:${encodeURIComponent(options.clientSecret!)}` + ).toString('base64'); + headers.authorization = `Basic ${credentials}`; + } + + const response = await request(metadata.token_endpoint, { + method: 'POST', + headers, + body: params.toString() + }); + + if (response.statusCode !== 200) { + throw new Error(`Invalid status code: ${response.statusCode}`); + } + + const body = await response.body.json(); + return { body, headers: response.headers }; + } + + private validateTokenResponse( + response: { + body: any; + headers: any; + }, + errors: string[] + ): void { + const { body, headers } = response; + + this.assertHeader( + headers['content-type'], + 'application/json', + 'Content-Type', + errors + ); + this.assertHeader( + headers['cache-control'], + 'no-store', + 'Cache-Control', + errors + ); + + if (typeof body !== 'object' || body === null) { + throw new Error('Token response body is not an object'); + } + + if (typeof body.access_token !== 'string') { + errors.push('Missing access_token'); + } + + if (typeof body.token_type !== 'string') { + errors.push('Missing token_type'); + } + } + + private assertHeader( + value: unknown, + expected: string, + name: string, + errors: string[] + ): void { + if (typeof value !== 'string' || !value.toLowerCase().includes(expected)) { + errors.push(`Invalid ${name}: ${value ?? '(missing)'}`); + } + } + + private successCheck(details: any): ConformanceCheck { + return { + id: 'authorization-code-grant', + name: 'AuthorizationCodeGrant', + description: 'Valid authorization code grant', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_CODE_GRANT], + details + }; + } + + private failureCheck(error: unknown): ConformanceCheck { + return { + id: 'authorization-code-grant', + name: 'AuthorizationCodeGrant', + description: 'Valid authorization code grant', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: error instanceof Error ? error.message : String(error), + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_CODE_GRANT] + }; + } + + private skippedCheck(reason: string): ConformanceCheck { + return { + id: 'authorization-code-grant', + name: 'AuthorizationCodeGrant', + description: 'Valid authorization code grant', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: reason, + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_CODE_GRANT] + }; + } +} diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts new file mode 100644 index 0000000..20ad437 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AuthorizationServerMetadataEndpointScenario } from './authorization-server-metadata.js'; +import { request } from 'undici'; + +vi.mock('undici', () => ({ + request: vi.fn() +})); + +const mockedRequest = vi.mocked(request); + +const SERVER_URL = 'https://example.com'; +const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; +const TOKEN_ENDPOINT = `${SERVER_URL}/token`; +const OPTIONS = { + url: SERVER_URL, + clientId: 'client', + clientSecret: 'secret', + port: 3000 +}; +const details: Record = {}; + +const validMetadata = { + issuer: SERVER_URL, + authorization_endpoint: AUTHORIZATION_ENDPOINT, + token_endpoint: TOKEN_ENDPOINT, + response_types_supported: ['code'], + code_challenge_methods_supported: ['plain', 'S256'] +}; + +function mockMetadataResponse(body: Record) { + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { 'content-type': 'application/json' }, + body: { json: async () => body } + } as any); +} + +describe('AuthorizationServerMetadataEndpointScenario', () => { + it('returns SUCCESS for valid authorization server metadata', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + mockMetadataResponse(validMetadata); + + const checks = await scenario.run(OPTIONS, details); + + expect(checks).toHaveLength(2); + + const check = checks[0]; + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + expect(check.details).toBeDefined(); + expect(check.details!.contentType).toContain('application/json'); + expect((check.details!.body as any).issuer).toBe(SERVER_URL); + expect((check.details!.body as any).authorization_endpoint).toBe( + AUTHORIZATION_ENDPOINT + ); + expect((check.details!.body as any).token_endpoint).toBe(TOKEN_ENDPOINT); + expect((check.details!.body as any).response_types_supported).toEqual([ + 'code' + ]); + expect( + (check.details!.body as any).code_challenge_methods_supported + ).toEqual(['plain', 'S256']); + }); + + it('returns FAILURE when code_challenge_methods_supported is missing', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + mockMetadataResponse({ + issuer: validMetadata.issuer, + authorization_endpoint: validMetadata.authorization_endpoint, + token_endpoint: validMetadata.token_endpoint, + response_types_supported: validMetadata.response_types_supported + }); + + const checks = await scenario.run(OPTIONS, details); + + expect(checks).toHaveLength(2); + + const check = checks[0]; + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('code_challenge_methods_supported'); + }); + + it('returns FAILURE when code_challenge_methods_supported does not include S256', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + mockMetadataResponse({ + ...validMetadata, + code_challenge_methods_supported: ['plain'] + }); + + const checks = await scenario.run(OPTIONS, details); + + expect(checks).toHaveLength(2); + + const check = checks[0]; + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('code_challenge_methods_supported'); + }); + + it('returns SUCCESS for CIMD check when server metadata includes client_id_metadata_document_supported=true', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + mockMetadataResponse({ + ...validMetadata, + client_id_metadata_document_supported: true + }); + + const checks = await scenario.run(OPTIONS, details); + + expect(checks).toHaveLength(2); + + const metadataCheck = checks[0]; + expect(metadataCheck.status).toBe('SUCCESS'); + + const cimdCheck = checks[1]; + expect(cimdCheck.id).toBe('authorization-server-metadata-cimd'); + expect(cimdCheck.status).toBe('SUCCESS'); + expect(cimdCheck.errorMessage).toBeUndefined(); + expect(cimdCheck.details).toEqual({ + client_id_metadata_document_supported: true + }); + }); + + it('returns WARNING for CIMD check when server metadata lacks client_id_metadata_document_supported', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + mockMetadataResponse(validMetadata); + + const checks = await scenario.run(OPTIONS, details); + + expect(checks).toHaveLength(2); + + const metadataCheck = checks[0]; + expect(metadataCheck.status).toBe('SUCCESS'); + + const cimdCheck = checks[1]; + expect(cimdCheck.id).toBe('authorization-server-metadata-cimd'); + expect(cimdCheck.status).toBe('WARNING'); + expect(cimdCheck.source).toEqual({ introducedIn: '2025-11-25' }); + expect(cimdCheck.errorMessage).toContain( + 'client_id_metadata_document_supported' + ); + }); + + it('returns WARNING for CIMD check when client_id_metadata_document_supported is false', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + mockMetadataResponse({ + ...validMetadata, + client_id_metadata_document_supported: false + }); + + const checks = await scenario.run(OPTIONS, details); + + expect(checks).toHaveLength(2); + + const metadataCheck = checks[0]; + expect(metadataCheck.status).toBe('SUCCESS'); + + const cimdCheck = checks[1]; + expect(cimdCheck.id).toBe('authorization-server-metadata-cimd'); + expect(cimdCheck.status).toBe('WARNING'); + expect(cimdCheck.source).toEqual({ introducedIn: '2025-11-25' }); + expect(cimdCheck.errorMessage).toContain( + 'client_id_metadata_document_supported' + ); + expect(cimdCheck.errorMessage).toContain('false'); + }); +}); + +describe('AuthorizationServerOptionsSchema', () => { + // Dynamic import to avoid circular dependency issues at module level + async function getSchema() { + const { AuthorizationServerOptionsSchema } = + await import('../../schemas.js'); + return AuthorizationServerOptionsSchema; + } + + it('accepts a valid scenario name', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + scenario: 'authorization-server-metadata-endpoint' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.scenario).toBe( + 'authorization-server-metadata-endpoint' + ); + } + }); + + it('rejects an unknown scenario name', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + scenario: 'nonexistent-scenario' + }); + expect(result.success).toBe(false); + }); + + it('accepts without scenario (optional)', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.scenario).toBeUndefined(); + } + }); + + it('accepts an absolute resource URI', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + resource: 'https://mcp.example.com/mcp' + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.resource).toBe('https://mcp.example.com/mcp'); + } + }); + + it('rejects a resource URI with a fragment', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + resource: 'https://mcp.example.com/mcp#section' + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('fragment'); + } + }); + + it('rejects a resource that is not an absolute URI', async () => { + const schema = await getSchema(); + const result = schema.safeParse({ + url: 'https://example.com', + resource: '/mcp' + }); + expect(result.success).toBe(false); + }); +}); + +describe('printAuthorizationServerResults', () => { + async function getPrintFn() { + const { printAuthorizationServerResults } = + await import('../../runner/authorization-server.js'); + return printAuthorizationServerResults; + } + + const successCheck = { + id: 'test-check', + name: 'TestCheck', + description: 'A test check', + status: 'SUCCESS' as const, + timestamp: new Date().toISOString() + }; + + const failureCheck = { + id: 'test-check-fail', + name: 'TestCheckFail', + description: 'A failing check', + status: 'FAILURE' as const, + timestamp: new Date().toISOString(), + errorMessage: 'Something went wrong' + }; + + it('prints pretty-printed output when verbose is false', async () => { + const printFn = await getPrintFn(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = printFn([successCheck], 'Test scenario description', false); + + expect(result).toEqual({ + passed: 1, + failed: 0, + denominator: 1, + warnings: 0 + }); + + // Should print "Checks:" header (pretty format), not raw JSON + const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('Checks:'); + expect(allOutput).not.toContain('"id"'); + + consoleSpy.mockRestore(); + }); + + it('prints JSON output when verbose is true', async () => { + const printFn = await getPrintFn(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = printFn([successCheck], 'Test scenario description', true); + + expect(result).toEqual({ + passed: 1, + failed: 0, + denominator: 1, + warnings: 0 + }); + + // Should print raw JSON, not "Checks:" header + const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('"id"'); + expect(allOutput).toContain('"test-check"'); + expect(allOutput).not.toMatch(/^Checks:/m); + + consoleSpy.mockRestore(); + }); + + it('reports failed checks with error messages', async () => { + const printFn = await getPrintFn(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = printFn( + [successCheck, failureCheck], + 'Test scenario description', + false + ); + + expect(result).toEqual({ + passed: 1, + failed: 1, + denominator: 2, + warnings: 0 + }); + + const allOutput = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(allOutput).toContain('Failed Checks'); + expect(allOutput).toContain('Something went wrong'); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts new file mode 100644 index 0000000..ec5fd3a --- /dev/null +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -0,0 +1,199 @@ +/** + * Authorization server metadata endpoint test scenarios for MCP authorization servers + */ +import { AuthorizationServerOptions } from '../../schemas'; +import { + CheckStatus, + ClientScenarioForAuthorizationServer, + ConformanceCheck +} from '../../types'; +import { request } from 'undici'; +import { SpecReferences } from '../authorization-server/auth/spec-references'; +import { SpecReferences as ClientSpecReferences } from '../client/auth/spec-references'; + +type Status = 'SUCCESS' | 'FAILURE'; + +export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer { + name = 'authorization-server-metadata-endpoint'; + readonly source = { introducedIn: '2025-03-26' } as const; + description = `Test authorization server metadata endpoint. + +**Authorization Server Implementation Requirements:** + +**Endpoint**: \`authorization server metadata\` + +**Requirements**: +- HTTP response status code MUST be 200 OK +- Content-Type header MUST be application/json +- Return a JSON response including issuer, authorization_endpoint, token_endpoint and response_types_supported +- The issuer value MUST match the URI obtained by removing the well-known URI string from the authorization server metadata URI. +- (2025-11-25+) SHOULD include client_id_metadata_document_supported=true (Client ID Metadata Document support)`; + + async run( + options: AuthorizationServerOptions, + _details: Record + ): Promise { + let status: Status = 'SUCCESS'; + let errorMessage: string | undefined; + let details: any; + let response: any | null = null; + let body: Record | undefined; + try { + const wellKnownUrls = this.createWellKnownUrl(options.url); + + for (const url of wellKnownUrls) { + try { + const checkResponse = await request(url, { method: 'GET' }); + if (checkResponse.statusCode === 200) { + response = checkResponse; + break; + } + } catch { + // Ignore the error and proceed to the next loop. + } + } + + if (!response) { + throw new Error( + 'All authorization server metadata endpoints returned invalid status code.' + ); + } + + this.validateContentType(response.headers['content-type']); + + body = await this.parseJson(response); + const errors: string[] = []; + this.validateMetadataBody(body, options.url, errors); + + if (errors.length > 0) { + status = 'FAILURE'; + errorMessage = errors.join(', '); + } + + details = { + contentType: response.headers['content-type'], + body + }; + } catch (error) { + status = 'FAILURE'; + errorMessage = error instanceof Error ? error.message : String(error); + } + + const checks: ConformanceCheck[] = [ + { + id: 'authorization-server-metadata', + name: 'AuthorizationServerMetadata', + description: 'Valid authorization server metadata response', + status, + timestamp: new Date().toISOString(), + errorMessage, + specReferences: [SpecReferences.MCP_AUTH_DISCOVERY], + ...(details ? { details } : {}) + } + ]; + + if (body) { + const cimdSupported = body.client_id_metadata_document_supported; + const cimdStatus: CheckStatus = + cimdSupported === true ? 'SUCCESS' : 'WARNING'; + const cimdErrorMessage = + cimdSupported === true + ? undefined + : cimdSupported === undefined + ? 'Authorization server metadata does not include "client_id_metadata_document_supported"' + : `Expected "client_id_metadata_document_supported" to be true, got ${JSON.stringify(cimdSupported)}`; + + checks.push({ + id: 'authorization-server-metadata-cimd', + name: 'AuthorizationServerMetadataCIMD', + description: + 'Authorization server metadata includes client_id_metadata_document_supported=true (Client ID Metadata Document support)', + status: cimdStatus, + source: { introducedIn: '2025-11-25' } as const, + timestamp: new Date().toISOString(), + errorMessage: cimdErrorMessage, + specReferences: [ + ClientSpecReferences.MCP_CLIENT_ID_METADATA_DOCUMENTS, + ClientSpecReferences.IETF_CIMD + ], + details: { + client_id_metadata_document_supported: cimdSupported + } + }); + } + + return checks; + } + + private createWellKnownUrl(serverUrl: string): string[] { + const base = new URL(serverUrl); + const origin = base.origin; + const path = base.pathname.replace(/\/$/, ''); + + const urls = new Set(); + urls.add(`${origin}/.well-known/oauth-authorization-server${path}`); + urls.add(`${origin}/.well-known/openid-configuration${path}`); + urls.add(`${origin}${path}/.well-known/openid-configuration`); + + return Array.from(urls); + } + + private validateContentType(contentType?: string | string[]): void { + const valid = + typeof contentType === 'string' && + contentType.toLowerCase().includes('application/json'); + + if (!valid) { + throw new Error(`Invalid Content-Type: ${contentType ?? '(missing)'}`); + } + } + + private async parseJson(response: any): Promise> { + const body = await response.body.json(); + if (typeof body !== 'object' || body === null) { + throw new Error('Response body is not an object'); + } + return body; + } + + private validateMetadataBody( + body: Record, + serverUrl: string, + errors: string[] + ): void { + this.assertString( + body.authorization_endpoint, + 'authorization_endpoint', + errors + ); + this.assertString(body.token_endpoint, 'token_endpoint', errors); + + if ( + !Array.isArray(body.response_types_supported) || + !body.response_types_supported.includes('code') + ) { + errors.push( + 'Response body does not include valid "response_types_supported" claim' + ); + } + + if ( + !Array.isArray(body.code_challenge_methods_supported) || + !body.code_challenge_methods_supported.includes('S256') + ) { + errors.push( + 'Response body does not include valid "code_challenge_methods_supported" claim' + ); + } + + if (body.issuer !== serverUrl) { + errors.push(`Invalid issuer: ${body.issuer ?? '(missing)'}`); + } + } + + private assertString(value: unknown, name: string, errors: string[]): void { + if (typeof value !== 'string' || value.length === 0) { + errors.push(`Response body does not include valid "${name}" claim`); + } + } +} diff --git a/src/scenarios/client/auth/authorization-server-migration.ts b/src/scenarios/client/auth/authorization-server-migration.ts new file mode 100644 index 0000000..6c7c989 --- /dev/null +++ b/src/scenarios/client/auth/authorization-server-migration.ts @@ -0,0 +1,214 @@ +/** + * SEP-2352 — Authorization-server binding and migration. + * + * The MCP server's PRM initially lists AS₁. The client registers, authorizes, + * and calls tools/list. The harness then invalidates the token and flips PRM + * to AS₂. On the next 401 the client re-discovers PRM, sees a new issuer, and + * MUST re-register with AS₂ rather than reuse AS₁'s client credentials. + */ +import type { ScenarioContext } from '../../../mock-server'; +import type { Request, Response, NextFunction } from 'express'; +import type { Scenario, ConformanceCheck } from '../../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier'; +import { SpecReferences } from './spec-references'; + +export class AuthorizationServerMigrationScenario implements Scenario { + name = 'auth/authorization-server-migration'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that a client, when the PRM authorization_servers changes to a new issuer, re-registers with the new authorization server and does not reuse credentials from the previous one (SEP-2352).'; + private as1 = new ServerLifecycle(); + private as2 = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']); + + /** AS₁ issues a recognizable client_id so AS₂ can detect cross-AS reuse. */ + const AS1_CLIENT_ID = 'as1-client-id-LEAKED-IF-SEEN-AT-AS2'; + let as2SawRegister = false; + let as2SawAs1ClientId = false; + let as2SawAs1ClientIdAtToken = false; + + // ── AS₁ ──────────────────────────────────────────────────────────────── + const as1App = createAuthServer(ctx, this.checks, this.as1.getUrl, { + tokenVerifier, + onRegistrationRequest: () => ({ + clientId: AS1_CLIENT_ID, + clientSecret: 'as1-client-secret' + }) + }); + await this.as1.start(as1App); + + // ── AS₂ ──────────────────────────────────────────────────────────────── + const as2App = createAuthServer(ctx, this.checks, this.as2.getUrl, { + tokenVerifier, + onRegistrationRequest: () => { + as2SawRegister = true; + return { clientId: 'as2-client-id', clientSecret: 'as2-client-secret' }; + }, + onAuthorizationRequest: (data) => { + if (data.clientId === AS1_CLIENT_ID) as2SawAs1ClientId = true; + }, + onTokenRequest: (data) => { + const cid = + data.body.client_id ?? + this.basicAuthClientId(data.authorizationHeader); + if (cid === AS1_CLIENT_ID) as2SawAs1ClientIdAtToken = true; + const scopes = data.scope ? data.scope.split(' ') : ['mcp:basic']; + const token = `test-token-as2-${Date.now()}`; + tokenVerifier.registerToken(token, scopes); + return { token, scopes }; + } + }); + await this.as2.start(as2App); + + // ── MCP server with mutable PRM authorization_servers ────────────────── + let migrated = false; + const currentAuthServerUrl = () => + migrated ? this.as2.getUrl() : this.as1.getUrl(); + + const resourceMetadataUrl = () => + `${this.server.getUrl()}/.well-known/oauth-protected-resource/mcp`; + + const middleware = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + let body = req.body; + if (typeof body === 'string') body = JSON.parse(body); + const method = body?.method; + // initialize / notifications never require auth + if (method === 'initialize' || method?.startsWith('notifications/')) + return next(); + + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Bearer ')) { + return res + .status(401) + .set( + 'WWW-Authenticate', + `Bearer scope="mcp:basic", resource_metadata="${resourceMetadataUrl()}"` + ) + .json({ error: 'invalid_token' }); + } + const token = auth.substring('Bearer '.length); + const info = await tokenVerifier.verifyAccessToken(token); + + // Phase 1: accept any verified token, then flip PRM to AS₂ for the next + // call. Phase 2: reject the (now-stale) AS₁ token so the client + // re-discovers PRM and sees AS₂. + if (!migrated) { + migrated = true; + return next(); + } + // After migration, only AS₂ tokens are valid. + if (!info.token.startsWith('test-token-as2-')) { + return res + .status(401) + .set( + 'WWW-Authenticate', + `Bearer scope="mcp:basic", resource_metadata="${resourceMetadataUrl()}"` + ) + .json({ + error: 'invalid_token', + error_description: 'authorization server has changed' + }); + } + return next(); + }; + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + currentAuthServerUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: ['mcp:basic'], + includeScopeInWwwAuth: true, + authMiddleware: middleware, + tokenVerifier + } + ); + await this.server.start(app); + + // Record evaluation closures so getChecks can see them after stop(). + this._evaluate = () => { + const ts = new Date().toISOString(); + const reusedAtAS2 = as2SawAs1ClientId || as2SawAs1ClientIdAtToken; + this.checks.push({ + id: 'sep-2352-reregister-on-as-change', + name: 'Client re-registers with the new authorization server', + description: as2SawRegister + ? 'Client performed Dynamic Client Registration with the new authorization server after PRM authorization_servers changed' + : 'Client MUST re-register with the new authorization server when PRM authorization_servers changes (SEP-2352); no registration request was observed at the new AS', + status: as2SawRegister ? 'SUCCESS' : 'FAILURE', + timestamp: ts, + specReferences: [SpecReferences.MCP_DCR] + }); + this.checks.push({ + id: 'sep-2352-no-reuse-on-as-change', + name: 'Client does not reuse the previous AS client credentials', + description: reusedAtAS2 + ? 'Client MUST NOT reuse client credentials from a different authorization server (SEP-2352); the previous AS client_id was observed at the new AS' + : 'Client did not present the previous AS client_id at the new authorization server', + status: reusedAtAS2 ? 'FAILURE' : 'SUCCESS', + timestamp: ts, + specReferences: [SpecReferences.MCP_DCR], + details: { + previousClientId: AS1_CLIENT_ID, + seenAtAuthorize: as2SawAs1ClientId, + seenAtToken: as2SawAs1ClientIdAtToken + } + }); + // The "no cross-AS credential reuse" general MUST NOT is the same wire + // observation as no-reuse-on-as-change in this scenario; emit it as a + // distinct id so the yaml traceability is 1:1. + this.checks.push({ + id: 'sep-2352-no-cross-as-credential-reuse', + name: 'Client does not assume credentials are portable across authorization servers', + description: reusedAtAS2 + ? 'Client MUST NOT assume that credentials valid for one authorization server will be accepted by another (SEP-2352)' + : 'Client treated credentials as bound to the issuing authorization server', + status: reusedAtAS2 ? 'FAILURE' : 'SUCCESS', + timestamp: ts, + specReferences: [SpecReferences.MCP_DCR] + }); + }; + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + private _evaluate: () => void = () => {}; + + private basicAuthClientId(h?: string): string | undefined { + if (!h?.startsWith('Basic ')) return undefined; + try { + const [id] = Buffer.from(h.slice(6), 'base64') + .toString('utf8') + .split(':'); + return id; + } catch { + return undefined; + } + } + + async stop() { + await this.server.stop(); + await this.as1.stop(); + await this.as2.stop(); + } + + getChecks(): ConformanceCheck[] { + this._evaluate(); + return this.checks; + } +} diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts index 9e5fe67..1e4b37c 100644 --- a/src/scenarios/client/auth/basic-cimd.ts +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -1,5 +1,6 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls, SpecVersion } from '../../../types'; +import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -22,17 +23,17 @@ export const CIMD_CLIENT_METADATA_URL = */ export class AuthBasicCIMDScenario implements Scenario { name = 'auth/basic-cimd'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests OAuth flow with Client ID Metadata Documents (SEP-991/URL-based client IDs). Server advertises client_id_metadata_document_supported=true and client should use URL as client_id instead of DCR.'; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { clientIdMetadataDocumentSupported: true, onAuthorizationRequest: (data) => { // Check if client used URL-based client ID @@ -60,6 +61,7 @@ export class AuthBasicCIMDScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 79ab1b2..d633f7a 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -1,11 +1,7 @@ +import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; -import type { - Scenario, - ConformanceCheck, - ScenarioUrls, - SpecVersion -} from '../../../types'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -37,7 +33,9 @@ async function generateTestKeypair(): Promise<{ */ export class ClientCredentialsJwtScenario implements Scenario { name = 'auth/client-credentials-jwt'; - specVersions: SpecVersion[] = ['extension']; + readonly source = { + extensionId: 'io.modelcontextprotocol/oauth-client-credentials' + } as const; description = 'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)'; @@ -45,13 +43,13 @@ export class ClientCredentialsJwtScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Generate a fresh keypair for this test run const { publicKey, privateKeyPem } = await generateTestKeypair(); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { grantTypesSupported: ['client_credentials'], tokenEndpointAuthMethodsSupported: ['private_key_jwt'], tokenEndpointAuthSigningAlgValuesSupported: ['ES256'], @@ -204,6 +202,7 @@ export class ClientCredentialsJwtScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl @@ -256,7 +255,9 @@ export class ClientCredentialsJwtScenario implements Scenario { */ export class ClientCredentialsBasicScenario implements Scenario { name = 'auth/client-credentials-basic'; - specVersions: SpecVersion[] = ['extension']; + readonly source = { + extensionId: 'io.modelcontextprotocol/oauth-client-credentials' + } as const; description = 'Tests OAuth client_credentials flow with client_secret_basic authentication'; @@ -264,10 +265,10 @@ export class ClientCredentialsBasicScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { grantTypesSupported: ['client_credentials'], tokenEndpointAuthMethodsSupported: ['client_secret_basic'], onTokenRequest: async ({ @@ -365,6 +366,7 @@ export class ClientCredentialsBasicScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 3dd64b7..c5623a9 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -6,11 +6,13 @@ * generated from them. */ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; +import { addResourceParameterChecks } from './helpers/resourceParameterChecks'; import { SpecReferences } from './spec-references'; import { Request, Response } from 'express'; @@ -76,6 +78,13 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { const server = new ServerLifecycle(); let checks: ConformanceCheck[] = []; + // Track resource parameters for RFC 8707 validation. metadata-var2 serves + // the PRM at the root, so its `resource` is a bare origin: the case a URL + // parser rewrites with a trailing slash. + let authorizationResource: string | undefined; + let tokenResource: string | undefined; + let prmResource: string | undefined; + const routePrefix = config.authRoutePrefix || ''; const isOpenIdConfiguration = config.oauthMetadataLocation.includes( 'openid-configuration' @@ -87,20 +96,32 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { return { name: `auth/${config.name}`, - specVersions: ['2025-11-25'], + source: { introducedIn: '2025-11-25' }, description: `Tests Basic OAuth metadata discovery flow. **PRM:** ${config.prmLocation}${config.inWwwAuth ? '' : ' (not in WWW-Authenticate)'} **OAuth metadata:** ${config.oauthMetadataLocation} `, - async start(): Promise { + async start(ctx: ScenarioContext): Promise { checks = []; + authorizationResource = undefined; + tokenResource = undefined; + prmResource = undefined; - const authApp = createAuthServer(checks, authServer.getUrl, { + const authApp = createAuthServer(ctx, checks, authServer.getUrl, { metadataPath: config.oauthMetadataLocation, isOpenIdConfiguration, - ...(routePrefix && { routePrefix }) + ...(routePrefix && { routePrefix }), + onAuthorizationRequest: ({ resource }) => { + authorizationResource = resource; + }, + onTokenRequest: ({ body }) => { + tokenResource = body.resource; + // Same token the auth server mints by default; these scenarios + // request no scopes. + return { token: `test-token-${Date.now()}`, scopes: [] }; + } }); // If path-based OAuth metadata, trap root requests @@ -131,9 +152,12 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { ? () => `${authServer.getUrl()}${routePrefix}` : authServer.getUrl; - const app = createServer(checks, server.getUrl, getAuthServerUrl, { + const app = createServer(ctx, checks, server.getUrl, getAuthServerUrl, { prmPath: config.prmLocation, - includePrmInWwwAuth: config.inWwwAuth + includePrmInWwwAuth: config.inWwwAuth, + onPrmRequest: ({ resource }) => { + prmResource = resource; + } }); // Add trap for root PRM requests if configured @@ -197,6 +221,13 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { } } + // RFC 8707 Resource Parameter Validation Checks + addResourceParameterChecks( + checks, + { authorizationResource, tokenResource, prmResource }, + new Date().toISOString() + ); + return checks; } }; diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts new file mode 100644 index 0000000..8b16ba5 --- /dev/null +++ b/src/scenarios/client/auth/dpop.ts @@ -0,0 +1,333 @@ +import type { ScenarioContext } from '../../../mock-server'; +import type { + Scenario, + ConformanceCheck, + CheckStatus, + SpecReference +} from '../../../types'; +import { ScenarioUrls } from '../../../types'; +import { + createAuthServer, + type DpopTokenRequestObservation +} from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { + createDpopResourceAuth, + newDpopClientObservations, + type DpopClientObservations +} from './helpers/dpopResourceAuth'; +import { SpecReferences } from './spec-references'; +import { collapseDuplicateChecks } from '../../../checks/collapse'; + +const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; + +/** Static id → (name, description, spec references) for each emitted check. */ +const CHECK_DEFS: Record< + string, + { name: string; description: string; specReferences: SpecReference[] } +> = { + 'sep-1932-client-dpop-auth-scheme': { + name: 'DpopAuthScheme', + description: + 'Client presents the access token with the DPoP Authorization scheme (not Bearer)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_AUTH_SCHEME + ] + }, + 'sep-1932-client-fresh-proof': { + name: 'DpopFreshProof', + description: + 'Client includes a fresh, well-formed DPoP proof in the DPoP header on each MCP POST request', + specReferences: [ + SpecReferences.RFC_9449_CHECKING_PROOFS, + SpecReferences.RFC_9449_PROOF_SYNTAX, + SpecReferences.DPOP_EXTENSION + ] + }, + 'sep-1932-client-token-request-proof': { + name: 'DpopTokenRequestProof', + description: + 'Client includes a valid DPoP proof in the DPoP header of its token request — the prerequisite for obtaining a DPoP-bound access token (RFC 9449 §5)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_TOKEN_REQUEST + ] + }, + 'sep-1932-client-as-nonce': { + name: 'DpopAsNonce', + description: + 'On a use_dpop_nonce challenge from the token endpoint, the client retries the token request with the server-supplied nonce (RFC 9449 §8)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_AS_NONCE + ] + }, + 'sep-1932-client-rs-nonce': { + name: 'DpopRsNonce', + description: + 'On a use_dpop_nonce challenge from the MCP server, the client retries the request with the server-supplied nonce (RFC 9449 §9)', + specReferences: [ + SpecReferences.SEP_1932_DPOP, + SpecReferences.DPOP_EXTENSION, + SpecReferences.RFC_9449_RS_NONCE + ] + } +}; + +/** + * Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449). + * + * The test authorization server (DPoP-capable `createAuthServer`) issues a + * DPoP-bound token; the test MCP server judges how the client presents it. + * + * Registered in two postures, because server-provided nonces are OPTIONAL in + * RFC 9449 (AS §8 "MAY", RS §9 "can also choose") and the two are mutually + * exclusive for a given run: + * + * - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline. + * Neither the AS nor the MCP server issues a nonce challenge; the client + * completes the flow with plain proofs. Emits three checks: + * · token acquisition — a valid DPoP proof at the token request, obtaining + * a sender-constrained token (RFC 9449 §5); + * · the token is presented with the `DPoP` Authorization scheme (§7.1); + * · a fresh, well-formed DPoP proof accompanies each request (unique `jti`). + * + * - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both + * require a server-provided nonce (§8/§9), exercising the client's nonce + * handling. Emits the three baseline checks plus two more: + * · the client retries the token request with the AS-supplied nonce (§8); + * · the client retries the MCP request with the server-supplied nonce (§9). + */ +function newTokenReqObs(): DpopTokenRequestObservation { + return { + recorded: false, + validProof: false, + asNonceChallengeIssued: false, + asNonceHonored: false + }; +} + +export class DPoPClientScenario implements Scenario { + readonly name: string; + readonly source = { + extensionId: 'io.modelcontextprotocol/auth/dpop' + } as const; + readonly description: string; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private obs: DpopClientObservations = newDpopClientObservations(); + private tokenReqObs: DpopTokenRequestObservation = newTokenReqObs(); + + /** + * @param requireNonce when true (`auth/dpop-nonce`) the test AS and MCP server + * both demand a server-provided nonce (RFC 9449 §8/§9); when false + * (`auth/dpop`) neither challenges and the client completes with plain + * proofs — the common, nonce-less baseline. + */ + constructor(private readonly requireNonce: boolean) { + this.name = requireNonce ? 'auth/dpop-nonce' : 'auth/dpop'; + this.description = requireNonce + ? 'Tests that an MCP client, when the authorization server and MCP server require a DPoP nonce, retries the token request and the MCP request with the server-supplied nonce (RFC 9449 §8/§9) — on top of requesting a DPoP-bound token and presenting it with the DPoP Authorization scheme and a fresh proof per request (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).' + : 'Tests that an MCP client requests a DPoP-bound access token (a valid DPoP proof at the token request) and presents it using the DPoP Authorization scheme (not Bearer) with a fresh, well-formed DPoP proof on each POST /mcp request, when the server does not require a nonce (SEP-1932 / RFC 9449 §5, §7.1, §4.2–4.3).'; + } + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.obs = newDpopClientObservations(); + this.tokenReqObs = newTokenReqObs(); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + dpopSigningAlgValuesSupported: ['ES256'], + dpopTokenRequestObs: this.tokenReqObs, + dpopRequireNonce: this.requireNonce + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + authMiddleware: createDpopResourceAuth( + this.obs, + () => `${this.server.getUrl()}/mcp`, + () => `${this.server.getUrl()}${PRM_PATH}`, + this.requireNonce + ) + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop(): Promise { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Only the nonce posture re-POSTs /token (challenge → retry), which + // duplicates the shared token-flow checks (token-request, pkce-*); collapse + // those. The baseline (`auth/dpop`) is left untouched so genuinely distinct + // repeated attempts (e.g. a restarted authorization flow) keep both entries. + const shared = this.requireNonce + ? collapseDuplicateChecks(this.checks) + : this.checks; + const checks: ConformanceCheck[] = [ + ...shared, + this.tokenRequestProofCheck(), + this.authSchemeCheck(), + this.freshProofCheck() + ]; + // The nonce checks only apply to the nonce-requiring posture: in the + // baseline (`auth/dpop`) neither server issues a `use_dpop_nonce` + // challenge, so there is no nonce behaviour to assert. + if (this.requireNonce) { + checks.push(this.asNonceCheck(), this.rsNonceCheck()); + } + return checks; + } + + private asNonceCheck(): ConformanceCheck { + const challenged = this.tokenReqObs.asNonceChallengeIssued; + const honored = this.tokenReqObs.asNonceHonored; + // SUCCESS requires that a challenge was actually issued AND then honored — + // grading `honored` alone would let a client that pre-sends the nonce + // (never challenged) pass vacuously. + const pass = challenged && honored; + return this.build( + 'sep-1932-client-as-nonce', + pass ? 'SUCCESS' : 'FAILURE', + { + errorMessage: pass + ? undefined + : challenged + ? 'Client did not complete the token request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)' + : 'Client never presented a valid proof that was answered with a use_dpop_nonce challenge', + details: { + challengeIssued: challenged, + nonceHonored: honored + } + } + ); + } + + private rsNonceCheck(): ConformanceCheck { + const challenged = this.obs.rsNonceChallengeIssued; + const honored = this.obs.rsNonceHonored; + // SUCCESS requires that a challenge was actually issued AND then honored — + // see asNonceCheck; grading `honored` alone permits a vacuous pass. + const pass = challenged && honored; + return this.build( + 'sep-1932-client-rs-nonce', + pass ? 'SUCCESS' : 'FAILURE', + { + errorMessage: pass + ? undefined + : challenged + ? 'Client did not complete the MCP request with the server-supplied nonce after a use_dpop_nonce challenge (it either did not retry or the retried proof was rejected)' + : 'Client never made an MCP request with a valid proof that was answered with a use_dpop_nonce challenge', + details: { + challengeIssued: challenged, + nonceHonored: honored + } + } + ); + } + + private tokenRequestProofCheck(): ConformanceCheck { + let status: CheckStatus; + let errorMessage: string | undefined; + if (!this.tokenReqObs.recorded) { + status = 'FAILURE'; + errorMessage = + 'Client never completed an authorization_code token request, so it obtained no DPoP-bound token'; + } else if (!this.tokenReqObs.validProof) { + status = 'FAILURE'; + errorMessage = `Client did not present a valid DPoP proof at the token endpoint: ${this.tokenReqObs.error}`; + } else { + status = 'SUCCESS'; + } + return this.build('sep-1932-client-token-request-proof', status, { + errorMessage, + details: { + tokenRequestObserved: this.tokenReqObs.recorded, + validProofPresented: this.tokenReqObs.validProof + } + }); + } + + private authSchemeCheck(): ConformanceCheck { + let status: CheckStatus; + let errorMessage: string | undefined; + if (this.obs.authenticatedRequests === 0) { + status = 'FAILURE'; + errorMessage = 'Client never presented an access token to the MCP server'; + } else if (this.obs.nonDpopSchemeSeen) { + status = 'FAILURE'; + errorMessage = `Client presented the token with a non-DPoP Authorization scheme (${[...new Set(this.obs.observedSchemes)].join(', ')})`; + } else { + status = 'SUCCESS'; + } + return this.build('sep-1932-client-dpop-auth-scheme', status, { + errorMessage, + details: { + authenticatedRequests: this.obs.authenticatedRequests, + observedSchemes: [...new Set(this.obs.observedSchemes)] + } + }); + } + + private freshProofCheck(): ConformanceCheck { + let status: CheckStatus; + let errorMessage: string | undefined; + if (this.obs.authenticatedRequests === 0) { + status = 'FAILURE'; + errorMessage = 'Client never presented an access token to the MCP server'; + } else if (!this.obs.allProofsWellFormed) { + status = 'FAILURE'; + errorMessage = `DPoP proof was missing or malformed: ${this.obs.proofError}`; + } else if (this.obs.replayDetected) { + status = 'FAILURE'; + errorMessage = + 'Client reused a DPoP proof (duplicate jti) across requests instead of sending a fresh one'; + } else { + status = 'SUCCESS'; + } + return this.build('sep-1932-client-fresh-proof', status, { + errorMessage, + details: { + authenticatedRequests: this.obs.authenticatedRequests, + distinctJtis: this.obs.jtisSeen.length, + replayDetected: this.obs.replayDetected + } + }); + } + + private build( + id: string, + status: CheckStatus, + opts: { errorMessage?: string; details?: Record } = {} + ): ConformanceCheck { + const def = CHECK_DEFS[id]; + return { + id, + name: def.name, + description: def.description, + status, + timestamp: new Date().toISOString(), + specReferences: def.specReferences, + ...(opts.errorMessage ? { errorMessage: opts.errorMessage } : {}), + ...(opts.details ? { details: opts.details } : {}) + }; + } +} diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts similarity index 95% rename from src/scenarios/client/auth/cross-app-access.ts rename to src/scenarios/client/auth/enterprise-managed-authorization.ts index 05a351b..eff5e25 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -1,13 +1,10 @@ +import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; -import type { - Scenario, - ConformanceCheck, - ScenarioUrls, - SpecVersion -} from '../../../types'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; +import { JWT_BEARER_GRANT_TYPE } from './helpers/createWorkloadJwt.js'; import { createServer } from './helpers/createServer'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -53,16 +50,18 @@ async function createIdpIdToken( } /** - * Scenario: Complete Cross-App Access Flow + * Scenario: Enterprise-Managed Authorization (SEP-990) * * Tests the complete SEP-990 flow: IDP ID token -> authorization grant -> access token * This scenario combines both RFC 8693 token exchange and RFC 7523 JWT bearer grant. */ -export class CrossAppAccessCompleteFlowScenario implements Scenario { - name = 'auth/cross-app-access-complete-flow'; - specVersions: SpecVersion[] = ['extension']; +export class EnterpriseManagedAuthorizationScenario implements Scenario { + name = 'auth/enterprise-managed-authorization'; + readonly source = { + extensionId: 'io.modelcontextprotocol/enterprise-managed-authorization' + } as const; description = - 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise Managed OAuth)'; + 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise-Managed Authorization)'; private idpServer = new ServerLifecycle(); private authServer = new ServerLifecycle(); @@ -72,7 +71,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { private idpPrivateKey?: CryptoKey; private grantKeypairs: Map = new Map(); - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Generate IDP keypair @@ -89,8 +88,8 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { // Start auth server with JWT bearer grant support only // Token exchange is handled by IdP - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + grantTypesSupported: [JWT_BEARER_GRANT_TYPE], tokenEndpointAuthMethodsSupported: ['client_secret_basic'], tokenVerifier, onTokenRequest: async ({ @@ -101,7 +100,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { authorizationHeader }) => { // Auth server only handles JWT bearer grant (ID-JAG -> access token) - if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') { + if (grantType === JWT_BEARER_GRANT_TYPE) { const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`; return await this.handleJwtBearerGrant( body, @@ -123,6 +122,7 @@ export class CrossAppAccessCompleteFlowScenario implements Scenario { // Start MCP server with shared token verifier const mcpApp = createServer( + ctx, this.checks, this.mcpServer.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 8621490..162f93a 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,9 +1,17 @@ import express, { Request, Response } from 'express'; import { createHash } from 'crypto'; import type { ConformanceCheck } from '../../../../types'; +import type { ScenarioContext } from '../../../../mock-server'; +import { isStatefulVersion } from '../../../../connection/select'; import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; +import * as jose from 'jose'; +import { + generateIssuerKey, + mintDpopBoundToken, + type TokenIssuerKey +} from './dpopToken'; /** * Compute S256 code challenge from a code verifier. @@ -18,6 +26,105 @@ function computeS256Challenge(codeVerifier: string): string { .replace(/=+$/, ''); } +// Fixed nonce the test AS hands out when `dpopRequireNonce` is set (RFC 9449 §8). +const AS_DPOP_NONCE = 'conformance-as-dpop-nonce'; + +// Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3, §11.6). +const DPOP_ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +/** + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based + * normalization): lowercase scheme/host, drop the default port, ignore a + * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), + * not silently stripped here. + */ +function normalizeHtu(u: string): string { + try { + const url = new URL(u); + // Tolerate a single trailing slash only; `/mcp//` is a distinct path. + return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + } catch { + return u; + } +} + +/** + * Validate a DPoP proof presented at the token endpoint (the token-request + * subset of RFC 9449 §4.3). Hand-rolled with jose primitives — deliberately an + * INDEPENDENT code path from the suite's proof builder, so a shared bug + * surfaces rather than hides. Returns the JWK thumbprint to bind on success. + */ +export async function validateDpopProofAtTokenEndpoint( + proof: string, + tokenEndpointUrl: string +): Promise<{ ok: true; jkt: string } | { ok: false; error: string }> { + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return { ok: false, error: 'DPoP proof is not a well-formed JWT' }; + } + if (header.typ !== 'dpop+jwt') { + return { ok: false, error: 'typ must be dpop+jwt' }; + } + if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) { + return { ok: false, error: 'alg must be a supported asymmetric algorithm' }; + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) { + return { ok: false, error: 'missing jwk header parameter' }; + } + if ((jwk as Record).d !== undefined) { + return { ok: false, error: 'jwk must not contain a private key' }; + } + let claims: jose.JWTPayload; + try { + const key = await jose.importJWK(jwk, header.alg); + claims = ( + await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS }) + ).payload; + } catch { + return { ok: false, error: 'DPoP proof signature does not verify' }; + } + if (typeof claims.jti !== 'string') { + return { ok: false, error: 'missing jti claim' }; + } + if (claims.htm !== 'POST') { + return { ok: false, error: 'htm does not match POST' }; + } + if (typeof claims.htu !== 'string') { + return { ok: false, error: 'htu does not match the token endpoint' }; + } + if (claims.htu.includes('?') || claims.htu.includes('#')) { + return { + ok: false, + error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)' + }; + } + if (normalizeHtu(claims.htu) !== normalizeHtu(tokenEndpointUrl)) { + return { ok: false, error: 'htu does not match the token endpoint' }; + } + if ( + typeof claims.iat !== 'number' || + Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 + ) { + return { ok: false, error: 'iat outside the acceptable window' }; + } + const jkt = await jose.calculateJwkThumbprint(jwk, 'sha256'); + return { ok: true, jkt }; +} + export interface TokenRequestResult { token: string; scopes: string[]; @@ -29,6 +136,24 @@ export interface TokenRequestError { statusCode?: number; } +/** + * Sink for the DPoP token-request observation (RFC 9449 §5). The AS writes to + * it on the authorization_code exchange (sticky-failure: once any exchange + * lacks a valid proof it stays FAILURE; refresh grants are ignored) so the + * client scenario can emit sep-1932-client-token-request-proof unconditionally + * — FAILURE by default when the client never reaches the token endpoint, + * matching its sibling checks. + */ +export interface DpopTokenRequestObservation { + recorded: boolean; + validProof: boolean; + error?: string; + /** The token endpoint issued a `use_dpop_nonce` challenge (RFC 9449 §8). */ + asNonceChallengeIssued: boolean; + /** The client retried the token request carrying the correct nonce. */ + asNonceHonored: boolean; +} + export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; @@ -43,6 +168,52 @@ export interface AuthServerOptions { disableDynamicRegistration?: boolean; /** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */ codeChallengeMethodsSupported?: string[] | null; + /** Advertise authorization_response_iss_parameter_supported in AS metadata. Default: true. Pass null to omit. */ + issParameterSupported?: boolean | null; + /** + * What iss value to include in authorization redirect. Default: 'correct'. + * 'normalized' sends a normalization-equivalent variant of the correct + * issuer (trailing slash appended, exactly what `new URL(x).href` + * round-tripping produces) — equal under RFC 3986 §6.2.2–6.2.3 + * normalization but not under simple string comparison. + */ + issInRedirect?: 'correct' | 'wrong' | 'omit' | 'normalized'; + /** + * Override the `issuer` value served in the AS metadata document. Used to + * test that clients validate the metadata issuer against the issuer + * identifier used to construct the well-known URL (RFC 8414 §3.3). + * Accepts a lazy getter for callers that don't know the server URL until + * after `start()`. + */ + metadataIssuer?: string | (() => string); + /** + * DPoP (SEP-1932 / RFC 9449) — opt-in; absent ⇒ no DPoP behaviour at all. + * When set, the AS advertises `dpop_signing_alg_values_supported` in its + * metadata and validates+binds a DPoP proof presented at the token endpoint. + */ + dpopSigningAlgValuesSupported?: string[]; + /** + * Negative-test mode: make the AS misbehave in one specific way so a check + * can be proven to FAIL. + * - 'omit-alg-values' — drop `dpop_signing_alg_values_supported` entirely + * - 'empty-alg-values' — advertise the field as an empty array + * - 'include-none' — list `none` among the supported proof algs + * - 'unbound-token' — issue a Bearer token ignoring a valid proof + */ + dpopMisbehavior?: + | 'omit-alg-values' + | 'empty-alg-values' + | 'include-none' + | 'unbound-token'; + /** Sink for the DPoP token-request observation; see the interface docstring. */ + dpopTokenRequestObs?: DpopTokenRequestObservation; + /** + * When true, the token endpoint requires a DPoP nonce (RFC 9449 §8): a + * proof-bearing request without the correct `nonce` claim is answered with + * `400 use_dpop_nonce` + a `DPoP-Nonce` header, and the retry carrying the + * nonce is accepted. Used to exercise the client's nonce handling. + */ + dpopRequireNonce?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -70,6 +241,7 @@ export interface AuthServerOptions { } export function createAuthServer( + ctx: ScenarioContext, checks: ConformanceCheck[], getAuthBaseUrl: () => string, options: AuthServerOptions = {} @@ -86,6 +258,13 @@ export function createAuthServer( clientIdMetadataDocumentSupported, disableDynamicRegistration = false, codeChallengeMethodsSupported = ['S256'], + issParameterSupported = true, + issInRedirect = 'correct', + metadataIssuer, + dpopSigningAlgValuesSupported, + dpopMisbehavior, + dpopTokenRequestObs, + dpopRequireNonce = false, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -96,6 +275,34 @@ export function createAuthServer( let lastAuthorizationScopes: string[] = []; // Track PKCE code_challenge for verification in token request let storedCodeChallenge: string | undefined; + // Lazily-created issuer key for minting DPoP-bound JWT access tokens. + let dpopIssuerKey: TokenIssuerKey | undefined; + // DPoP behaviour is active only when the caller opts in (any DPoP option). + const dpopEnabled = + dpopSigningAlgValuesSupported !== undefined || + dpopMisbehavior !== undefined; + + // Records whether the client presented a valid DPoP proof at its + // authorization_code token request (RFC 9449 §5) into the caller's observation + // sink. Sticky-failure: FAILURE if ANY exchange lacked a valid proof, SUCCESS + // only if every one had one — so a later proof-less exchange (e.g. a refresh + // done as authorization_code) can't overwrite an earlier failure, nor vice + // versa. Refresh grants are ignored entirely. The client scenario reads this + // to emit sep-1932-client-token-request-proof unconditionally (FAILURE by + // default when the client never reaches the token endpoint). + const recordTokenRequestProof = ( + grantType: string, + ok: boolean, + detail?: string + ): void => { + if (grantType !== 'authorization_code' || !dpopTokenRequestObs) return; + const valid = dpopTokenRequestObs.recorded + ? dpopTokenRequestObs.validProof && ok + : ok; + dpopTokenRequestObs.recorded = true; + dpopTokenRequestObs.validProof = valid; + if (!valid && detail) dpopTokenRequestObs.error ??= detail; + }; const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, @@ -103,6 +310,14 @@ export function createAuthServer( registration_endpoint: `${routePrefix}/register` }; + // The issuer identifier this AS publishes — used for both the metadata + // `issuer` field and the RFC 9207 callback `iss` parameter, which per + // RFC 9207 §2.4 must be identical under simple string comparison. + const resolveIssuer = () => + typeof metadataIssuer === 'function' + ? metadataIssuer() + : (metadataIssuer ?? `${getAuthBaseUrl()}${routePrefix}`); + const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -134,7 +349,7 @@ export function createAuthServer( }); const metadata: any = { - issuer: getAuthBaseUrl(), + issuer: resolveIssuer(), authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, ...(!disableDynamicRegistration && { @@ -146,11 +361,28 @@ export function createAuthServer( ...(codeChallengeMethodsSupported !== null && { code_challenge_methods_supported: codeChallengeMethodsSupported }), + ...(issParameterSupported !== null && { + authorization_response_iss_parameter_supported: issParameterSupported + }), token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported, ...(tokenEndpointAuthSigningAlgValuesSupported && { token_endpoint_auth_signing_alg_values_supported: tokenEndpointAuthSigningAlgValuesSupported - }) + }), + // DPoP AS-metadata support signal (SEP-1932 / RFC 9449 §5.1). Opt-in; + // misbehaviour modes alter it: 'omit-alg-values' drops the field, + // 'empty-alg-values' advertises an empty array, 'include-none' adds the + // forbidden `none` alg. (dpop_bound_access_tokens is client-registration + // metadata per RFC 9449 §5.2, so it is deliberately NOT advertised here.) + ...(dpopMisbehavior !== 'omit-alg-values' && + dpopSigningAlgValuesSupported !== undefined && { + dpop_signing_alg_values_supported: + dpopMisbehavior === 'empty-alg-values' + ? [] + : dpopMisbehavior === 'include-none' + ? [...dpopSigningAlgValuesSupported, 'none'] + : dpopSigningAlgValuesSupported + }) }; // Add scopes_supported if provided @@ -244,6 +476,21 @@ export function createAuthServer( redirectUrl.searchParams.set('state', state); } + // ISS: Include iss parameter in redirect if configured. The 'correct' + // value must equal the metadata `issuer` exactly (RFC 9207 §2.4 simple + // string comparison), so honor the same metadataIssuer override the + // metadata document does. + if (issInRedirect === 'correct') { + redirectUrl.searchParams.set('iss', resolveIssuer()); + } else if (issInRedirect === 'wrong') { + redirectUrl.searchParams.set('iss', 'https://evil.example.com'); + } else if (issInRedirect === 'normalized') { + // Normalization-equivalent variant of the correct issuer: identical + // after RFC 3986 scheme-based normalization (trailing slash on an + // empty path) but different under simple string comparison. + redirectUrl.searchParams.set('iss', `${resolveIssuer()}/`); + } + res.redirect(redirectUrl.toString()); }); @@ -320,6 +567,122 @@ export function createAuthServer( }); } + // ---- DPoP token binding (SEP-1932 / RFC 9449) — opt-in ---- + if (dpopEnabled) { + const proofHeader = req.headers['dpop']; + const proofValue = Array.isArray(proofHeader) + ? proofHeader.join(', ') + : proofHeader; + // RFC 9449 §4.2: at most one DPoP header field. Node collapses duplicate + // request headers into one comma-joined value; a valid proof JWT contains + // no comma, so a comma means more than one proof was sent. + if (typeof proofValue === 'string' && proofValue.includes(',')) { + recordTokenRequestProof( + grantType, + false, + 'multiple DPoP proof headers' + ); + res.status(400).json({ + error: 'invalid_dpop_proof', + error_description: 'Multiple DPoP proof headers' + }); + return; + } + const proof = proofValue; + const tokenEndpointUrl = `${getAuthBaseUrl()}${authRoutes.token_endpoint}`; + const grantedScopes = requestedScope + ? requestedScope.split(' ') + : lastAuthorizationScopes; + + // No proof ⇒ DPoP is not being exercised here; fall through to a Bearer + // token. (Requiring a proof is a per-client `dpop_bound_access_tokens` + // registration policy — RFC 9449 §5.2 — not a global AS behaviour.) The + // client-side check still records that the client failed to ask for a + // bound token. + if (proof) { + const result = await validateDpopProofAtTokenEndpoint( + proof, + tokenEndpointUrl + ); + if (!result.ok) { + recordTokenRequestProof(grantType, false, result.error); + res.status(400).json({ + error: 'invalid_dpop_proof', + error_description: result.error + }); + return; + } + // The proof itself is valid — record that now, BEFORE any nonce + // challenge, so a client that is challenged but does not retry is not + // mis-reported by token-request-proof as "never completed a token + // request" (its proof was just verified). Sticky + gated on + // authorization_code inside recordTokenRequestProof. + recordTokenRequestProof(grantType, true); + + // RFC 9449 §8: require a server-provided nonce. A proof without the + // correct nonce is challenged (400 use_dpop_nonce + DPoP-Nonce); the + // client is expected to retry with it. The nonce observation is gated + // on the authorization_code exchange, matching recordTokenRequestProof + // (honoring a challenge on a refresh exchange must not satisfy §8). + if (dpopRequireNonce) { + let proofNonce: unknown; + try { + proofNonce = jose.decodeJwt(proof).nonce; + } catch { + proofNonce = undefined; + } + if (proofNonce !== AS_DPOP_NONCE) { + if (grantType === 'authorization_code' && dpopTokenRequestObs) + dpopTokenRequestObs.asNonceChallengeIssued = true; + res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({ + error: 'use_dpop_nonce', + error_description: 'Authorization server requires a DPoP nonce' + }); + return; + } + if (grantType === 'authorization_code' && dpopTokenRequestObs) + dpopTokenRequestObs.asNonceHonored = true; + } + + if (dpopMisbehavior === 'unbound-token') { + // Misbehaviour: ignore the binding and issue a plain Bearer token. + const bearer = `test-token-${Date.now()}`; + if (tokenVerifier) tokenVerifier.registerToken(bearer, grantedScopes); + res.json({ + access_token: bearer, + token_type: 'Bearer', + expires_in: 3600 + }); + return; + } + + if (!dpopIssuerKey) { + dpopIssuerKey = await generateIssuerKey(); + } + const boundToken = await mintDpopBoundToken({ + issuerKey: dpopIssuerKey, + issuer: resolveIssuer(), + audience: + (req.body.resource as string) || 'urn:conformance-test-resource', + jkt: result.jkt, + ...(requestedScope && { scope: requestedScope }) + }); + res.json({ + access_token: boundToken, + token_type: 'DPoP', + expires_in: 3600, + ...(requestedScope && { scope: requestedScope }) + }); + return; + } + // dpopEnabled but the token request carried no DPoP proof. + recordTokenRequestProof( + grantType, + false, + 'no DPoP proof in the token request' + ); + } + let token = `test-token-${Date.now()}`; let scopes: string[] = lastAuthorizationScopes; @@ -386,6 +749,31 @@ export function createAuthServer( } }); + // SEP-837: clients MUST specify an appropriate application_type during DCR. + // The harness can't know the client's real class (native vs web), so this + // checks presence + that the value is one of the two OIDC-defined values. + // SEP-837 first appears in the draft spec (the same revision that + // introduces the stateless lifecycle), so the check only exists for runs + // targeting a version that includes it; at dated versions it is not + // emitted at all. + if (!isStatefulVersion(ctx.specVersion)) { + const appType = req.body.application_type; + const validAppType = appType === 'native' || appType === 'web'; + checks.push({ + id: 'sep-837-application-type-present', + name: 'DCR application_type specified', + description: validAppType + ? `Client specified application_type "${appType}" during Dynamic Client Registration` + : appType === undefined + ? 'Client MUST specify an appropriate application_type during Dynamic Client Registration (SEP-837); field was omitted' + : `Client MUST specify an appropriate application_type during Dynamic Client Registration (SEP-837); got "${appType}", expected "native" or "web"`, + status: validAppType ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_DCR], + details: { application_type: appType ?? '(omitted)' } + }); + } + res.status(201).json({ client_id: clientId, ...(clientSecret && { client_secret: clientSecret }), diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index c836630..6d308d6 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -10,6 +10,12 @@ import { import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types'; +import { + validateStatelessRequest, + withRequiredDraftResultFields, + type ScenarioContext +} from '../../../../mock-server'; +import { isStatefulVersion } from '../../../../connection/select'; import { createRequestLogger } from '../../../request-logger'; import { MockTokenVerifier } from './mockTokenVerifier'; import { SpecReferences } from '../spec-references'; @@ -24,9 +30,12 @@ export interface ServerOptions { tokenVerifier?: MockTokenVerifier; /** Override the resource field in PRM response (for testing resource mismatch) */ prmResourceOverride?: string; + /** Observe the `resource` identifier the PRM route served (RFC 8707 checks) */ + onPrmRequest?: (requestData: { resource: string; timestamp: string }) => void; } export function createServer( + ctx: ScenarioContext, checks: ConformanceCheck[], getBaseUrl: () => string, getAuthServerUrl: () => string, @@ -39,7 +48,8 @@ export function createServer( includePrmInWwwAuth = true, includeScopeInWwwAuth = false, tokenVerifier, - prmResourceOverride + prmResourceOverride, + onPrmRequest } = options; // Factory: create a fresh Server per request to avoid "Already connected" errors // after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7) @@ -123,6 +133,8 @@ export function createServer( ? getBaseUrl() : `${getBaseUrl()}/mcp`); + onPrmRequest?.({ resource, timestamp: new Date().toISOString() }); + const prmResponse: any = { resource, authorization_servers: [getAuthServerUrl()] @@ -157,6 +169,9 @@ export function createServer( authMiddleware(req, res, async (err?: any) => { if (err) return next(err); + if (!isStatefulVersion(ctx.specVersion)) { + return handleStateless(req, res); + } const server = createMcpServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined @@ -164,12 +179,13 @@ export function createServer( try { await server.connect(transport); - - await transport.handleRequest(req, res, req.body); + // Register cleanup before handing the request to the transport so the + // pair is torn down even when handleRequest throws. res.on('close', () => { transport.close(); server.close(); }); + await transport.handleRequest(req, res, req.body); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { @@ -186,5 +202,40 @@ export function createServer( }); }); + // Stateless lifecycle for the /mcp route: shared SEP-2575 validation + + // server/discover from mock-server/stateless, then the same tools handlers + // as createMcpServer. Bearer-auth middleware and PRM route above are + // version-independent. + function handleStateless(req: Request, res: Response) { + const v = validateStatelessRequest(req, { tools: {} }, [ctx.specVersion]); + if (v.kind !== 'route') { + return res.status(v.status).json(v.body); + } + const { id, method } = v; + if (method === 'tools/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, { + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + }) + }); + } + if (method === 'tools/call') { + return res.json({ + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, { + content: [{ type: 'text', text: 'test' }] + }) + }); + } + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } + }); + } + return app; } diff --git a/src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts b/src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts new file mode 100644 index 0000000..7f69283 --- /dev/null +++ b/src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts @@ -0,0 +1,194 @@ +import * as jose from 'jose'; +import { describe, it, expect } from 'vitest'; +import { + JWT_BEARER_GRANT_TYPE, + DEFAULT_WORKLOAD_JWT_ALG, + createWorkloadJwt, + generateWorkloadKeypair +} from './createWorkloadJwt.js'; + +describe('constants', () => { + it('JWT_BEARER_GRANT_TYPE matches the IANA-registered URN format', () => { + expect(JWT_BEARER_GRANT_TYPE).toMatch(/^urn:ietf:params:oauth:grant-type:/); + expect(JWT_BEARER_GRANT_TYPE).toBe( + 'urn:ietf:params:oauth:grant-type:jwt-bearer' + ); + }); + + it('DEFAULT_WORKLOAD_JWT_ALG is ES256', () => { + expect(DEFAULT_WORKLOAD_JWT_ALG).toBe('ES256'); + }); +}); + +describe('generateWorkloadKeypair', () => { + it('returns an ES256 keypair with PEM and JWK', async () => { + const kp = await generateWorkloadKeypair(); + expect(kp.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/); + expect(kp.publicJwk.kty).toBe('EC'); + expect(kp.publicJwk.crv).toBe('P-256'); + expect(kp.publicKey).toBeDefined(); + expect(kp.privateKey).toBeDefined(); + }); + + it('uses the specified algorithm', async () => { + const kp = await generateWorkloadKeypair('RS256'); + expect(kp.publicJwk.kty).toBe('RSA'); + }); +}); + +describe('createWorkloadJwt', () => { + it('produces a verifiable JWT with all standard claims', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'system:serviceaccount:prod:my-app', + audience: 'https://as.example/token', + privateKey: kp.privateKey + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey, { + issuer: 'https://issuer.example', + audience: 'https://as.example/token' + }); + + expect(payload.iss).toBe('https://issuer.example'); + expect(payload.sub).toBe('system:serviceaccount:prod:my-app'); + expect(payload.aud).toBe('https://as.example/token'); + expect(typeof payload.exp).toBe('number'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.jti).toBe('string'); + }); + + it('defaults exp to approximately 5 minutes after iat', async () => { + const kp = await generateWorkloadKeypair(); + const before = Math.floor(Date.now() / 1000); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey + }); + const after = Math.floor(Date.now() / 1000); + + const { payload } = await jose.jwtVerify(token, kp.publicKey); + const lifetime = (payload.exp as number) - (payload.iat as number); + expect(lifetime).toBeGreaterThanOrEqual(5 * 60 - 2); + expect(lifetime).toBeLessThanOrEqual(5 * 60 + 2); + expect(payload.iat as number).toBeGreaterThanOrEqual(before); + expect(payload.iat as number).toBeLessThanOrEqual(after); + }); + + it('accepts a negative duration string as expiresIn for already-expired tokens', async () => { + const kp = await generateWorkloadKeypair(); + const before = Math.floor(Date.now() / 1000); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + expiresIn: '-60s' + }); + + const payload = jose.decodeJwt(token); + expect(payload.exp).toBeLessThan(before); + await expect(jose.jwtVerify(token, kp.publicKey)).rejects.toThrow(); + }); + + it('generates a unique jti on each call with identical inputs', async () => { + const kp = await generateWorkloadKeypair(); + const opts = { + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey + }; + const t1 = await createWorkloadJwt(opts); + const t2 = await createWorkloadJwt(opts); + + const { payload: p1 } = await jose.jwtVerify(t1, kp.publicKey); + const { payload: p2 } = await jose.jwtVerify(t2, kp.publicKey); + expect(p1.jti).not.toBe(p2.jti); + }); + + it('preserves an array audience as an array', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: ['https://as.example/token', 'https://other.example'], + privateKey: kp.privateKey + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey, { + audience: 'https://as.example/token' + }); + expect(Array.isArray(payload.aud)).toBe(true); + expect(payload.aud).toContain('https://as.example/token'); + expect(payload.aud).toContain('https://other.example'); + }); + + it('merges additionalClaims without overriding reserved claims', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + additionalClaims: { + custom: 'value', + iss: 'should-be-ignored', + sub: 'should-be-ignored' + } + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey); + expect(payload.custom).toBe('value'); + expect(payload.iss).toBe('https://issuer.example'); + expect(payload.sub).toBe('workload'); + }); + + it('allows caller-supplied jwtId', async () => { + const kp = await generateWorkloadKeypair(); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + jwtId: 'fixed-jti-for-replay-test' + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey); + expect(payload.jti).toBe('fixed-jti-for-replay-test'); + }); + + it('sets notBefore when specified', async () => { + const kp = await generateWorkloadKeypair(); + const nbf = Math.floor(Date.now() / 1000) + 3600; + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + notBefore: nbf + }); + + const payload = jose.decodeJwt(token); + expect(payload.nbf).toBe(nbf); + }); + + it('uses the specified algorithm when signing', async () => { + const kp = await generateWorkloadKeypair('RS256'); + const token = await createWorkloadJwt({ + issuer: 'https://issuer.example', + subject: 'workload', + audience: 'https://as.example/token', + privateKey: kp.privateKey, + algorithm: 'RS256' + }); + + const { payload } = await jose.jwtVerify(token, kp.publicKey, { + algorithms: ['RS256'] + }); + expect(payload.iss).toBe('https://issuer.example'); + }); +}); diff --git a/src/scenarios/client/auth/helpers/createWorkloadJwt.ts b/src/scenarios/client/auth/helpers/createWorkloadJwt.ts new file mode 100644 index 0000000..3728bd8 --- /dev/null +++ b/src/scenarios/client/auth/helpers/createWorkloadJwt.ts @@ -0,0 +1,86 @@ +import * as jose from 'jose'; + +export const JWT_BEARER_GRANT_TYPE = + 'urn:ietf:params:oauth:grant-type:jwt-bearer'; +export const DEFAULT_WORKLOAD_JWT_ALG = 'ES256'; + +// Scope values used by the WIF scenario's mock AS and broken-client runners. +// Exported here so both sides stay in sync without manual duplication. +export const WIF_TRIGGER_UNAUTHORIZED_SCOPE = 'wif.trigger-unauthorized'; +export const WIF_REJECTED_SCOPE = 'wif.rejected'; + +export interface CreateWorkloadJwtOptions { + issuer: string; + subject: string; + audience: string | string[]; + privateKey: jose.CryptoKey; + /** Jose duration string (e.g. '5m', '-60s', '60 seconds ago'). Use a negative offset to construct already-expired tokens for negative tests. */ + expiresIn?: string; + jwtId?: string; + issuedAt?: number; + notBefore?: number; + algorithm?: string; + additionalClaims?: Record; +} + +export async function createWorkloadJwt( + opts: CreateWorkloadJwtOptions +): Promise { + const { + issuer, + subject, + audience, + privateKey, + expiresIn = '5m', + jwtId = crypto.randomUUID(), + issuedAt, + notBefore, + algorithm = DEFAULT_WORKLOAD_JWT_ALG, + additionalClaims + } = opts; + + // additionalClaims are merged first; reserved claims set via builder methods + // overwrite any same-named key already in the payload, so callers cannot + // accidentally override iss/sub/aud/exp/iat/jti via additionalClaims. + const extra: Record = additionalClaims + ? { ...additionalClaims } + : {}; + + let builder = new jose.SignJWT(extra) + .setProtectedHeader({ alg: algorithm }) + .setIssuer(issuer) + .setSubject(subject) + .setAudience(audience) + .setExpirationTime(expiresIn) + .setJti(jwtId); + + if (issuedAt !== undefined) { + builder = builder.setIssuedAt(issuedAt); + } else { + builder = builder.setIssuedAt(); + } + + if (notBefore !== undefined) { + builder = builder.setNotBefore(notBefore); + } + + return builder.sign(privateKey); +} + +export interface WorkloadKeypair { + publicKey: jose.CryptoKey; + privateKey: jose.CryptoKey; + privateKeyPem: string; + publicJwk: jose.JWK; +} + +export async function generateWorkloadKeypair( + alg: string = DEFAULT_WORKLOAD_JWT_ALG +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable: true + }); + const privateKeyPem = await jose.exportPKCS8(privateKey); + const publicJwk = await jose.exportJWK(publicKey); + return { publicKey, privateKey, privateKeyPem, publicJwk }; +} diff --git a/src/scenarios/client/auth/helpers/dpopProof.test.ts b/src/scenarios/client/auth/helpers/dpopProof.test.ts new file mode 100644 index 0000000..e0d1efc --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopProof.test.ts @@ -0,0 +1,180 @@ +import { webcrypto } from 'node:crypto'; +import * as jose from 'jose'; +import type { JWK } from 'jose'; +import { + generateDpopKeyPair, + accessTokenHash, + jwkThumbprint, + buildDpopProof +} from './dpopProof'; + +/** + * Independent ES256 signature verification using Node's native WebCrypto — + * a different code path from jose's signer, so a signing bug can't be masked + * by symmetric use of one library (validation Layer 2). + */ +async function verifyEs256Independently( + jwt: string, + publicJwk: JWK +): Promise { + const [h, p, s] = jwt.split('.'); + const key = await webcrypto.subtle.importKey( + 'jwk', + publicJwk as JsonWebKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + const signature = new Uint8Array(Buffer.from(s, 'base64url')); + const data = new TextEncoder().encode(`${h}.${p}`); + return webcrypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signature, + data + ); +} + +describe('DPoP proof helper — RFC 9449 published vectors (Layer 1)', () => { + // RFC 9449 §4 / §6.1 example: this EC public key's JWK thumbprint is the + // bound `cnf.jkt` value shown in the spec. + const RFC9449_EC_JWK: JWK = { + kty: 'EC', + x: 'l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs', + y: '9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA', + crv: 'P-256' + }; + const RFC9449_EXPECTED_JKT = '0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I'; + + // RFC 9449 §4.1 example: ath = base64url(SHA-256(ASCII access token)). + const RFC9449_ACCESS_TOKEN = 'Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU'; + const RFC9449_EXPECTED_ATH = 'fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo'; + + it('reproduces the RFC 9449 JWK thumbprint vector', async () => { + expect(await jwkThumbprint(RFC9449_EC_JWK)).toBe(RFC9449_EXPECTED_JKT); + }); + + it('reproduces the RFC 9449 access-token-hash (ath) vector', () => { + expect(accessTokenHash(RFC9449_ACCESS_TOKEN)).toBe(RFC9449_EXPECTED_ATH); + }); +}); + +describe('DPoP proof helper — key pair', () => { + it('generates an extractable P-256 public JWK with a consistent thumbprint', async () => { + const kp = await generateDpopKeyPair(); + expect(kp.publicJwk.kty).toBe('EC'); + expect(kp.publicJwk.crv).toBe('P-256'); + expect(kp.publicJwk.d).toBeUndefined(); // public JWK must not carry the private scalar + expect(kp.thumbprint).toBe( + await jose.calculateJwkThumbprint(kp.publicJwk, 'sha256') + ); + }); +}); + +describe('DPoP proof helper — valid proof', () => { + it('builds a well-formed dpop+jwt with the required header and claims', async () => { + const kp = await generateDpopKeyPair(); + const accessToken = 'example-access-token-value'; + const jwt = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: 'https://mcp.example.com/mcp', + accessToken + }); + + const header = jose.decodeProtectedHeader(jwt); + expect(header.typ).toBe('dpop+jwt'); + expect(header.alg).toBe('ES256'); + expect((header.jwk as JWK).kty).toBe('EC'); + expect((header.jwk as JWK).d).toBeUndefined(); + + const claims = jose.decodeJwt(jwt); + expect(typeof claims.jti).toBe('string'); + expect(claims.htm).toBe('POST'); + expect(claims.htu).toBe('https://mcp.example.com/mcp'); + expect(typeof claims.iat).toBe('number'); + expect(claims.ath).toBe(accessTokenHash(accessToken)); + }); + + it('signature verifies under an independent WebCrypto verifier (Layer 2)', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ + keyPair: kp, + htm: 'GET', + htu: 'https://mcp.example.com/mcp' + }); + expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(true); + }); +}); + +describe('DPoP proof helper — invalid variants isolate exactly one defect (Layer 3)', () => { + const base = { htm: 'POST', htu: 'https://mcp.example.com/mcp' }; + + it('a tampered signature fails verification while header and claims are unchanged', async () => { + const kp = await generateDpopKeyPair(); + const good = await buildDpopProof({ keyPair: kp, ...base }); + // Derive the bad proof from the good one so ONLY the signature differs + // (each build has a fresh jti and ECDSA signatures are randomized, so two + // independent builds would differ in their claims too). + const [h, p, s] = good.split('.'); + // Flip the FIRST signature char (fully significant); the last char carries + // base64url padding bits that can decode identically and stay valid. + const bad = [h, p, (s[0] === 'A' ? 'B' : 'A') + s.slice(1)].join('.'); + + expect(bad.split('.').slice(0, 2)).toEqual([h, p]); + expect(await verifyEs256Independently(good, kp.publicJwk)).toBe(true); + expect(await verifyEs256Independently(bad, kp.publicJwk)).toBe(false); + }); + + it('the builder tamperSignature option yields a proof that fails verification', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ + keyPair: kp, + ...base, + tamperSignature: true + }); + expect(await verifyEs256Independently(jwt, kp.publicJwk)).toBe(false); + }); + + it('omitting jti drops only the jti claim', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ keyPair: kp, ...base, omit: ['jti'] }); + const claims = jose.decodeJwt(jwt); + expect(claims.jti).toBeUndefined(); + expect(claims.htm).toBe('POST'); + expect(claims.htu).toBe('https://mcp.example.com/mcp'); + expect(typeof claims.iat).toBe('number'); + }); + + it('unsigned variant uses alg=none with an empty signature segment', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ keyPair: kp, ...base, unsigned: true }); + const parts = jwt.split('.'); + expect(parts).toHaveLength(3); + expect(parts[2]).toBe(''); + expect(jose.decodeProtectedHeader(jwt).alg).toBe('none'); + }); + + it('symmetric variant signs with HS256 (must be rejected by servers)', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ keyPair: kp, ...base, symmetric: true }); + expect(jose.decodeProtectedHeader(jwt).alg).toBe('HS256'); + }); + + it('embedPrivateKey leaks the private scalar into the jwk header', async () => { + const kp = await generateDpopKeyPair(); + const jwt = await buildDpopProof({ + keyPair: kp, + ...base, + embedPrivateKey: true + }); + expect((jose.decodeProtectedHeader(jwt).jwk as JWK).d).toBeDefined(); + }); + + it('iat override produces a stale proof', async () => { + const kp = await generateDpopKeyPair(); + const staleIat = Math.floor(Date.now() / 1000) - 3600; + const jwt = await buildDpopProof({ keyPair: kp, ...base, iat: staleIat }); + expect(jose.decodeJwt(jwt).iat).toBe(staleIat); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopProof.ts b/src/scenarios/client/auth/helpers/dpopProof.ts new file mode 100644 index 0000000..5ebc868 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopProof.ts @@ -0,0 +1,178 @@ +import * as jose from 'jose'; +import type { JWK, CryptoKey } from 'jose'; +import { createHash, randomBytes } from 'node:crypto'; + +/** + * DPoP proof construction helpers (RFC 9449). + * + * Shared across the DPoP client, server, and authorization-server conformance + * scenarios. The framework uses these to act as a DPoP client: it generates a + * key pair, builds a well-formed `dpop+jwt` proof for the happy path, and builds + * deliberately-malformed variants (one defect at a time) for the negative checks. + * + * Correctness of this module is anchored to published RFC test vectors in + * `proof.test.ts` (RFC 9449 §4 examples), not to the conformance servers that + * consume it — see the validation strategy in the project notes. + */ + +const DEFAULT_ALG = 'ES256'; +const DPOP_TYP = 'dpop+jwt'; + +export interface DpopKeyPair { + /** Private key used to sign proofs. */ + privateKey: CryptoKey; + /** Public key matching {@link publicJwk}. */ + publicKey: CryptoKey; + /** Public JWK embedded in the proof's `jwk` header parameter. */ + publicJwk: JWK; + /** RFC 7638 JWK SHA-256 thumbprint (the value bound as `cnf.jkt`). */ + thumbprint: string; +} + +/** Generate an asymmetric key pair for DPoP proofs (default ES256 / P-256). */ +export async function generateDpopKeyPair( + alg: string = DEFAULT_ALG +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable: true + }); + const publicJwk = await jose.exportJWK(publicKey); + const thumbprint = await jose.calculateJwkThumbprint(publicJwk, 'sha256'); + return { privateKey, publicKey, publicJwk, thumbprint }; +} + +/** + * Compute the `ath` claim for a DPoP proof presented with an access token: + * the base64url-encoded SHA-256 of the ASCII access-token value (RFC 9449 §4.1). + */ +export function accessTokenHash(accessToken: string): string { + return createHash('sha256').update(accessToken, 'ascii').digest('base64url'); +} + +/** RFC 7638 JWK SHA-256 thumbprint (base64url). */ +export async function jwkThumbprint(jwk: JWK): Promise { + return jose.calculateJwkThumbprint(jwk, 'sha256'); +} + +export interface DpopProofOptions { + /** Key pair whose private key signs the proof and whose public JWK is embedded. */ + keyPair: DpopKeyPair; + /** HTTP method of the target request (`htm` claim). */ + htm: string; + /** HTTP target URI of the request, no query/fragment (`htu` claim). */ + htu: string; + /** When set, adds an `ath` claim bound to this access token. */ + accessToken?: string; + /** When set, adds a `nonce` claim (server-supplied nonce). */ + nonce?: string; + /** `iat` in epoch seconds. Defaults to now. Override to craft stale/future proofs. */ + iat?: number; + /** `jti` value. Defaults to a fresh random id. */ + jti?: string; + + // --- override knobs for crafting deliberately-invalid variants --- + /** Override the `typ` header (default `dpop+jwt`). */ + typ?: string; + /** Override the signing algorithm (default ES256). */ + alg?: string; + /** Embed the PRIVATE JWK in the header instead of the public one (invalid). */ + embedPrivateKey?: boolean; + /** Replace the embedded `jwk` header entirely. */ + jwkOverride?: JWK; + /** Omit specific header params / claims to craft "missing field" variants. */ + omit?: Array<'jti' | 'htm' | 'htu' | 'iat' | 'typ' | 'jwk'>; + /** Force a specific (wrong) `ath` value. */ + athOverride?: string; + /** Sign with a symmetric key (HS256) — must be rejected (asymmetric-only). */ + symmetric?: boolean; + /** Produce an unsigned `alg: none` proof — must be rejected. */ + unsigned?: boolean; + /** Corrupt the signature after signing so verification fails. */ + tamperSignature?: boolean; +} + +function base64urlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +/** + * Build a DPoP proof JWT. With no override knobs set, produces a well-formed, + * RFC 9449-conformant proof. The override knobs each introduce exactly one + * defect so a negative check can attribute a rejection to that single cause. + */ +export async function buildDpopProof( + options: DpopProofOptions +): Promise { + const omit = new Set(options.omit ?? []); + const iat = options.iat ?? Math.floor(Date.now() / 1000); + const jti = options.jti ?? randomBytes(16).toString('base64url'); + + // ----- claims (payload) ----- + const payload: Record = {}; + if (!omit.has('jti')) payload.jti = jti; + if (!omit.has('htm')) payload.htm = options.htm; + if (!omit.has('htu')) payload.htu = options.htu; + if (!omit.has('iat')) payload.iat = iat; + if (options.athOverride !== undefined) { + payload.ath = options.athOverride; + } else if (options.accessToken !== undefined) { + payload.ath = accessTokenHash(options.accessToken); + } + if (options.nonce !== undefined) payload.nonce = options.nonce; + + // ----- protected header ----- + const unsigned = options.unsigned === true || options.alg === 'none'; + const alg = unsigned + ? 'none' + : options.symmetric + ? 'HS256' + : (options.alg ?? DEFAULT_ALG); + const header: Record = { alg }; + if (!omit.has('typ')) header.typ = options.typ ?? DPOP_TYP; + if (!omit.has('jwk')) { + if (options.jwkOverride !== undefined) { + header.jwk = options.jwkOverride; + } else if (options.embedPrivateKey === true) { + header.jwk = await jose.exportJWK(options.keyPair.privateKey); + } else { + header.jwk = options.keyPair.publicJwk; + } + } + + // ----- assembly / signing ----- + if (unsigned) { + // `alg: none` — no signature segment. + return `${base64urlJson(header)}.${base64urlJson(payload)}.`; + } + + if (options.symmetric) { + const secret = new Uint8Array(randomBytes(32)); + return new jose.SignJWT(payload) + .setProtectedHeader(header as jose.JWTHeaderParameters) + .sign(secret); + } + + const jwt = await new jose.SignJWT(payload) + .setProtectedHeader(header as jose.JWTHeaderParameters) + .sign(options.keyPair.privateKey); + + if (options.tamperSignature) { + const parts = jwt.split('.'); + parts[2] = corruptSegment(parts[2]); + return parts.join('.'); + } + + return jwt; +} + +/** + * Corrupt a base64url signature so it no longer verifies. We flip the FIRST + * character (all 6 of its bits are significant): the LAST character of a + * 64-byte ECDSA signature carries 4 zero padding bits, so flipping it (e.g. + * `A`→`B`) can decode to the same bytes and leave the signature valid. + */ +function corruptSegment(segment: string): string { + const first = segment[0]; + const replacement = first === 'A' ? 'B' : 'A'; + return replacement + segment.slice(1); +} diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts new file mode 100644 index 0000000..8d20cf7 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import { + generateDpopKeyPair, + buildDpopProof, + type DpopKeyPair +} from './dpopProof'; +import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; +import { validateResourceProof } from './dpopResourceAuth'; + +const ISSUER = 'https://auth.example.com'; +const RESOURCE = 'https://mcp.example.com/mcp'; + +/** Mint a DPoP-bound token for `kp`'s key, optionally bound to a foreign key. */ +async function boundToken( + kp: DpopKeyPair, + jktOverride?: string +): Promise { + const issuerKey = await generateIssuerKey(); + return mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: RESOURCE, + jkt: kp.thumbprint, + ...(jktOverride ? { jktOverride } : {}) + }); +} + +describe('validateResourceProof — accepts a well-formed resource proof', () => { + it('accepts a valid proof bound to the presented token', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(true); + }); + + it('accepts an htu that differs only by normalization (trailing slash)', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${RESOURCE}/`, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(true); + }); +}); + +describe('validateResourceProof — rejects each single defect', () => { + async function expectRejected( + build: Parameters[0], + method = 'POST', + tokenJktOverride?: string + ): Promise { + const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair()); + const token = await boundToken(kp, tokenJktOverride); + const proof = await buildDpopProof({ ...build, keyPair: kp }); + const result = await validateResourceProof(proof, token, method, RESOURCE); + expect(result.ok).toBe(false); + return result.ok ? '' : result.error; + } + + it('rejects a missing proof', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const result = await validateResourceProof( + undefined, + token, + 'POST', + RESOURCE + ); + expect(result.ok).toBe(false); + }); + + it('rejects a wrong typ', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + typ: 'jwt' + }) + ).toMatch(/typ/); + }); + + it('rejects a symmetric algorithm', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + symmetric: true + }) + ).toMatch(/alg/); + }); + + it('rejects a private key embedded in the jwk header', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + embedPrivateKey: true + }) + ).toMatch(/private key/); + }); + + it('rejects a tampered signature', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + tamperSignature: true + }) + ).toMatch(/signature/); + }); + + it('rejects a missing jti', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + omit: ['jti'] + }) + ).toMatch(/jti/); + }); + + it('rejects an htm that does not match the request method', async () => { + const kp = await generateDpopKeyPair(); + // proof says GET, request is POST + expect( + await expectRejected({ keyPair: kp, htm: 'GET', htu: RESOURCE }, 'POST') + ).toMatch(/htm/); + }); + + it('rejects an htu that does not match the request URI', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: 'https://elsewhere.example.com/mcp' + }) + ).toMatch(/htu/); + }); + + it('rejects a stale iat', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + iat: Math.floor(Date.now() / 1000) - 3600 + }) + ).toMatch(/iat/); + }); + + it('rejects a wrong ath', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + athOverride: 'not-the-token-hash' + }) + ).toMatch(/ath/); + }); + + it('rejects a token bound to a different key (cnf.jkt mismatch)', async () => { + const kp = await generateDpopKeyPair(); + const foreign = await generateDpopKeyPair(); + const token = await boundToken(kp, foreign.thumbprint); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + const result = await validateResourceProof(proof, token, 'POST', RESOURCE); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch(/cnf\.jkt/); + }); + + it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ keyPair: kp, htm: 'POST', htu: `${RESOURCE}?x=1` }) + ).toMatch(/query or fragment/); + }); + + it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: `${RESOURCE}#frag` + }) + ).toMatch(/query or fragment/); + }); + + it('rejects two comma-joined proofs — multiple DPoP headers (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + const token = await boundToken(kp); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: RESOURCE, + accessToken: token + }); + // Node joins duplicate DPoP headers into one comma-separated value. + const result = await validateResourceProof( + `${proof}, ${proof}`, + token, + 'POST', + RESOURCE + ); + expect(result.ok).toBe(false); + expect(result.ok ? '' : result.error).toMatch( + /multiple DPoP proof headers/ + ); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopResourceAuth.ts b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts new file mode 100644 index 0000000..33f44be --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopResourceAuth.ts @@ -0,0 +1,279 @@ +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import * as jose from 'jose'; +import { createHash } from 'node:crypto'; + +/** Fixed nonce the judge hands out when `requireNonce` is set (RFC 9449 §9). */ +const RS_DPOP_NONCE = 'conformance-rs-dpop-nonce'; + +/** Asymmetric JWS algorithms acceptable for a DPoP proof (RFC 9449 §4.3 / §11.6). */ +const DPOP_ASYMMETRIC_ALGS = [ + 'ES256', + 'ES384', + 'ES512', + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'EdDSA' +]; + +/** + * Accumulated observations of how the client presented its access token and + * per-request DPoP proof. The scenario turns these into the two + * sep-1932-client-* checks in getChecks() — freshness needs cross-request + * state (unique `jti` per request), so we accumulate rather than emit inline. + */ +export interface DpopClientObservations { + authenticatedRequests: number; + nonDpopSchemeSeen: boolean; + observedSchemes: string[]; + jtisSeen: string[]; + replayDetected: boolean; + allProofsWellFormed: boolean; + proofError?: string; + /** The judge issued a `use_dpop_nonce` challenge (RFC 9449 §9). */ + rsNonceChallengeIssued: boolean; + /** The client retried the request carrying the correct nonce. */ + rsNonceHonored: boolean; +} + +export function newDpopClientObservations(): DpopClientObservations { + return { + authenticatedRequests: 0, + nonDpopSchemeSeen: false, + observedSchemes: [], + jtisSeen: [], + replayDetected: false, + allProofsWellFormed: true, + rsNonceChallengeIssued: false, + rsNonceHonored: false + }; +} + +/** + * Test MCP server DPoP judge (SEP-1932 / RFC 9449), passed to `createServer` + * via its `options.authMiddleware` hook. An unauthenticated request gets a + * `401 DPoP` discovery challenge; an authenticated one is observed (scheme + + * per-request proof) and allowed through so the MCP session can complete and + * multiple requests can be examined for proof freshness. + * + * Proof validation is hand-rolled with jose — deliberately an INDEPENDENT code + * path from the suite's proof builder, so a shared bug surfaces rather than hides. + */ +export function createDpopResourceAuth( + obs: DpopClientObservations, + getResourceUrl: () => string, + getPrmUrl: () => string, + requireNonce = false +): RequestHandler { + return async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + const authorization = req.headers.authorization; + if (!authorization) { + res + .status(401) + .set('WWW-Authenticate', `DPoP resource_metadata="${getPrmUrl()}"`) + .json({ error: 'unauthorized' }); + return; + } + + obs.authenticatedRequests++; + const { scheme, token } = splitAuthorization(authorization); + obs.observedSchemes.push(scheme); + if (scheme.toLowerCase() !== 'dpop') { + obs.nonDpopSchemeSeen = true; + } + + const proofHeader = req.headers['dpop']; + // Node collapses duplicate DPoP request headers into one comma-joined value; + // validateResourceProof rejects a comma (RFC 9449 §4.2 — at most one proof). + const proofValue = Array.isArray(proofHeader) + ? proofHeader.join(', ') + : proofHeader; + const result = await validateResourceProof( + proofValue, + token, + req.method, + getResourceUrl() + ); + if (!result.ok) { + obs.allProofsWellFormed = false; + obs.proofError ??= result.error; + next(); + return; + } + + // Record proof freshness on ANY valid proof, before the nonce gate. A + // nonce-challenged (but otherwise valid) proof still demonstrates freshness, + // so fresh-proof is never asserted on zero `jti` evidence, and a client that + // never honours the nonce fails only rs-nonce (not fresh-proof as well). + // + // Deliberate consequence: a client that re-sends the *identical* proof + // across the challenge/retry boundary (same `jti`) trips replay detection. + // That is a genuine RFC 9449 §4.2 violation (`jti` MUST be unique per proof) + // — a conformant client mints a fresh proof, carrying the nonce, on retry. + if (obs.jtisSeen.includes(result.jti)) { + obs.replayDetected = true; + } else { + obs.jtisSeen.push(result.jti); + } + + // RFC 9449 §9: require a server-provided nonce. A valid proof without the + // correct nonce is challenged (401 use_dpop_nonce + DPoP-Nonce); the client + // is expected to retry with it. + if (requireNonce) { + let proofNonce: unknown; + try { + proofNonce = proofValue ? jose.decodeJwt(proofValue).nonce : undefined; + } catch { + proofNonce = undefined; + } + if (proofNonce !== RS_DPOP_NONCE) { + obs.rsNonceChallengeIssued = true; + res + .status(401) + .set( + 'WWW-Authenticate', + `DPoP error="use_dpop_nonce", resource_metadata="${getPrmUrl()}"` + ) + .set('DPoP-Nonce', RS_DPOP_NONCE) + .json({ error: 'use_dpop_nonce' }); + return; + } + obs.rsNonceHonored = true; + } + + next(); + }; +} + +function splitAuthorization(authorization: string): { + scheme: string; + token: string; +} { + const idx = authorization.indexOf(' '); + if (idx === -1) return { scheme: authorization, token: '' }; + return { + scheme: authorization.slice(0, idx), + token: authorization.slice(idx + 1).trim() + }; +} + +/** + * Canonicalize an `htu` for comparison per RFC 9449 §4.3 (RFC 3986 scheme-based + * normalization): lowercase scheme/host, drop the default port, ignore a + * trailing slash. Query/fragment are rejected by the caller (RFC 9449 §4.2), + * not silently stripped here. + */ +function normalizeHtu(u: string): string { + try { + const url = new URL(u); + // Tolerate a single trailing slash only; `/mcp//` is a distinct path. + return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}`; + } catch { + return u; + } +} + +/** + * Validate a per-request DPoP proof presented alongside an access token + * (RFC 9449 §4.3, resource-request subset): well-formed `dpop+jwt`, asymmetric + * alg, embedded public jwk, htm/htu match, `ath` binds the token, signature + * verifies, and the proof key's thumbprint matches the token's `cnf.jkt`. + */ +export async function validateResourceProof( + proof: string | undefined, + token: string, + method: string, + resourceUrl: string +): Promise<{ ok: true; jti: string } | { ok: false; error: string }> { + if (!proof) return { ok: false, error: 'missing DPoP proof header' }; + // RFC 9449 §4.2: at most one DPoP header. A single proof JWT has no comma, so + // a comma means duplicate headers were sent (Node joins them with ", "). + if (proof.includes(',')) { + return { ok: false, error: 'multiple DPoP proof headers' }; + } + + let header: jose.ProtectedHeaderParameters; + try { + header = jose.decodeProtectedHeader(proof); + } catch { + return { ok: false, error: 'DPoP proof is not a well-formed JWT' }; + } + if (header.typ !== 'dpop+jwt') + return { ok: false, error: 'typ must be dpop+jwt' }; + if (!header.alg || !DPOP_ASYMMETRIC_ALGS.includes(header.alg)) { + return { ok: false, error: 'alg must be a supported asymmetric algorithm' }; + } + const jwk = header.jwk as jose.JWK | undefined; + if (!jwk) return { ok: false, error: 'missing jwk header parameter' }; + if ((jwk as Record).d !== undefined) { + return { ok: false, error: 'jwk must not contain a private key' }; + } + + let claims: jose.JWTPayload; + try { + const key = await jose.importJWK(jwk, header.alg); + claims = ( + await jose.jwtVerify(proof, key, { algorithms: DPOP_ASYMMETRIC_ALGS }) + ).payload; + } catch { + return { ok: false, error: 'DPoP proof signature does not verify' }; + } + if (typeof claims.jti !== 'string') + return { ok: false, error: 'missing jti claim' }; + if (claims.htm !== method) + return { ok: false, error: 'htm does not match the request method' }; + if (typeof claims.htu !== 'string') { + return { ok: false, error: 'htu does not match the request URI' }; + } + if (claims.htu.includes('?') || claims.htu.includes('#')) { + return { + ok: false, + error: 'htu MUST NOT contain a query or fragment (RFC 9449 §4.2)' + }; + } + if (normalizeHtu(claims.htu) !== normalizeHtu(resourceUrl)) { + return { ok: false, error: 'htu does not match the request URI' }; + } + if ( + typeof claims.iat !== 'number' || + Math.abs(Math.floor(Date.now() / 1000) - claims.iat) > 300 + ) { + return { ok: false, error: 'iat outside the acceptable window' }; + } + // Recompute ath and the thumbprint inline (jose + node crypto) rather than + // via the proof builder's helpers, so this validator stays a fully + // independent path. + const expectedAth = createHash('sha256') + .update(token, 'ascii') + .digest('base64url'); + if (typeof claims.ath !== 'string' || claims.ath !== expectedAth) { + return { + ok: false, + error: 'ath does not match the presented access token' + }; + } + + // Possession: the proof key must be the key the token is bound to. + try { + const tokenClaims = jose.decodeJwt(token); + const cnf = tokenClaims.cnf as { jkt?: unknown } | undefined; + const thumbprint = await jose.calculateJwkThumbprint(jwk, 'sha256'); + if (!cnf || cnf.jkt !== thumbprint) { + return { + ok: false, + error: 'proof key thumbprint does not match the token cnf.jkt' + }; + } + } catch { + return { ok: false, error: 'access token is not a decodable JWT' }; + } + + return { ok: true, jti: claims.jti }; +} diff --git a/src/scenarios/client/auth/helpers/dpopToken.test.ts b/src/scenarios/client/auth/helpers/dpopToken.test.ts new file mode 100644 index 0000000..7103809 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopToken.test.ts @@ -0,0 +1,158 @@ +import { webcrypto } from 'node:crypto'; +import * as jose from 'jose'; +import type { JWK } from 'jose'; +import { + generateDpopKeyPair, + buildDpopProof, + accessTokenHash +} from './dpopProof'; +import { generateIssuerKey, mintDpopBoundToken } from './dpopToken'; + +/** Independent ES256 verification via Node WebCrypto (a different path from jose). */ +async function verifyEs256Independently( + jwt: string, + publicJwk: JWK +): Promise { + const [h, p, s] = jwt.split('.'); + const key = await webcrypto.subtle.importKey( + 'jwk', + publicJwk as JsonWebKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ); + const signature = new Uint8Array(Buffer.from(s, 'base64url')); + const data = new TextEncoder().encode(`${h}.${p}`); + return webcrypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + signature, + data + ); +} + +const ISSUER = 'https://auth.example.com'; +const AUDIENCE = 'https://mcp.example.com/mcp'; + +describe('DPoP token minter — valid bound token', () => { + it('mints a token bound to the given thumbprint with correct claims', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: AUDIENCE, + jkt: kp.thumbprint + }); + + const claims = jose.decodeJwt(token); + expect(claims.iss).toBe(ISSUER); + expect(claims.aud).toBe(AUDIENCE); + expect(typeof claims.sub).toBe('string'); + expect(typeof claims.iat).toBe('number'); + expect(typeof claims.exp).toBe('number'); + expect((claims.exp as number) > (claims.iat as number)).toBe(true); + expect((claims.cnf as { jkt: string }).jkt).toBe(kp.thumbprint); + + expect(jose.decodeProtectedHeader(token).typ).toBe('at+jwt'); + }); + + it('is signed by the issuer key (independent WebCrypto verification, Layer 2)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: AUDIENCE, + jkt: kp.thumbprint + }); + expect(await verifyEs256Independently(token, issuerKey.publicJwk)).toBe( + true + ); + }); +}); + +describe('DPoP token minter — proof/token binding agreement (integration)', () => { + it('proof.ath hashes the minted token and token.cnf.jkt equals the proof key thumbprint', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: AUDIENCE, + jkt: kp.thumbprint + }); + + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: AUDIENCE, + accessToken: token + }); + + // The two halves of the sender-constraint must line up: + const tokenJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt; + const proofJwkThumbprint = kp.thumbprint; + expect(tokenJkt).toBe(proofJwkThumbprint); // token bound to the proof's key + + const proofAth = jose.decodeJwt(proof).ath; + expect(proofAth).toBe(accessTokenHash(token)); // proof attests to this token + }); +}); + +describe('DPoP token minter — invalid variants', () => { + const base = { issuer: ISSUER, audience: AUDIENCE }; + + it('omitCnf produces an unbound token (no cnf)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + omitCnf: true + }); + expect(jose.decodeJwt(token).cnf).toBeUndefined(); + }); + + it('jktOverride binds to a foreign key (cnf.jkt mismatch case)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const foreign = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + jktOverride: foreign.thumbprint + }); + const boundJkt = (jose.decodeJwt(token).cnf as { jkt: string }).jkt; + expect(boundJkt).toBe(foreign.thumbprint); + expect(boundJkt).not.toBe(kp.thumbprint); + }); + + it('wrong-audience token still binds correctly (audience-validation case)', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + issuer: ISSUER, + audience: 'https://other.example.com/mcp', + jkt: kp.thumbprint + }); + expect(jose.decodeJwt(token).aud).toBe('https://other.example.com/mcp'); + }); + + it('expired produces a token whose exp is in the past', async () => { + const issuerKey = await generateIssuerKey(); + const kp = await generateDpopKeyPair(); + const token = await mintDpopBoundToken({ + issuerKey, + ...base, + jkt: kp.thumbprint, + expired: true + }); + const claims = jose.decodeJwt(token); + expect((claims.exp as number) < (claims.iat as number)).toBe(true); + }); +}); diff --git a/src/scenarios/client/auth/helpers/dpopToken.ts b/src/scenarios/client/auth/helpers/dpopToken.ts new file mode 100644 index 0000000..afe5632 --- /dev/null +++ b/src/scenarios/client/auth/helpers/dpopToken.ts @@ -0,0 +1,110 @@ +import * as jose from 'jose'; +import type { JWK, CryptoKey } from 'jose'; + +/** + * DPoP-bound access-token minting (RFC 9449 §6). + * + * Transport-free: this issues a signed JWT access token carrying the + * `cnf.jkt` confirmation that binds the token to a DPoP key. It is the + * token-issuing *core* shared across the DPoP scenarios: + * + * - #369 server: the scenario (acting as client) mints a bound token to + * PRESENT to the server under test. + * - #370 AS / #368 client: the compliant/test authorization-server fixtures + * call this to ISSUE bound tokens (wrapped by an HTTP token endpoint). + * + * `jkt` is a parameter (not derived here) so the same function works whether + * the bound key is ours (#369/#370) or extracted from a client's proof (#368). + */ + +const DEFAULT_ALG = 'ES256'; + +export interface TokenIssuerKey { + privateKey: CryptoKey; + publicKey: CryptoKey; + publicJwk: JWK; + alg: string; +} + +/** Generate an asymmetric signing key for the (test) token issuer. */ +export async function generateIssuerKey( + alg: string = DEFAULT_ALG +): Promise { + const { publicKey, privateKey } = await jose.generateKeyPair(alg, { + extractable: true + }); + const publicJwk = await jose.exportJWK(publicKey); + return { privateKey, publicKey, publicJwk, alg }; +} + +/** + * Reconstruct a {@link TokenIssuerKey} from a private JWK (e.g. supplied to a + * scenario via env), deriving the public JWK by stripping the private scalar. + * EC/OKP keys only (sufficient for the ES256 test issuer). + */ +export async function importIssuerKey( + privateJwk: JWK, + alg: string = DEFAULT_ALG +): Promise { + const privateKey = (await jose.importJWK(privateJwk, alg)) as CryptoKey; + const publicJwk: JWK = { ...privateJwk }; + delete (publicJwk as Record).d; + const publicKey = (await jose.importJWK(publicJwk, alg)) as CryptoKey; + return { privateKey, publicKey, publicJwk, alg }; +} + +export interface MintDpopBoundTokenOptions { + /** Issuer signing key (the server under test must trust its public key). */ + issuerKey: TokenIssuerKey; + /** `iss` claim — the token issuer identifier. */ + issuer: string; + /** `aud` claim — the resource the token is for (the MCP server's canonical URI). */ + audience: string; + /** Thumbprint bound as `cnf.jkt` (RFC 7638 JWK thumbprint of the DPoP public key). */ + jkt: string; + /** `sub` claim. Defaults to a fixed test subject. */ + subject?: string; + /** `scope` claim, if any. */ + scope?: string; + /** `iat` in epoch seconds. Defaults to now. */ + iat?: number; + /** Token lifetime in seconds. Defaults to 3600. */ + expiresInSeconds?: number; + + // --- override knobs for crafting deliberately-invalid variants --- + /** Omit `cnf` entirely — an unbound (bearer-style) token. */ + omitCnf?: boolean; + /** Bind to a different (foreign/wrong) thumbprint than the presented proof. */ + jktOverride?: string; + /** Issue an already-expired token (`exp` in the past). */ + expired?: boolean; + /** Additional claims to merge into the payload. */ + extraClaims?: Record; +} + +/** + * Mint a signed JWT access token. With no override knobs set, produces a + * valid DPoP-bound token whose `cnf.jkt` equals {@link MintDpopBoundTokenOptions.jkt}. + */ +export async function mintDpopBoundToken( + options: MintDpopBoundTokenOptions +): Promise { + const iat = options.iat ?? Math.floor(Date.now() / 1000); + const lifetime = options.expiresInSeconds ?? 3600; + const exp = options.expired ? iat - 60 : iat + lifetime; + + const payload: Record = { ...(options.extraClaims ?? {}) }; + if (options.scope !== undefined) payload.scope = options.scope; + if (!options.omitCnf) { + payload.cnf = { jkt: options.jktOverride ?? options.jkt }; + } + + return new jose.SignJWT(payload) + .setProtectedHeader({ alg: options.issuerKey.alg, typ: 'at+jwt' }) + .setIssuer(options.issuer) + .setAudience(options.audience) + .setSubject(options.subject ?? 'conformance-test-subject') + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(options.issuerKey.privateKey); +} diff --git a/src/scenarios/client/auth/helpers/resourceParameterChecks.ts b/src/scenarios/client/auth/helpers/resourceParameterChecks.ts new file mode 100644 index 0000000..e7223f1 --- /dev/null +++ b/src/scenarios/client/auth/helpers/resourceParameterChecks.ts @@ -0,0 +1,178 @@ +import type { ConformanceCheck } from '../../../../types'; +import { SpecReferences } from '../spec-references'; + +/** + * What a scenario's mock servers observed about the RFC 8707 `resource` + * parameter: the values the client sent to the authorization and token + * endpoints, and the identifier the protected resource metadata served. + */ +export interface ResourceParameterObservation { + /** `resource` query parameter the client sent to the authorization endpoint. */ + authorizationResource?: string; + /** `resource` form parameter the client sent to the token endpoint. */ + tokenResource?: string; + /** `resource` value the protected resource metadata document served. */ + prmResource?: string; +} + +/** + * RFC 8707 resource-parameter checks, shared by every client-auth scenario + * whose mock servers observe the authorization and token requests. The check + * IDs are stable across scenarios so one slug finds every emission. + * + * Each check is emitted once per scenario run; a check already present under + * the same id is left alone. + */ +export function addResourceParameterChecks( + checks: ConformanceCheck[], + observed: ResourceParameterObservation, + timestamp: string +): void { + const { authorizationResource, tokenResource, prmResource } = observed; + const specRefs = [ + SpecReferences.RFC_8707_RESOURCE_INDICATORS, + SpecReferences.MCP_RESOURCE_PARAMETER + ]; + + // Check 1: Resource parameter in authorization request + if (!checks.some((c) => c.id === 'resource-parameter-in-authorization')) { + const hasResource = !!authorizationResource; + checks.push({ + id: 'resource-parameter-in-authorization', + name: 'Resource parameter in authorization request', + description: hasResource + ? 'Client included resource parameter in authorization request' + : 'Client MUST include resource parameter in authorization request per RFC 8707', + status: hasResource ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + resource: authorizationResource || 'not provided' + } + }); + } + + // Check 2: Resource parameter in token request + if (!checks.some((c) => c.id === 'resource-parameter-in-token')) { + const hasResource = !!tokenResource; + checks.push({ + id: 'resource-parameter-in-token', + name: 'Resource parameter in token request', + description: hasResource + ? 'Client included resource parameter in token request' + : 'Client MUST include resource parameter in token request per RFC 8707', + status: hasResource ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + resource: tokenResource || 'not provided' + } + }); + } + + // Check 3: Resource parameter is valid canonical URI + if (!checks.some((c) => c.id === 'resource-parameter-valid-uri')) { + const resourceToValidate = authorizationResource || tokenResource; + if (resourceToValidate) { + const validation = validateCanonicalUri(resourceToValidate); + checks.push({ + id: 'resource-parameter-valid-uri', + name: 'Resource parameter is valid canonical URI', + description: validation.valid + ? 'Resource parameter is a valid canonical URI (has scheme, no fragment)' + : `Resource parameter is invalid: ${validation.error}`, + status: validation.valid ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + resource: resourceToValidate, + ...(validation.error && { error: validation.error }) + } + }); + } + } + + // Check 4: Resource parameter consistency between requests + if (!checks.some((c) => c.id === 'resource-parameter-consistency')) { + if (authorizationResource && tokenResource) { + const consistent = authorizationResource === tokenResource; + checks.push({ + id: 'resource-parameter-consistency', + name: 'Resource parameter consistency', + description: consistent + ? 'Resource parameter is consistent between authorization and token requests' + : 'Resource parameter MUST be consistent between authorization and token requests', + status: consistent ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + authorizationResource, + tokenResource + } + }); + } + } + + // Check 5: Resource parameter equals the identifier published in PRM. + // The MCP spec requires the canonical URI, which it defines as the RFC 9728 + // `resource` value; RFC 9728 §3.3 requires that value to be identical to the + // identifier the client used. Re-serializing it through a URL parser is the + // usual way to break this: `new URL('https://example.com').href` is + // `https://example.com/`, and authorization servers that match the + // indicator exactly reject the extra slash. + if (!checks.some((c) => c.id === 'resource-parameter-matches-prm')) { + const sent: Array<[request: string, value: string]> = []; + if (authorizationResource !== undefined) { + sent.push(['authorization', authorizationResource]); + } + if (tokenResource !== undefined) { + sent.push(['token', tokenResource]); + } + if (prmResource !== undefined && sent.length > 0) { + const mismatched = sent + .filter(([, value]) => value !== prmResource) + .map(([request]) => request); + const matches = mismatched.length === 0; + const errorMessage = `Client MUST send the resource identifier exactly as published in protected resource metadata; the ${mismatched.join(' and ')} request sent a different value (a URL parser that appends "/" to a pathless identifier is the usual cause)`; + checks.push({ + id: 'resource-parameter-matches-prm', + name: 'Resource parameter matches protected resource metadata', + description: matches + ? 'Client sent the resource identifier exactly as published in protected resource metadata' + : errorMessage, + status: matches ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [ + ...specRefs, + SpecReferences.MCP_CANONICAL_SERVER_URI, + SpecReferences.RFC_9728_RESOURCE_IDENTITY + ], + ...(!matches && { errorMessage }), + details: { + prmResource, + authorizationResource: authorizationResource ?? 'not provided', + tokenResource: tokenResource ?? 'not provided' + } + }); + } + } +} + +function validateCanonicalUri(uri: string): { + valid: boolean; + error?: string; +} { + try { + const parsed = new URL(uri); + // Check for fragment (RFC 8707: MUST NOT include fragment) + if (parsed.hash) { + return { + valid: false, + error: 'contains fragment (not allowed per RFC 8707)' + }; + } + return { valid: true }; + } catch { + return { valid: false, error: 'invalid URI format' }; + } +} diff --git a/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts new file mode 100644 index 0000000..e4a2310 --- /dev/null +++ b/src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { + generateDpopKeyPair, + buildDpopProof, + type DpopKeyPair +} from './dpopProof'; +import { validateDpopProofAtTokenEndpoint } from './createAuthServer'; + +const TOKEN_ENDPOINT = 'https://auth.example.com/token'; + +/** + * The token-endpoint validator is an INDEPENDENT copy of the resource-side + * validator (dpopResourceAuth). These tests keep the two from drifting: they + * cover the token-request subset of RFC 9449 §4.3 (no `ath`/`cnf` — there is no + * access token yet at the token request). + */ +describe('validateDpopProofAtTokenEndpoint — accepts a valid token-request proof', () => { + it('accepts a well-formed proof and returns the JWK thumbprint', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT + }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(true); + expect(result.ok ? result.jkt : '').toBe(kp.thumbprint); + }); + + it('accepts an htu differing only by a single trailing slash', async () => { + const kp = await generateDpopKeyPair(); + const proof = await buildDpopProof({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}/` + }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(true); + }); +}); + +describe('validateDpopProofAtTokenEndpoint — rejects each single defect', () => { + async function expectRejected( + build: Parameters[0] + ): Promise { + const kp = (build.keyPair as DpopKeyPair) ?? (await generateDpopKeyPair()); + const proof = await buildDpopProof({ ...build, keyPair: kp }); + const result = await validateDpopProofAtTokenEndpoint( + proof, + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(false); + return result.ok ? '' : result.error; + } + + it('rejects a non-JWT', async () => { + const result = await validateDpopProofAtTokenEndpoint( + 'this-is-not-a-jwt', + TOKEN_ENDPOINT + ); + expect(result.ok).toBe(false); + }); + + it('rejects a wrong typ', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + typ: 'jwt' + }) + ).toMatch(/typ/); + }); + + it('rejects a symmetric algorithm', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + symmetric: true + }) + ).toMatch(/alg/); + }); + + it('rejects an unsigned (alg=none) proof', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + unsigned: true + }) + ).toMatch(/alg/); + }); + + it('rejects a private key embedded in the jwk header', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + embedPrivateKey: true + }) + ).toMatch(/private key/); + }); + + it('rejects a tampered signature', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + tamperSignature: true + }) + ).toMatch(/signature/); + }); + + it('rejects a missing jti', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + omit: ['jti'] + }) + ).toMatch(/jti/); + }); + + it('rejects an htm that is not POST', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ keyPair: kp, htm: 'GET', htu: TOKEN_ENDPOINT }) + ).toMatch(/htm/); + }); + + it('rejects an htu that does not match the token endpoint', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: 'https://wrong.example.com/token' + }) + ).toMatch(/htu/); + }); + + it('rejects an htu containing a query string (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}?tenant=x` + }) + ).toMatch(/query or fragment/); + }); + + it('rejects an htu containing a fragment (RFC 9449 §4.2)', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: `${TOKEN_ENDPOINT}#frag` + }) + ).toMatch(/query or fragment/); + }); + + it('rejects a stale iat', async () => { + const kp = await generateDpopKeyPair(); + expect( + await expectRejected({ + keyPair: kp, + htm: 'POST', + htu: TOKEN_ENDPOINT, + iat: Math.floor(Date.now() / 1000) - 3600 + }) + ).toMatch(/iat/); + }); +}); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index e43f0d3..41c84ae 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,17 +1,47 @@ -import { authScenariosList, backcompatScenariosList } from './index'; +import { + authScenariosList, + backcompatScenariosList, + draftScenariosList, + extensionScenariosList +} from './index'; import { runClientAgainstScenario, InlineClientRunner } from './test_helpers/testClient'; import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm'; +import { + runWifJwtBearerWrongAudience, + runWifJwtBearerMissingAssertion, + runWifJwtBearerExpiredAssertion, + runWifJwtBearerScopeRejected, + runWifJwtBearerGrantFallback, + runWifJwtBearerRetry +} from '../../../../examples/clients/typescript/wif-broken-clients'; import { runClient as noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd'; import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope'; import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes'; import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403'; import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit'; import { runClient as noPkceClient } from '../../../../examples/clients/typescript/auth-test-no-pkce'; +import { runClient as reuseCredsClient } from '../../../../examples/clients/typescript/auth-test-reuse-credentials'; +import { runClient as noAppTypeClient } from '../../../../examples/clients/typescript/auth-test-no-application-type'; +import { runClient as noIssValidationClient } from '../../../../examples/clients/typescript/auth-test'; +import { runClient as inertClient } from '../../../../examples/clients/typescript/auth-test-inert'; +import { runClient as issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize'; +import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope'; +import { runClient as dpopBearerClient } from '../../../../examples/clients/typescript/auth-test-dpop-bearer'; +import { runClient as dpopReplayClient } from '../../../../examples/clients/typescript/auth-test-dpop-replay'; +import { runClient as dpopNoTokenProofClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-token-proof'; +import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-as-nonce'; +import { runClient as dpopNoRsNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-rs-nonce'; +import { runClient as dpopNoNonceClient } from '../../../../examples/clients/typescript/auth-test-dpop-no-nonce'; +import { runClient as dpopClient } from '../../../../examples/clients/typescript/auth-test-dpop'; +import { runClient as resourceSlashClient } from '../../../../examples/clients/typescript/auth-test-resource-slash'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { testScenarioContext } from '../../../mock-server/testing'; +import { ClientConformanceContextSchema } from '../../../schemas/context'; beforeAll(() => { setLogLevel('error'); @@ -25,9 +55,34 @@ const allowClientErrorScenarios = new Set([ // Client is expected to give up (error) after limited retries, but check should pass 'auth/scope-retry-limit', // Client is expected to error when PRM resource doesn't match server URL - 'auth/resource-mismatch' + 'auth/resource-mismatch', + // The post-migration retry path may surface as a client error after + // re-registering; the SEP-2352 checks are evaluated in getChecks() + 'auth/authorization-server-migration', + // Client is expected to error when iss validation fails + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected', + 'auth/iss-normalized', + // Client is expected to error when AS metadata issuer validation fails + 'auth/metadata-issuer-mismatch' ]); +/** + * Checks the everything-client is known to fail because of a bug in the SDK + * release it depends on. Each entry is asserted to still fail, so the entry + * must be removed as soon as the pinned SDK passes. + * + * - `resource-parameter-matches-prm` on the root-PRM scenario: + * @modelcontextprotocol/sdk 1.x re-serializes a pathless PRM `resource` + * through `URL.href`, adding a trailing slash (typescript-sdk#1968, fixed + * on 2.x by #2581; 1.x backport #1972). Remove once the example's SDK + * dependency includes the backport. + */ +const knownExampleClientFailures: Record = { + 'auth/metadata-var2': ['resource-parameter-matches-prm'] +}; + describe('Client Auth Scenarios', () => { // Generate individual test for each auth scenario for (const scenario of authScenariosList) { @@ -42,7 +97,8 @@ describe('Client Auth Scenarios', () => { } const runner = new InlineClientRunner(clientFn); await runClientAgainstScenario(runner, scenario.name, { - allowClientError: allowClientErrorScenarios.has(scenario.name) + allowClientError: allowClientErrorScenarios.has(scenario.name), + expectedFailureSlugs: knownExampleClientFailures[scenario.name] }); }); } @@ -61,6 +117,52 @@ describe('Client Back-compat Scenarios', () => { } }); +describe('Client Draft Scenarios', () => { + for (const scenario of draftScenariosList) { + test(`${scenario.name} passes`, async () => { + const clientFn = getHandler(scenario.name); + if (!clientFn) { + throw new Error(`No handler registered for scenario: ${scenario.name}`); + } + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, scenario.name, { + allowClientError: allowClientErrorScenarios.has(scenario.name) + }); + }); + } +}); + +describe('auth/pre-registration context', () => { + // Authorization Server Binding: the context issuer is only usable as a + // binding key if it equals the issuer the mock AS publishes in its metadata. + test('supplies the issuer the mock AS publishes in its metadata', async () => { + const scenario = authScenariosList.find( + (s) => s.name === 'auth/pre-registration' + ); + if (!scenario) { + throw new Error('auth/pre-registration scenario not found'); + } + const urls = await scenario.start(testScenarioContext()); + try { + const context = ClientConformanceContextSchema.parse({ + name: 'auth/pre-registration', + ...urls.context + }); + if (context.name !== 'auth/pre-registration') { + throw new Error(`Unexpected context variant: ${context.name}`); + } + const res = await fetch( + `${context.issuer}/.well-known/oauth-authorization-server` + ); + expect(res.ok).toBe(true); + const metadata = (await res.json()) as { issuer?: string }; + expect(metadata.issuer).toBe(context.issuer); + } finally { + await scenario.stop(); + } + }); +}); + describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); @@ -69,6 +171,15 @@ describe('Negative tests', () => { }); }); + test('client appends a trailing slash to the PRM resource identifier', async () => { + // auth/metadata-var2 serves the PRM at the root, so its `resource` is a + // bare origin: exactly the value a URL parser rewrites with a "/". + const runner = new InlineClientRunner(resourceSlashClient); + await runClientAgainstScenario(runner, 'auth/metadata-var2', { + expectedFailureSlugs: ['resource-parameter-matches-prm'] + }); + }); + test('client ignores scope from WWW-Authenticate header', async () => { const runner = new InlineClientRunner(ignoreScopeClient); await runClientAgainstScenario(runner, 'auth/scope-from-www-authenticate', { @@ -95,9 +206,36 @@ describe('Negative tests', () => { }); test('client only responds to 401, not 403', async () => { + // Run at draft so the SEP-2350 union check is also emitted: a client that + // never makes the second authorization request fails both the escalation + // check and the union check. const runner = new InlineClientRunner(ignore403Client); await runClientAgainstScenario(runner, 'auth/scope-step-up', { - expectedFailureSlugs: ['scope-step-up-escalation'] + specVersion: DRAFT_PROTOCOL_VERSION, + expectedFailureSlugs: [ + 'scope-step-up-escalation', + 'sep-2350-scope-union-on-reauth' + ] + }); + }); + + test('client echoes challenge scope without accumulating prior grant (SEP-2350) at draft', async () => { + // The set-wise union requirement (SEP-2350) was added in the draft spec + // (2026-07-28); a client that echoes only the challenged scope fails it. + const runner = new InlineClientRunner(echoScopeClient); + await runClientAgainstScenario(runner, 'auth/scope-step-up', { + specVersion: DRAFT_PROTOCOL_VERSION, + expectedFailureSlugs: ['sep-2350-scope-union-on-reauth'] + }); + }); + + test('client echoes challenge scope without accumulating prior grant (SEP-2350) at 2025-11-25', async () => { + // At dated spec versions that predate SEP-2350, the union check is not + // emitted at all, so a client that drops the previously-granted scope on + // re-auth passes cleanly. + const runner = new InlineClientRunner(echoScopeClient); + await runClientAgainstScenario(runner, 'auth/scope-step-up', { + specVersion: '2025-11-25' }); }); @@ -116,6 +254,41 @@ describe('Negative tests', () => { }); }); + test('client reuses credentials across authorization servers (SEP-2352)', async () => { + const runner = new InlineClientRunner(reuseCredsClient); + await runClientAgainstScenario( + runner, + 'auth/authorization-server-migration', + { + allowClientError: true, + expectedFailureSlugs: [ + 'sep-2352-reregister-on-as-change', + 'sep-2352-no-reuse-on-as-change', + 'sep-2352-no-cross-as-credential-reuse' + ] + } + ); + }); + + test('client omits application_type during DCR (SEP-837) at draft', async () => { + // SEP-837 is a draft-spec requirement, so the check is only enforced when + // the run targets the draft version. + const runner = new InlineClientRunner(noAppTypeClient); + await runClientAgainstScenario(runner, 'auth/metadata-default', { + specVersion: DRAFT_PROTOCOL_VERSION, + expectedFailureSlugs: ['sep-837-application-type-present'] + }); + }); + + test('client omits application_type during DCR (SEP-837) at 2025-11-25', async () => { + // At dated spec versions that predate SEP-837 the check is not emitted at + // all, so a client omitting application_type passes cleanly. + const runner = new InlineClientRunner(noAppTypeClient); + await runClientAgainstScenario(runner, 'auth/metadata-default', { + specVersion: '2025-11-25' + }); + }); + test('client does not use PKCE', async () => { const runner = new InlineClientRunner(noPkceClient); await runClientAgainstScenario(runner, 'auth/metadata-default', { @@ -127,4 +300,239 @@ describe('Negative tests', () => { ] }); }); + + test('client does not reject missing iss when server requires it', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-supported-missing', { + expectedFailureSlugs: ['sep-2468-client-reject-missing-iss'], + allowClientError: true + }); + }); + + test('client does not reject mismatched iss', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-wrong-issuer', { + expectedFailureSlugs: ['sep-2468-client-compare-iss-supported'], + allowClientError: true + }); + }); + + test('client does not reject unexpected iss', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/iss-unexpected', { + expectedFailureSlugs: ['sep-2468-client-compare-iss-unadvertised'], + allowClientError: true + }); + }); + + test('client normalizes iss before comparison and accepts a trailing-slash variant', async () => { + const runner = new InlineClientRunner(issNormalizeClient); + await runClientAgainstScenario(runner, 'auth/iss-normalized', { + expectedFailureSlugs: ['sep-2468-client-no-normalization'], + allowClientError: true + }); + }); + + test('client uses AS metadata whose issuer does not match the well-known URL', async () => { + const runner = new InlineClientRunner(noIssValidationClient); + await runClientAgainstScenario(runner, 'auth/metadata-issuer-mismatch', { + expectedFailureSlugs: ['sep-2468-client-validate-metadata-issuer'], + allowClientError: true + }); + }); +}); + +describe('Client Extension Scenarios', () => { + for (const scenario of extensionScenariosList) { + test(`${scenario.name} passes`, async () => { + const clientFn = getHandler(scenario.name); + if (!clientFn) { + throw new Error(`No handler registered for scenario: ${scenario.name}`); + } + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, scenario.name); + }); + } +}); + +// allowClientError: true because broken clients receive an error response from +// the AS and will throw. The AS-side check is the authoritative conformance +// signal; client process exit behaviour is not asserted here. +describe('WIF JWT-bearer negative tests', () => { + test('client presents JWT with wrong audience', async () => { + const runner = new InlineClientRunner(runWifJwtBearerWrongAudience); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-audience'], + allowClientError: true + }); + }); + + test('client omits assertion from JWT-bearer request', async () => { + const runner = new InlineClientRunner(runWifJwtBearerMissingAssertion); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-missing'], + allowClientError: true + }); + }); + + test('client presents expired JWT assertion', async () => { + const runner = new InlineClientRunner(runWifJwtBearerExpiredAssertion); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-assertion-expired'], + allowClientError: true + }); + }); + + test('client requests a scope the AS rejects for JWT-bearer grant', async () => { + const runner = new InlineClientRunner(runWifJwtBearerScopeRejected); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + allowClientError: true + }); + }); + + test('client falls back to authorization_code after unauthorized_client', async () => { + const runner = new InlineClientRunner(runWifJwtBearerGrantFallback); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-grant-fallback'], + allowClientError: true + }); + }); + + test('client retries JWT-bearer after unauthorized_client', async () => { + const runner = new InlineClientRunner(runWifJwtBearerRetry); + await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', { + expectedFailureSlugs: ['wif-no-retry'], + allowClientError: true + }); + }); +}); + +// DPoP (SEP-1932): the compliant paths for both postures (auth/dpop and +// auth/dpop-nonce) are covered by the Client Extension Scenarios loop above; these +// are the negative cases, via deliberately-broken example clients. The baseline +// checks (scheme, replay, token-request-proof) are nonce-independent, so those +// negatives run against auth/dpop; the two nonce checks only fire when a +// challenge is issued, so their negatives run against auth/dpop-nonce. +describe('DPoP client negative tests (SEP-1932)', () => { + test('auth/dpop: client presents the token with the Bearer scheme', async () => { + const runner = new InlineClientRunner(dpopBearerClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-dpop-auth-scheme'] + }); + }); + + test('auth/dpop: client reuses a DPoP proof across requests', async () => { + const runner = new InlineClientRunner(dpopReplayClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: ['sep-1932-client-fresh-proof'] + }); + }); + + test('auth/dpop: client never requests a sender-constrained token', async () => { + // No token-endpoint proof → the AS issues an unbound Bearer token, so both + // the token-request check and (as a consequence of the unbound token) the + // resource binding check fail. + const runner = new InlineClientRunner(dpopNoTokenProofClient); + await runClientAgainstScenario(runner, 'auth/dpop', { + expectedFailureSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-fresh-proof' + ] + }); + }); + + test('auth/dpop-nonce: client ignores the authorization-server nonce challenge', async () => { + const runner = new InlineClientRunner(dpopNoAsNonceClient); + // The client presents a valid proof but never retries with the nonce, so it + // never obtains a token — as-nonce fails and the downstream token-dependent + // checks legitimately cascade. But token-request-proof MUST stay SUCCESS: + // the client DID present a valid proof at the token endpoint (regression + // guard for the "never completed a token request" misattribution). + await runClientAgainstScenario(runner, 'auth/dpop-nonce', { + expectedFailureSlugs: [ + 'sep-1932-client-as-nonce', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof', + 'sep-1932-client-rs-nonce' + ], + expectedSuccessSlugs: ['sep-1932-client-token-request-proof'] + }); + }); + + test('auth/dpop-nonce: client ignores the MCP-server nonce challenge', async () => { + const runner = new InlineClientRunner(dpopNoRsNonceClient); + // Clean one-defect isolation: the client obtains a token and presents a + // valid fresh proof (recorded before the nonce gate), so only rs-nonce + // fails — everything else stays SUCCESS. + await runClientAgainstScenario(runner, 'auth/dpop-nonce', { + expectedFailureSlugs: ['sep-1932-client-rs-nonce'], + expectedSuccessSlugs: [ + 'sep-1932-client-token-request-proof', + 'sep-1932-client-dpop-auth-scheme', + 'sep-1932-client-fresh-proof', + 'sep-1932-client-as-nonce' + ] + }); + }); +}); + +// DPoP nonce-less baseline (SEP-1932): a client that implements NO nonce +// handling still completes DPoP successfully when the server does not require a +// nonce (the common case — server nonces are OPTIONAL, RFC 9449 §8/§9). The +// nonce-capable compliant path is covered by the Client Extension Scenarios +// loop above (auth/dpop is in extensionScenariosList). +describe('DPoP client nonce-less baseline (SEP-1932)', () => { + test('auth/dpop: nonce-incapable client passes the baseline', async () => { + const runner = new InlineClientRunner(dpopNoNonceClient); + // No expectedFailureSlugs → asserts every emitted check is SUCCESS (the + // three baseline checks; no as-nonce/rs-nonce checks are emitted here). + await runClientAgainstScenario(runner, 'auth/dpop'); + }); + + test('auth/dpop-nonce: the §8/§9 double-POST is collapsed to one shared check each', async () => { + // Pins that collapseDuplicateChecks is actually wired into getChecks for the + // nonce posture: the challenge→retry re-POST records token-request/pkce + // twice, so without the collapse these would appear 2×. Guards against the + // gating being deleted or inverted. + const runner = new InlineClientRunner(dpopClient); + const checks = await runClientAgainstScenario(runner, 'auth/dpop-nonce'); + const count = (id: string): number => + checks.filter((c) => c.id === id).length; + expect(count('token-request')).toBe(1); + expect(count('pkce-code-verifier-sent')).toBe(1); + expect(count('pkce-verifier-matches-challenge')).toBe(1); + }); +}); + +// Reason-bound negative checks (issue #467). +// +// A negative check that reads only the final verdict scores SUCCESS whenever +// the client fails to reach the requirement at all: "did not proceed" and +// "never got far enough to decide" are the same observation. These tests pin +// that the harness distinguishes them, so a client that cannot have performed +// the validation cannot bank a pass for it. +describe('Reason-bound negative checks (#467)', () => { + test('auth/resource-mismatch: an inert client does not pass by doing nothing', async () => { + // The inert client throws before any discovery request, so it never reads + // the mismatched `resource` it is required to validate. Bound only to the + // verdict (`!authorizationRequestMade`) this scored SUCCESS. + const runner = new InlineClientRunner(inertClient); + const checks = await runClientAgainstScenario( + runner, + 'auth/resource-mismatch', + { + allowClientError: true, + expectedFailureSlugs: ['resource-mismatch-rejected'] + } + ); + + const check = checks.find((c) => c.id === 'resource-mismatch-rejected'); + expect(check).toBeDefined(); + // Reported as untestable (#248), not as a plain violation: the client did + // not break the requirement, it never exercised it. + expect(check?.details?.untestable).toBe(true); + expect(check?.details?.propertyReached).toBe(false); + expect(check?.details?.stopReason).toBe('prm-not-requested'); + expect(check?.errorMessage).toMatch(/^Not testable: /); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 73c9ddb..7daec39 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -23,7 +23,23 @@ import { } from './client-credentials'; import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; -import { CrossAppAccessCompleteFlowScenario } from './cross-app-access'; +import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization'; +import { WifJwtBearerScenario } from './wif-jwt-bearer'; +import { DPoPClientScenario } from './dpop'; +import { + OfflineAccessScopeScenario, + OfflineAccessNotSupportedScenario +} from './offline-access'; +import { AuthorizationServerMigrationScenario } from './authorization-server-migration'; +import { + IssParameterSupportedScenario, + IssParameterNotAdvertisedScenario, + IssParameterSupportedMissingScenario, + IssParameterWrongIssuerScenario, + IssParameterUnexpectedScenario, + IssParameterNormalizedVariantScenario, + MetadataIssuerMismatchScenario +} from './issuer-parameter'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -37,7 +53,6 @@ export const authScenariosList: Scenario[] = [ new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), new PublicClientAuthScenario(), - new ResourceMismatchScenario(), new PreRegistrationScenario() ]; @@ -51,5 +66,23 @@ export const backcompatScenariosList: Scenario[] = [ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario(), - new CrossAppAccessCompleteFlowScenario() + new EnterpriseManagedAuthorizationScenario(), + new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case) + new DPoPClientScenario(true), // auth/dpop-nonce — server-required nonce (§8/§9) + new WifJwtBearerScenario() +]; + +// Draft scenarios (informational - not scored for tier assessment) +export const draftScenariosList: Scenario[] = [ + new ResourceMismatchScenario(), + new OfflineAccessScopeScenario(), + new OfflineAccessNotSupportedScenario(), + new AuthorizationServerMigrationScenario(), + new IssParameterSupportedScenario(), + new IssParameterNotAdvertisedScenario(), + new IssParameterSupportedMissingScenario(), + new IssParameterWrongIssuerScenario(), + new IssParameterUnexpectedScenario(), + new IssParameterNormalizedVariantScenario(), + new MetadataIssuerMismatchScenario() ]; diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts new file mode 100644 index 0000000..85773e2 --- /dev/null +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -0,0 +1,745 @@ +import type { ScenarioContext } from '../../../mock-server'; +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types.js'; +import { createAuthServer } from './helpers/createAuthServer.js'; +import { createServer } from './helpers/createServer.js'; +import { ServerLifecycle } from './helpers/serverLifecycle.js'; +import { SpecReferences } from './spec-references.js'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; +import { untestableCheck } from '../../untestable.js'; + +const specRefs = [SpecReferences.RFC_9207_ISS_PARAMETER]; +const metadataSpecRefs = [ + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY +]; + +/** + * Reason-bound verdict for the RFC 9207 `iss` rejection checks (issue #467). + * + * `authReached && !tokenRequestMade` is a verdict, not a reason. SEP-2468 + * conditions every one of these requirements on the issuer the client recorded + * "from the selected authorization server validated metadata document", so a + * client that never retrieved that document cannot have performed the + * comparison under test — yet it satisfies the verdict, because not reaching + * the token endpoint is exactly what a client that fell over earlier also + * does. Absent the retrieval the requirement was never exercised, which is the + * untestable case (#248) rather than a pass or a violation. + * + * `auth/metadata-issuer-mismatch` in this same file already gates on the + * metadata fetch; the other checks did not. Keeping the policy in one function + * is deliberate: the duplication is what let five of six sites drift apart. + * + * Residual gap, deliberately not papered over: a client that receives the + * redirect and then aborts before the token request for an unrelated reason is + * still indistinguishable from one that rejected on `iss`. Closing that needs + * a signal from inside the client, which a black-box harness does not have. + * What this closes is the "never reached the requirement at all" class. + */ +function issRejectionCheck(opts: { + id: string; + name: string; + passDescription: string; + failDescription: string; + metadataRequested: boolean; + authReached: boolean; + tokenRequestMade: boolean; + observations: Record; + timestamp: string; +}): ConformanceCheck { + const { + id, + name, + passDescription, + failDescription, + metadataRequested, + authReached, + tokenRequestMade, + observations, + timestamp + } = opts; + + const reached = metadataRequested && authReached; + const observed = { + ...observations, + metadataRequested, + authReached, + tokenRequestMade + }; + + if (!reached) { + const reason = !metadataRequested + ? 'client never retrieved the authorization server metadata document, so it never recorded the issuer this comparison is made against' + : 'client never reached the authorization endpoint, so it never received an authorization response to validate'; + const check = untestableCheck(id, name, failDescription, reason, specRefs); + check.details = { + ...check.details, + ...observed, + propertyReached: false, + stopReason: !metadataRequested + ? 'as-metadata-not-requested' + : 'authorization-endpoint-not-reached' + }; + return check; + } + + const correctlyRejected = !tokenRequestMade; + return { + id, + name, + description: correctlyRejected ? passDescription : failDescription, + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + ...observed, + propertyReached: true, + stopReason: correctlyRejected + ? 'declined-before-token-request' + : 'transmitted-code-to-token-endpoint' + } + }; +} + +/** + * Scenario: ISS Parameter Supported (positive) + * + * Server advertises authorization_response_iss_parameter_supported: true and + * includes the correct iss value in the authorization redirect. A conformant + * client should validate iss and proceed normally. + */ +export class IssParameterSupportedScenario implements Scenario { + name = 'auth/iss-supported'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client accepts authorization response when server advertises and sends correct iss parameter'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'correct', + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + this.checks.push({ + id: 'sep-2468-client-compare-iss-supported', + name: 'Client accepts matching iss when advertised', + description: this.tokenRequestMade + ? 'Client compared advertised iss against recorded issuer and proceeded to token exchange' + : 'Client did not proceed to token exchange after receiving a correct iss from a server that advertised support', + status: this.tokenRequestMade ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { tokenRequestMade: this.tokenRequestMade } + }); + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Not Advertised (positive) + * + * Server does not advertise authorization_response_iss_parameter_supported and + * does not include iss in the redirect. A conformant client should proceed normally. + */ +export class IssParameterNotAdvertisedScenario implements Scenario { + name = 'auth/iss-not-advertised'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client accepts authorization response when server does not advertise or send iss parameter'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestMade = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: null, + issInRedirect: 'omit', + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + this.checks.push({ + id: 'sep-2468-client-proceed-no-iss', + name: 'Client proceeds when iss absent and not advertised', + description: this.tokenRequestMade + ? 'Client proceeded to token exchange when neither metadata advertised iss support nor redirect contained iss' + : 'Client did not proceed to token exchange — should proceed when iss is absent and not advertised', + status: this.tokenRequestMade ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { tokenRequestMade: this.tokenRequestMade } + }); + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Advertised but Missing from Redirect (client must reject) + * + * Server advertises authorization_response_iss_parameter_supported: true but + * omits iss from the redirect. A conformant client MUST reject this response. + */ +export class IssParameterSupportedMissingScenario implements Scenario { + name = 'auth/iss-supported-missing'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client rejects authorization response when server advertised iss support but omitted iss from redirect'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private authReached = false; + private tokenRequestMade = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'omit', // advertise support but don't send iss + onAuthorizationRequest: () => { + this.authReached = true; + }, + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if ( + !this.checks.some((c) => c.id === 'sep-2468-client-reject-missing-iss') + ) { + this.checks.push( + issRejectionCheck({ + id: 'sep-2468-client-reject-missing-iss', + name: 'Client rejects missing iss when required', + passDescription: + 'Client correctly rejected authorization response missing required iss parameter', + failDescription: + 'Client MUST reject authorization response when server advertised iss support but iss is absent from redirect', + metadataRequested: this.checks.some( + (c) => c.id === 'authorization-server-metadata' + ), + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade, + observations: { + serverAdvertisedSupport: true, + issSentInRedirect: false + }, + timestamp + }) + ); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Has Wrong Value (client must reject) + * + * Server advertises authorization_response_iss_parameter_supported: true and + * includes an iss value that does not match the server's actual issuer. A + * conformant client MUST reject this response. + */ +export class IssParameterWrongIssuerScenario implements Scenario { + name = 'auth/iss-wrong-issuer'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client rejects authorization response when iss does not match the authorization server issuer'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private authReached = false; + private tokenRequestMade = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'wrong', // send iss that doesn't match metadata issuer + onAuthorizationRequest: () => { + this.authReached = true; + }, + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if ( + !this.checks.some((c) => c.id === 'sep-2468-client-compare-iss-supported') + ) { + this.checks.push( + issRejectionCheck({ + id: 'sep-2468-client-compare-iss-supported', + name: 'Client rejects mismatched iss', + passDescription: + 'Client correctly rejected authorization response with mismatched iss parameter', + failDescription: + 'Client MUST reject authorization response when iss does not match the authorization server issuer', + metadataRequested: this.checks.some( + (c) => c.id === 'authorization-server-metadata' + ), + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade, + observations: { + serverAdvertisedSupport: true, + issSentInRedirect: 'https://evil.example.com' + }, + timestamp + }) + ); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Sent but Not Advertised, Mismatched (client must reject) + * + * Server does not advertise authorization_response_iss_parameter_supported but + * includes a mismatched iss value in the redirect. Per the SEP-2468 spec table + * row 3, a conformant client MUST compare a present iss against the recorded + * issuer regardless of metadata advertisement, and reject on mismatch. + */ +export class IssParameterUnexpectedScenario implements Scenario { + name = 'auth/iss-unexpected'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client compares iss against recorded issuer even when not advertised, and rejects on mismatch'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private authReached = false; + private tokenRequestMade = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: null, + issInRedirect: 'wrong', // send mismatched iss without advertising support + onAuthorizationRequest: () => { + this.authReached = true; + }, + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if ( + !this.checks.some( + (c) => c.id === 'sep-2468-client-compare-iss-unadvertised' + ) + ) { + this.checks.push( + issRejectionCheck({ + id: 'sep-2468-client-compare-iss-unadvertised', + name: 'Client compares unadvertised iss and rejects mismatch', + passDescription: + 'Client correctly compared unadvertised iss against recorded issuer and rejected the mismatch', + failDescription: + 'Client MUST compare a present iss against the recorded issuer regardless of metadata advertisement, and reject on mismatch', + metadataRequested: this.checks.some( + (c) => c.id === 'authorization-server-metadata' + ), + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade, + observations: { + serverAdvertisedSupport: false, + issSentInRedirect: 'https://evil.example.com' + }, + timestamp + }) + ); + } + + return this.checks; + } +} + +/** + * Scenario: ISS Parameter Is a Normalization-Equivalent Variant (client must reject) + * + * Server advertises authorization_response_iss_parameter_supported: true and + * includes an iss value that differs from the recorded issuer only by RFC 3986 + * normalization (a trailing slash on an empty path — exactly what + * `new URL(x).href` round-tripping produces). SEP-2468 requires simple string + * comparison with no scheme/host case folding, default-port elision, + * trailing-slash, or percent-encoding normalization, so a conformant client + * MUST treat the variant as a mismatch and reject the response. + */ +export class IssParameterNormalizedVariantScenario implements Scenario { + name = 'auth/iss-normalized'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client compares iss using simple string comparison without applying URL normalization'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private authReached = false; + private tokenRequestMade = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + issParameterSupported: true, + issInRedirect: 'normalized', // correct issuer + trailing slash + onAuthorizationRequest: () => { + this.authReached = true; + }, + onTokenRequest: () => { + this.tokenRequestMade = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if (!this.checks.some((c) => c.id === 'sep-2468-client-no-normalization')) { + this.checks.push( + issRejectionCheck({ + id: 'sep-2468-client-no-normalization', + name: 'Client compares iss without URL normalization', + passDescription: + 'Client rejected an iss value that only matches the recorded issuer after URL normalization', + failDescription: + 'Client MUST NOT apply scheme/host case folding, default-port elision, trailing-slash, or percent-encoding normalization to iss before comparison; a trailing-slash variant of the issuer must be treated as a mismatch', + metadataRequested: this.checks.some( + (c) => c.id === 'authorization-server-metadata' + ), + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade, + observations: { + recordedIssuer: this.authServer.getUrl(), + issSentInRedirect: `${this.authServer.getUrl()}/` + }, + timestamp + }) + ); + } + + return this.checks; + } +} + +/** + * Scenario: AS Metadata Issuer Mismatch (client must not use the metadata) + * + * The authorization server metadata document declares an `issuer` that is not + * identical to the issuer identifier used to construct the well-known URL. + * Per RFC 8414 §3.3 / OpenID Connect Discovery §4.3 (incorporated by + * SEP-2468), the client MUST validate the issuer and MUST NOT use the + * metadata on mismatch. The endpoints in the poisoned document live under a + * non-default route prefix, so any request that reaches them proves the + * client used the metadata. + */ +export class MetadataIssuerMismatchScenario implements Scenario { + name = 'auth/metadata-issuer-mismatch'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client rejects authorization server metadata whose issuer does not match the issuer used to construct the well-known URL'; + allowClientError = true; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private metadataEndpointsUsed = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.metadataEndpointsUsed = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + // The well-known URL is constructed from the AS URL advertised in PRM + // (the bare origin), so the document's issuer must equal that origin. + // Serve something else entirely. + metadataIssuer: 'https://attacker.example.com', + // Park the endpoints under a prefix only discoverable via the poisoned + // metadata, so "client used the metadata" is observable as a request. + routePrefix: '/mismatched-as', + onRegistrationRequest: () => { + this.metadataEndpointsUsed = true; + return { + clientId: 'test-client-id', + clientSecret: 'test-client-secret' + }; + }, + onAuthorizationRequest: () => { + this.metadataEndpointsUsed = true; + }, + onTokenRequest: () => { + this.metadataEndpointsUsed = true; + return { token: `test-token-${Date.now()}`, scopes: [] }; + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { requiredScopes: [], tokenVerifier } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + if ( + !this.checks.some( + (c) => c.id === 'sep-2468-client-validate-metadata-issuer' + ) + ) { + const metadataRequested = this.checks.some( + (c) => c.id === 'authorization-server-metadata' + ); + const observations = { + expectedIssuer: this.authServer.getUrl(), + metadataIssuer: 'https://attacker.example.com', + metadataRequested, + metadataEndpointsUsed: this.metadataEndpointsUsed + }; + const failDescription = + 'Client MUST NOT use authorization server metadata whose issuer differs from the issuer identifier used to construct the well-known URL; client used endpoints from the mismatched metadata'; + + // This check already bound its verdict to the metadata fetch, but + // reported the unreached case as a plain violation. Per #248 a + // requirement that could not be exercised is untestable, so the report + // distinguishes "client used the poisoned metadata" from "client never + // fetched it" instead of collapsing both into FAILURE (#467). + if (!metadataRequested) { + const check = untestableCheck( + 'sep-2468-client-validate-metadata-issuer', + 'Client validates metadata issuer against well-known URL', + failDescription, + 'client never retrieved the authorization server metadata document, so issuer validation could not be observed', + metadataSpecRefs + ); + check.details = { + ...check.details, + ...observations, + propertyReached: false, + stopReason: 'as-metadata-not-requested' + }; + this.checks.push(check); + } else { + const correctlyRejected = !this.metadataEndpointsUsed; + this.checks.push({ + id: 'sep-2468-client-validate-metadata-issuer', + name: 'Client validates metadata issuer against well-known URL', + description: correctlyRejected + ? 'Client rejected authorization server metadata whose issuer does not match the issuer identifier used to construct the well-known URL' + : failDescription, + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: metadataSpecRefs, + details: { + ...observations, + propertyReached: true, + stopReason: correctlyRejected + ? 'declined-poisoned-metadata' + : 'used-poisoned-metadata-endpoints' + } + }); + } + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 3bc857e..48f9170 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -1,5 +1,6 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls, SpecVersion } from '../../../types'; +import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -8,23 +9,31 @@ import { SpecReferences } from './spec-references'; export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { name = 'auth/2025-03-26-oauth-metadata-backcompat'; - specVersions: SpecVersion[] = ['2025-03-26']; + readonly source = { + introducedIn: '2025-03-26', + removedIn: '2025-06-18' + } as const; description = 'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location'; private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Legacy server, so we create the auth server endpoints on the // same URL as the main server (rather than separating AS / RS). - const authApp = createAuthServer(this.checks, this.server.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.server.getUrl, { // Disable logging since the main server will already have logging enabled loggingEnabled: false, - // Add a prefix to auth endpoints to avoid being caught by auth fallbacks - routePrefix: '/oauth' + // Keep auth endpoints off the 2025-03-26 fallback paths so a client that + // fetches metadata but ignores the advertised endpoints still 404s. + routePrefix: '/oauth', + // Metadata is served at the root well-known path, so per RFC 8414 §3.3 + // the `issuer` must be the bare origin — not `/oauth`. + metadataIssuer: () => this.server.getUrl() }); const app = createServer( + ctx, this.checks, this.server.getUrl, this.server.getUrl, @@ -69,16 +78,20 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { export class Auth20250326OEndpointFallbackScenario implements Scenario { name = 'auth/2025-03-26-oauth-endpoint-fallback'; - specVersions: SpecVersion[] = ['2025-03-26']; + readonly source = { + introducedIn: '2025-03-26', + removedIn: '2025-06-18' + } as const; description = 'Tests OAuth flow with no metadata endpoints, relying on fallback to standard OAuth endpoints at server root (2025-03-26 spec behavior)'; private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const app = createServer( + ctx, this.checks, this.server.getUrl, this.server.getUrl, diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts new file mode 100644 index 0000000..77eccca --- /dev/null +++ b/src/scenarios/client/auth/offline-access.ts @@ -0,0 +1,311 @@ +import type { ScenarioContext } from '../../../mock-server'; +import type { Scenario, ConformanceCheck } from '../../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier'; + +/** + * Scenario: Offline Access Scope (SEP-2207) + * + * Tests client behavior when the Authorization Server metadata lists + * `offline_access` in `scopes_supported`: + * + * 1. Client SHOULD include `refresh_token` in `grant_types` client metadata + * (checked via DCR body or CIMD document, whichever the client uses) + * 2. Client MAY include `offline_access` in authorization request scope + * + * Setup: + * - AS metadata: scopes_supported includes 'offline_access' + * - PRM: scopes_supported does NOT include 'offline_access' (per SEP-2207 server guidance) + * - Both CIMD and DCR paths available + */ +export class OfflineAccessScopeScenario implements Scenario { + name = 'auth/offline-access-scope'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that a client that wants a refresh token handles offline_access scope and refresh_token grant type when AS supports them (SEP-2207)'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private grantTypesChecked = false; + private capturedCimdUrl: string | undefined; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.grantTypesChecked = false; + this.capturedCimdUrl = undefined; + + const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + scopesSupported: ['mcp:basic', 'offline_access'], + clientIdMetadataDocumentSupported: true, + onRegistrationRequest: (req) => { + // DCR path: inspect grant_types in registration body + const grantTypes: string[] = req.body.grant_types || []; + const hasRefreshToken = grantTypes.includes('refresh_token'); + this.grantTypesChecked = true; + this.checks.push({ + id: 'sep-2207-client-metadata-grant-types', + name: 'Client metadata includes refresh_token grant type (DCR)', + description: hasRefreshToken + ? 'Client correctly included refresh_token in grant_types during dynamic client registration' + : 'Client SHOULD include refresh_token in grant_types client metadata (SEP-2207)', + status: hasRefreshToken ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE], + details: { + registrationMethod: 'DCR', + grantTypes: grantTypes.length > 0 ? grantTypes.join(' ') : 'none' + } + }); + + const clientId = `test-client-${Date.now()}`; + return { + clientId, + clientSecret: undefined, + tokenEndpointAuthMethod: 'none' + }; + }, + onAuthorizationRequest: (data) => { + // Capture CIMD URL if client used URL-based client_id + if (data.clientId && data.clientId.startsWith('http')) { + this.capturedCimdUrl = data.clientId; + } + + // Check if client included offline_access in scope + const requestedScopes = data.scope ? data.scope.split(' ') : []; + const hasOfflineAccess = requestedScopes.includes('offline_access'); + this.checks.push({ + id: 'sep-2207-offline-access-requested', + name: 'Client requests offline_access scope', + description: hasOfflineAccess + ? 'Client included offline_access in authorization request scope when AS lists it in scopes_supported' + : 'Client MAY include offline_access in scope when AS metadata lists it in scopes_supported (SEP-2207). Client chose not to request it.', + status: hasOfflineAccess ? 'SUCCESS' : 'INFO', + timestamp: data.timestamp, + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE], + details: { + asScopesSupported: 'mcp:basic offline_access', + requestedScope: data.scope || 'none' + } + }); + } + }); + await this.authServer.start(authApp); + + // PRM does NOT include offline_access (per SEP-2207 server guidance: + // servers SHOULD NOT include offline_access in PRM scopes_supported) + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: ['mcp:basic'], + scopesSupported: ['mcp:basic'], + tokenVerifier + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + // If client used CIMD and we haven't checked grant_types yet, + // attempt to fetch the CIMD URL to inspect the metadata document + if (this.capturedCimdUrl && !this.grantTypesChecked) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(this.capturedCimdUrl, { + signal: controller.signal + }); + clearTimeout(timeout); + + if (response.ok) { + const metadata = await response.json(); + const grantTypes: string[] = metadata.grant_types || []; + const hasRefreshToken = grantTypes.includes('refresh_token'); + this.grantTypesChecked = true; + this.checks.push({ + id: 'sep-2207-client-metadata-grant-types', + name: 'Client metadata includes refresh_token grant type (CIMD)', + description: hasRefreshToken + ? 'Client metadata document includes refresh_token in grant_types' + : 'Client SHOULD include refresh_token in grant_types client metadata (SEP-2207)', + status: hasRefreshToken ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE], + details: { + registrationMethod: 'CIMD', + cimdUrl: this.capturedCimdUrl, + grantTypes: grantTypes.length > 0 ? grantTypes.join(' ') : 'none' + } + }); + } + } catch { + // CIMD URL didn't resolve - emit info check + this.grantTypesChecked = true; + this.checks.push({ + id: 'sep-2207-client-metadata-grant-types', + name: 'Client metadata includes refresh_token grant type (CIMD)', + description: + 'Client used CIMD but metadata URL could not be fetched to verify grant_types', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE], + details: { + registrationMethod: 'CIMD', + cimdUrl: this.capturedCimdUrl, + fetchFailed: true + } + }); + } + } + + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + + // If grant_types was never checked (no DCR, no CIMD, possibly pre-registered) + if (!this.grantTypesChecked) { + this.checks.push({ + id: 'sep-2207-client-metadata-grant-types', + name: 'Client metadata includes refresh_token grant type', + description: + 'Client did not use DCR or fetchable CIMD — grant_types could not be inspected', + status: 'INFO', + timestamp, + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE], + details: { + registrationMethod: 'unknown' + } + }); + } + + // If offline_access check never ran, the authorization flow didn't complete + if ( + !this.checks.some((c) => c.id === 'sep-2207-offline-access-requested') + ) { + this.checks.push({ + id: 'sep-2207-offline-access-requested', + name: 'Client requests offline_access scope', + description: + 'Client did not complete authorization flow — offline_access scope check could not be performed', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE] + }); + } + + return this.checks; + } +} + +/** + * Scenario: Offline Access Not Supported (SEP-2207) + * + * Tests that clients do NOT include `offline_access` in the authorization + * request scope when the AS metadata does NOT list it in `scopes_supported`. + * + * Per SEP-2207, clients MAY add offline_access only "when the Authorization + * Server's metadata lists it in scopes_supported". If the AS doesn't support + * it, requesting it is an error (requesting an unsupported scope). + * + * Setup: + * - AS metadata: scopes_supported does NOT include 'offline_access' + * - PRM: standard scopes + */ +export class OfflineAccessNotSupportedScenario implements Scenario { + name = 'auth/offline-access-not-supported'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests that client does not request offline_access when AS does not list it in scopes_supported (SEP-2207)'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, [ + 'mcp:basic', + 'mcp:read' + ]); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + tokenVerifier, + scopesSupported: ['mcp:basic', 'mcp:read'], + onAuthorizationRequest: (data) => { + const requestedScopes = data.scope ? data.scope.split(' ') : []; + const hasOfflineAccess = requestedScopes.includes('offline_access'); + this.checks.push({ + id: 'sep-2207-offline-access-not-requested', + name: 'Client does not request unsupported offline_access', + description: hasOfflineAccess + ? 'Client MUST NOT request offline_access when it is not listed in AS scopes_supported (SEP-2207)' + : 'Client correctly did not request offline_access when AS does not list it in scopes_supported', + status: hasOfflineAccess ? 'FAILURE' : 'SUCCESS', + timestamp: data.timestamp, + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE], + details: { + asScopesSupported: 'mcp:basic mcp:read', + requestedScope: data.scope || 'none' + } + }); + } + }); + await this.authServer.start(authApp); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: ['mcp:basic', 'mcp:read'], + scopesSupported: ['mcp:basic', 'mcp:read'], + tokenVerifier + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + if ( + !this.checks.some((c) => c.id === 'sep-2207-offline-access-not-requested') + ) { + this.checks.push({ + id: 'sep-2207-offline-access-not-requested', + name: 'Client does not request unsupported offline_access', + description: + 'Client did not complete authorization flow — offline_access scope check could not be performed', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.SEP_2207_REFRESH_TOKEN_GUIDANCE] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index 00673df..7e4ff54 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -1,9 +1,5 @@ -import type { - Scenario, - ConformanceCheck, - ScenarioUrls, - SpecVersion -} from '../../../types'; +import type { ScenarioContext } from '../../../mock-server'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -17,14 +13,19 @@ const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret'; * Scenario: Pre-registration (static client credentials) * * Tests OAuth flow where the server does NOT support Dynamic Client Registration. - * Clients must use pre-registered credentials passed via context. + * Clients must use pre-registered credentials passed via context. The context + * also carries the mock AS's `issuer` identifier: clients targeting the + * 2026-07-28 spec MUST associate pre-registered credentials with the + * authorization server that issued them, keyed by that issuer + * (Authorization Server Binding): + * https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#authorization-server-binding * * This tests the pre-registration approach described in the MCP spec: * https://modelcontextprotocol.io/specification/draft/basic/authorization#preregistration */ export class PreRegistrationScenario implements Scenario { name = 'auth/pre-registration'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.'; @@ -32,11 +33,11 @@ export class PreRegistrationScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, []); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, disableDynamicRegistration: true, tokenEndpointAuthMethodsSupported: ['client_secret_basic'], @@ -110,6 +111,7 @@ export class PreRegistrationScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -126,7 +128,11 @@ export class PreRegistrationScenario implements Scenario { serverUrl: `${this.server.getUrl()}/mcp`, context: { client_id: PRE_REGISTERED_CLIENT_ID, - client_secret: PRE_REGISTERED_CLIENT_SECRET + client_secret: PRE_REGISTERED_CLIENT_SECRET, + // The `issuer` the mock AS publishes in its metadata — the scenario + // sets neither `metadataIssuer` nor `routePrefix`, so createAuthServer + // resolves the published issuer to exactly this URL. + issuer: this.authServer.getUrl() } }; } diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index dd76c68..a5cc157 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -1,10 +1,12 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls, SpecVersion } from '../../../types.js'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; import { SpecReferences } from './spec-references.js'; import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; +import { untestableCheck } from '../../untestable.js'; /** * Scenario: Resource Mismatch Detection @@ -27,7 +29,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; */ export class ResourceMismatchScenario implements Scenario { name = 'auth/resource-mismatch'; - specVersions: SpecVersion[] = ['draft']; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client rejects when PRM resource does not match server URL'; allowClientError = true; @@ -37,13 +39,13 @@ export class ResourceMismatchScenario implements Scenario { private checks: ConformanceCheck[] = []; private authorizationRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authorizationRequestMade = false; const tokenVerifier = new MockTokenVerifier(this.checks, []); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, tokenEndpointAuthMethodsSupported: ['none'], onAuthorizationRequest: () => { @@ -60,6 +62,7 @@ export class ResourceMismatchScenario implements Scenario { // Create server that returns a mismatched resource in PRM const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -88,25 +91,57 @@ export class ResourceMismatchScenario implements Scenario { SpecReferences.MCP_RESOURCE_PARAMETER ]; - // The test passes if the client did NOT make an authorization request - // (meaning it correctly rejected the mismatched resource) + // Reason-bound verdict (#467). "Did not proceed with authorization" is not + // by itself evidence of validation: a client that never fetched the PRM + // document never read the mismatched `resource`, so it cannot have + // compared it. Absent that fetch the requirement was never exercised, + // which is the untestable case (#248) rather than a pass or a violation. if (!this.checks.some((c) => c.id === 'resource-mismatch-rejected')) { - const correctlyRejected = !this.authorizationRequestMade; - this.checks.push({ - id: 'resource-mismatch-rejected', - name: 'Client rejects mismatched resource', - description: correctlyRejected - ? 'Client correctly rejected authorization when PRM resource does not match server URL' - : 'Client MUST validate that PRM resource matches the server URL before proceeding with authorization', - status: correctlyRejected ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - prmResource: 'https://evil.example.com/mcp', - expectedBehavior: 'Client should NOT proceed with authorization', - authorizationRequestMade: this.authorizationRequestMade - } - }); + const prmRequested = this.checks.some( + (c) => c.id === 'prm-pathbased-requested' + ); + const correctlyRejected = prmRequested && !this.authorizationRequestMade; + const observations = { + prmResource: 'https://evil.example.com/mcp', + expectedBehavior: 'Client should NOT proceed with authorization', + prmRequested, + authorizationRequestMade: this.authorizationRequestMade + }; + + if (!prmRequested) { + const check = untestableCheck( + 'resource-mismatch-rejected', + 'Client rejects mismatched resource', + 'Client MUST validate that PRM resource matches the server URL before proceeding with authorization', + 'client never requested the Protected Resource Metadata document, so it never read the resource value it was required to validate', + specRefs + ); + check.details = { + ...check.details, + ...observations, + propertyReached: false, + stopReason: 'prm-not-requested' + }; + this.checks.push(check); + } else { + this.checks.push({ + id: 'resource-mismatch-rejected', + name: 'Client rejects mismatched resource', + description: correctlyRejected + ? 'Client correctly rejected authorization when PRM resource does not match server URL' + : 'Client MUST validate that PRM resource matches the server URL before proceeding with authorization', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + ...observations, + propertyReached: true, + stopReason: correctlyRejected + ? 'declined-after-reading-prm' + : 'proceeded-to-authorization' + } + }); + } } return this.checks; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 166a9c3..227c6c4 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -1,10 +1,12 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls, SpecVersion } from '../../../types'; +import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; import { SpecReferences } from './spec-references'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; +import { DRAFT_PROTOCOL_VERSION, specVersionAtLeast } from '../../../types'; import type { Request, Response, NextFunction } from 'express'; /** @@ -15,20 +17,20 @@ import type { Request, Response, NextFunction } from 'express'; */ export class ScopeFromWwwAuthenticateScenario implements Scenario { name = 'auth/scope-from-www-authenticate'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests that client uses scope parameter from WWW-Authenticate header when provided'; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const expectedScope = 'mcp:basic'; const tokenVerifier = new MockTokenVerifier(this.checks, [expectedScope]); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, onAuthorizationRequest: (data) => { // Check if client used the scope from WWW-Authenticate header @@ -53,6 +55,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -101,20 +104,20 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { */ export class ScopeFromScopesSupportedScenario implements Scenario { name = 'auth/scope-from-scopes-supported'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests that client uses all scopes from scopes_supported when scope not in WWW-Authenticate header'; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const scopesSupported = ['mcp:basic', 'mcp:read', 'mcp:write']; const tokenVerifier = new MockTokenVerifier(this.checks, scopesSupported); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, onAuthorizationRequest: (data) => { // Check if client requested all scopes from scopes_supported @@ -148,6 +151,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -197,19 +201,19 @@ export class ScopeFromScopesSupportedScenario implements Scenario { */ export class ScopeOmittedWhenUndefinedScenario implements Scenario { name = 'auth/scope-omitted-when-undefined'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests that client omits scope parameter when scopes_supported is undefined'; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, []); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, onAuthorizationRequest: (data) => { // Check if client omitted scope parameter @@ -232,6 +236,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -284,22 +289,34 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { */ export class ScopeStepUpAuthScenario implements Scenario { name = 'auth/scope-step-up'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests that client handles step-up authentication with different scope requirements per operation'; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + // SEP-2350's set-wise union requirement was introduced in 2026-07-28 (the + // current draft); it was not a requirement at 2025-11-25, where + // non-accumulating re-auth is conformant. Gate the union check accordingly. + private unionRequired = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; + this.unionRequired = specVersionAtLeast( + ctx.specVersion, + DRAFT_PROTOCOL_VERSION + ); const initialScope = 'mcp:basic'; - const escalatedScopes = ['mcp:basic', 'mcp:write']; + // tools/call gates on mcp:write only (not the union) so the scenario can + // complete even for clients that don't accumulate; the SEP-2350 check then + // observes whether the previously-granted mcp:basic was retained. + const stepUpScope = 'mcp:write'; + const escalatedScopes = [initialScope, stepUpScope]; const tokenVerifier = new MockTokenVerifier(this.checks, escalatedScopes); let authRequestCount = 0; - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, onAuthorizationRequest: (data) => { authRequestCount++; @@ -323,24 +340,45 @@ export class ScopeStepUpAuthScenario implements Scenario { } }); } else if (authRequestCount === 2) { - // Second auth request - should escalate to mcp:basic + mcp:write - const hasAllScopes = escalatedScopes.every((s) => - requestedScopes.includes(s) - ); + // Second auth request after a 403 challenge that listed only the + // *missing* scope (mcp:write). Two distinct assertions: + // - escalation: client included the challenged scope at all + // - SEP-2350 union: client *also* kept the previously-granted scope + // that was NOT in the challenge (i.e., computed prior ∪ challenge) + const includesChallenged = requestedScopes.includes(stepUpScope); this.checks.push({ id: 'scope-step-up-escalation', name: 'Client scope escalation for step-up auth', - description: hasAllScopes - ? 'Client correctly escalated scopes for step-up authentication' + description: includesChallenged + ? 'Client correctly requested the challenged scope for step-up authentication' : 'Client SHOULD request additional scopes when receiving 403 with new scope requirements', - status: hasAllScopes ? 'SUCCESS' : 'WARNING', + status: includesChallenged ? 'SUCCESS' : 'WARNING', timestamp: data.timestamp, specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY], details: { - expectedScopes: escalatedScopes.join(' '), + challengedScope: stepUpScope, requestedScope: data.scope || 'none' } }); + + if (this.unionRequired) { + const retainedPrior = requestedScopes.includes(initialScope); + this.checks.push({ + id: 'sep-2350-scope-union-on-reauth', + name: 'Client accumulates previously-granted scopes on re-authorization', + description: retainedPrior + ? 'Client included previously-granted scopes alongside the newly challenged scope when re-authorizing' + : 'Client SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization (SEP-2350); previously-granted scope was dropped', + status: retainedPrior ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING], + details: { + previouslyGranted: initialScope, + challengedScope: stepUpScope, + requestedScope: data.scope || 'none' + } + }); + } } } }); @@ -388,19 +426,21 @@ export class ScopeStepUpAuthScenario implements Scenario { // Determine required scopes based on method const isToolCall = method === 'tools/call'; - const requiredScopes = isToolCall ? escalatedScopes : [initialScope]; + const requiredScopes = isToolCall ? [stepUpScope] : [initialScope]; const hasRequiredScopes = requiredScopes.every((s) => tokenScopes.includes(s) ); if (!hasRequiredScopes) { - // Has token but insufficient scopes - return 403 + // Has token but insufficient scopes - return 403. Challenge with only + // the step-up scope so SEP-2350 union accumulation is observable: a + // client that just echoes the challenge would drop mcp:basic. return res .status(403) .set( 'WWW-Authenticate', - `Bearer scope="${requiredScopes.join(' ')}", resource_metadata="${resourceMetadataUrl()}", error="insufficient_scope"` + `Bearer scope="${stepUpScope}", resource_metadata="${resourceMetadataUrl()}", error="insufficient_scope"` ) .json({ error: 'insufficient_scope', @@ -412,13 +452,19 @@ export class ScopeStepUpAuthScenario implements Scenario { }; const baseApp = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, { prmPath: '/.well-known/oauth-protected-resource/mcp', requiredScopes: escalatedScopes, - scopesSupported: escalatedScopes, + // Deliberately disjoint from initialScope/stepUpScope so the SEP-2350 + // union check can't be satisfied by a client that unions + // scopes_supported ∪ challenge instead of prior-grant ∪ challenge. + // The spec allows this: clients MUST NOT assume any set relationship + // between challenged scopes and scopes_supported. + scopesSupported: ['mcp:profile'], includeScopeInWwwAuth: true, authMiddleware: stepUpMiddleware, tokenVerifier @@ -465,6 +511,17 @@ export class ScopeStepUpAuthScenario implements Scenario { timestamp: new Date().toISOString(), specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] }); + if (this.unionRequired) { + this.checks.push({ + id: 'sep-2350-scope-union-on-reauth', + name: 'Client accumulates previously-granted scopes on re-authorization', + description: + 'Client did not make a second authorization request - scope union check could not be performed', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING] + }); + } } return this.checks; @@ -481,7 +538,7 @@ export class ScopeStepUpAuthScenario implements Scenario { */ export class ScopeRetryLimitScenario implements Scenario { name = 'auth/scope-retry-limit'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests that client implements retry limits to prevent infinite authorization loops on repeated 403 responses'; allowClientError = true; @@ -489,14 +546,14 @@ export class ScopeRetryLimitScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const requiredScope = 'mcp:admin'; const tokenVerifier = new MockTokenVerifier(this.checks, []); let authRequestCount = 0; - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, onAuthorizationRequest: (data) => { authRequestCount++; @@ -574,6 +631,7 @@ export class ScopeRetryLimitScenario implements Scenario { }; const baseApp = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 4020bfc..980c030 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -81,6 +81,14 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'MCP-Resource-Parameter-Implementation', url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#resource-parameter-implementation' }, + MCP_CANONICAL_SERVER_URI: { + id: 'MCP-Canonical-Server-URI', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#canonical-server-uri' + }, + RFC_9728_RESOURCE_IDENTITY: { + id: 'RFC-9728-resource-identity', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3' + }, MCP_PREREGISTRATION: { id: 'MCP-Preregistration', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration' @@ -89,6 +97,11 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'MCP-PKCE-requirement', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection' }, + // TODO: Update to MCP spec URL once SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468) is merged + RFC_9207_ISS_PARAMETER: { + id: 'RFC-9207-iss-parameter', + url: 'https://www.rfc-editor.org/rfc/rfc9207.html#section-2' + }, RFC_8693_TOKEN_EXCHANGE: { id: 'RFC-8693-Token-Exchange', url: 'https://datatracker.ietf.org/doc/html/rfc8693' @@ -99,6 +112,47 @@ export const SpecReferences: { [key: string]: SpecReference } = { }, SEP_990_ENTERPRISE_OAUTH: { id: 'SEP-990-Enterprise-Managed-OAuth', - url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-oauth.mdx' + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx' + }, + SEP_2207_REFRESH_TOKEN_GUIDANCE: { + id: 'SEP-2207-Refresh-Token-Guidance', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207' + }, + SEP_1933_WIF: { + id: 'SEP-1933-Workload-Identity-Federation', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933' + }, + // DPoP (SEP-1932 / RFC 9449) — client concerns. + SEP_1932_DPOP: { + id: 'SEP-1932-DPoP', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932' + }, + DPOP_EXTENSION: { + id: 'MCP-DPoP-Extension', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx' + }, + RFC_9449_PROOF_SYNTAX: { + id: 'RFC-9449-dpop-proof-jwt-syntax', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2' + }, + RFC_9449_CHECKING_PROOFS: { + id: 'RFC-9449-checking-dpop-proofs', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-4.3' + }, + RFC_9449_AUTH_SCHEME: { + id: 'RFC-9449-dpop-authentication-scheme', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-7.1' + }, + RFC_9449_TOKEN_REQUEST: { + id: 'RFC-9449-dpop-access-token-request', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5' + }, + RFC_9449_AS_NONCE: { + id: 'RFC-9449-authorization-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-8' + }, + RFC_9449_RS_NONCE: { + id: 'RFC-9449-resource-server-provided-nonce', + url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-9' } }; diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 0dbc8d8..e21459a 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -1,4 +1,6 @@ import { getScenario } from '../../../index'; +import { testScenarioContext } from '../../../../mock-server/testing'; +import type { SpecVersion, ConformanceCheck } from '../../../../types'; import { spawn } from 'child_process'; const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete @@ -85,15 +87,33 @@ export class InlineClientRunner implements ClientRunner { export interface RunClientOptions { expectedFailureSlugs?: string[]; + /** + * Slugs that MUST be present and SUCCESS. Use alongside expectedFailureSlugs + * to pin that a specific check is *not* collateral damage of the injected + * defect (e.g. a client that skips the nonce retry still passes + * token-request-proof because it did present a valid proof). + */ + expectedSuccessSlugs?: string[]; allowClientError?: boolean; + /** + * Spec version to run the scenario at. Defaults to the latest dated spec + * version (see {@link testScenarioContext}). Pass the draft version to + * exercise checks gated to draft-only requirements. + */ + specVersion?: SpecVersion; } export async function runClientAgainstScenario( clientRunner: ClientRunner, scenarioName: string, options: RunClientOptions = {} -): Promise { - const { expectedFailureSlugs = [], allowClientError = false } = options; +): Promise { + const { + expectedFailureSlugs = [], + expectedSuccessSlugs = [], + allowClientError = false, + specVersion + } = options; const runner = clientRunner; @@ -103,13 +123,19 @@ export async function runClientAgainstScenario( } // Start the scenario server - const urls = await scenario.start(); + const ctx = testScenarioContext(specVersion); + const urls = await scenario.start(ctx); const serverUrl = urls.serverUrl; + // The emitted checks, returned to the caller for assertions the standard + // pass/fail options don't cover (e.g. counting occurrences of a shared check). + let collected: ConformanceCheck[] = []; + try { // Set environment variables for inline clients // These mirror what src/runner/client.ts does for spawned processes process.env.MCP_CONFORMANCE_SCENARIO = scenarioName; + process.env.MCP_CONFORMANCE_PROTOCOL_VERSION = ctx.specVersion; if (urls.context) { process.env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({ name: scenarioName, @@ -129,6 +155,7 @@ export async function runClientAgainstScenario( // Get checks from the scenario const checks = scenario.getChecks(); + collected = checks; // Verify checks were returned if (checks.length === 0) { @@ -138,6 +165,15 @@ export async function runClientAgainstScenario( // Filter out INFO checks const nonInfoChecks = checks.filter((c) => c.status !== 'INFO'); + // Slugs that must be present and SUCCESS (independent of the failure set). + for (const slug of expectedSuccessSlugs) { + const check = checks.find((c) => c.id === slug); + if (!check) { + throw new Error(`Expected-success check ${slug} not found`); + } + expect(check.status).toBe('SUCCESS'); + } + // Check for expected failures if (expectedFailureSlugs.length > 0) { // Verify that the expected failures are present @@ -179,8 +215,11 @@ export async function runClientAgainstScenario( // Clean up environment variables delete process.env.MCP_CONFORMANCE_SCENARIO; delete process.env.MCP_CONFORMANCE_CONTEXT; + delete process.env.MCP_CONFORMANCE_PROTOCOL_VERSION; // Stop the scenario server await scenario.stop(); } + + return collected; } diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 2b32444..ae046e2 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -1,10 +1,12 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls, SpecVersion } from '../../../types.js'; +import { ScenarioUrls } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; import { SpecReferences } from './spec-references.js'; import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; +import { addResourceParameterChecks } from './helpers/resourceParameterChecks.js'; type AuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; @@ -45,7 +47,7 @@ const AUTH_METHOD_NAMES: Record = { class TokenEndpointAuthScenario implements Scenario { name: string; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description: string; private expectedAuthMethod: AuthMethod; private authServer = new ServerLifecycle(); @@ -55,6 +57,7 @@ class TokenEndpointAuthScenario implements Scenario { // Track resource parameters for RFC 8707 validation private authorizationResource?: string; private tokenResource?: string; + private prmResource?: string; constructor(expectedAuthMethod: AuthMethod) { this.expectedAuthMethod = expectedAuthMethod; @@ -62,13 +65,14 @@ class TokenEndpointAuthScenario implements Scenario { this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`; } - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authorizationResource = undefined; this.tokenResource = undefined; + this.prmResource = undefined; const tokenVerifier = new MockTokenVerifier(this.checks, []); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], onAuthorizationRequest: ({ resource }) => { @@ -137,13 +141,17 @@ class TokenEndpointAuthScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, { prmPath: '/.well-known/oauth-protected-resource/mcp', requiredScopes: [], - tokenVerifier + tokenVerifier, + onPrmRequest: ({ resource }) => { + this.prmResource = resource; + } } ); await this.server.start(app); @@ -171,118 +179,18 @@ class TokenEndpointAuthScenario implements Scenario { } // RFC 8707 Resource Parameter Validation Checks - this.addResourceParameterChecks(timestamp); + addResourceParameterChecks( + this.checks, + { + authorizationResource: this.authorizationResource, + tokenResource: this.tokenResource, + prmResource: this.prmResource + }, + timestamp + ); return this.checks; } - - private addResourceParameterChecks(timestamp: string): void { - const specRefs = [ - SpecReferences.RFC_8707_RESOURCE_INDICATORS, - SpecReferences.MCP_RESOURCE_PARAMETER - ]; - - // Check 1: Resource parameter in authorization request - if ( - !this.checks.some((c) => c.id === 'resource-parameter-in-authorization') - ) { - const hasResource = !!this.authorizationResource; - this.checks.push({ - id: 'resource-parameter-in-authorization', - name: 'Resource parameter in authorization request', - description: hasResource - ? 'Client included resource parameter in authorization request' - : 'Client MUST include resource parameter in authorization request per RFC 8707', - status: hasResource ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - resource: this.authorizationResource || 'not provided' - } - }); - } - - // Check 2: Resource parameter in token request - if (!this.checks.some((c) => c.id === 'resource-parameter-in-token')) { - const hasResource = !!this.tokenResource; - this.checks.push({ - id: 'resource-parameter-in-token', - name: 'Resource parameter in token request', - description: hasResource - ? 'Client included resource parameter in token request' - : 'Client MUST include resource parameter in token request per RFC 8707', - status: hasResource ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - resource: this.tokenResource || 'not provided' - } - }); - } - - // Check 3: Resource parameter is valid canonical URI - if (!this.checks.some((c) => c.id === 'resource-parameter-valid-uri')) { - const resourceToValidate = - this.authorizationResource || this.tokenResource; - if (resourceToValidate) { - const validation = this.validateCanonicalUri(resourceToValidate); - this.checks.push({ - id: 'resource-parameter-valid-uri', - name: 'Resource parameter is valid canonical URI', - description: validation.valid - ? 'Resource parameter is a valid canonical URI (has scheme, no fragment)' - : `Resource parameter is invalid: ${validation.error}`, - status: validation.valid ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - resource: resourceToValidate, - ...(validation.error && { error: validation.error }) - } - }); - } - } - - // Check 4: Resource parameter consistency between requests - if (!this.checks.some((c) => c.id === 'resource-parameter-consistency')) { - if (this.authorizationResource && this.tokenResource) { - const consistent = this.authorizationResource === this.tokenResource; - this.checks.push({ - id: 'resource-parameter-consistency', - name: 'Resource parameter consistency', - description: consistent - ? 'Resource parameter is consistent between authorization and token requests' - : 'Resource parameter MUST be consistent between authorization and token requests', - status: consistent ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - authorizationResource: this.authorizationResource, - tokenResource: this.tokenResource - } - }); - } - } - } - - private validateCanonicalUri(uri: string): { - valid: boolean; - error?: string; - } { - try { - const parsed = new URL(uri); - // Check for fragment (RFC 8707: MUST NOT include fragment) - if (parsed.hash) { - return { - valid: false, - error: 'contains fragment (not allowed per RFC 8707)' - }; - } - return { valid: true }; - } catch { - return { valid: false, error: 'invalid URI format' }; - } - } } export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario { diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts new file mode 100644 index 0000000..4db0bd9 --- /dev/null +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -0,0 +1,316 @@ +import * as jose from 'jose'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import type { ScenarioContext } from '../../../mock-server'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; +import { + JWT_BEARER_GRANT_TYPE, + WIF_TRIGGER_UNAUTHORIZED_SCOPE, + WIF_REJECTED_SCOPE, + generateWorkloadKeypair, + createWorkloadJwt +} from './helpers/createWorkloadJwt.js'; + +const WIF_ISSUER = 'https://idp.conformance-test.local'; +const WIF_SUBJECT = 'conformance-workload'; +const WIF_CLIENT_ID = 'conformance-wif-workload'; + +export class WifJwtBearerScenario implements Scenario { + name = 'auth/wif-jwt-bearer'; + readonly source = { + extensionId: 'io.modelcontextprotocol/auth/wif' + } as const; + description = + 'Tests the RFC 7523 JWT-bearer grant for workload identity federation (SEP-1933). ' + + 'The client must: use grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, ' + + 'include the workload JWT as the assertion parameter, and surface errors ' + + '(invalid_grant, invalid_scope, unauthorized_client) without retrying or switching grant types.'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private tokenRequestReceived = false; + private failedOnce = false; + + async start(ctx: ScenarioContext): Promise { + this.checks = []; + this.tokenRequestReceived = false; + this.failedOnce = false; + + const { publicKey, privateKey } = await generateWorkloadKeypair(); + + const tokenVerifier = new MockTokenVerifier(this.checks); + + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { + grantTypesSupported: [JWT_BEARER_GRANT_TYPE], + tokenEndpointAuthMethodsSupported: ['none'], + tokenVerifier, + disableDynamicRegistration: true, + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + // wif-no-retry and wif-grant-fallback fire on any second request after + // any first failure, not only after unauthorized_client specifically. + if (this.tokenRequestReceived && this.failedOnce) { + if (grantType !== JWT_BEARER_GRANT_TYPE) { + this.checks.push({ + id: 'wif-grant-fallback', + name: 'WifGrantFallback', + description: `Client fell back to ${grantType} grant after a JWT-bearer token request was rejected; clients should not switch grant types after a JWT-bearer failure`, + status: 'WARNING', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only JWT-bearer grant is supported' + }; + } + this.checks.push({ + id: 'wif-no-retry', + name: 'WifNoRetry', + description: + 'Client retried JWT-bearer token request after a failure instead of giving up', + status: 'WARNING', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + return { + error: 'invalid_request', + errorDescription: 'Retry not allowed for JWT-bearer grant' + }; + } + this.tokenRequestReceived = true; + if (grantType !== JWT_BEARER_GRANT_TYPE) { + this.checks.push({ + id: 'wif-grant-type', + name: 'WifGrantType', + description: `Expected grant_type=${JWT_BEARER_GRANT_TYPE}, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'unsupported_grant_type', + errorDescription: `Only ${JWT_BEARER_GRANT_TYPE} grant is supported` + }; + } + + const assertion = body.assertion; + if (!assertion) { + this.checks.push({ + id: 'wif-assertion-missing', + name: 'WifAssertionMissing', + description: + 'Missing assertion parameter in JWT-bearer token request', + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_request', + errorDescription: 'Missing assertion parameter' + }; + } + + try { + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; + // iss is not validated: the keypair is generated per start() call and + // the public key closure binds the assertion to this run. This scenario + // tests client behaviour, not AS issuer policy. + // clockTolerance of 5s is sufficient because JWTs are signed and consumed + // within the same test run; skew from a real IdP is not a factor here. + // Both slash forms are accepted because the SDK constructs the audience + // from the AS metadata URL, which may or may not carry a trailing slash + // depending on how the metadata endpoint was discovered. + await jose.jwtVerify(assertion, publicKey, { + audience: [withoutSlash, withSlash], + clockTolerance: 5 + }); + + this.checks.push({ + id: 'wif-assertion-verified', + name: 'WifAssertionVerified', + description: + 'Workload JWT assertion verified — signature, audience, and expiry are valid (iss not validated; keypair is run-scoped)', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + + const scopeList = body.scope ? body.scope.split(' ') : []; + if (scopeList.includes(WIF_TRIGGER_UNAUTHORIZED_SCOPE)) { + this.failedOnce = true; + return { + error: 'unauthorized_client', + errorDescription: 'Client not authorized for JWT-bearer grant' + }; + } + if (scopeList.includes(WIF_REJECTED_SCOPE)) { + this.failedOnce = true; + return { + error: 'invalid_scope', + errorDescription: + 'Requested scope is not permitted for this grant' + }; + } + return { + token: `test-token-${Date.now()}`, + scopes: scopeList + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + + if (e instanceof jose.errors.JWTExpired) { + this.checks.push({ + id: 'wif-assertion-expired', + name: 'WifAssertionExpired', + description: `JWT-bearer assertion is expired: ${msg}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_grant', + errorDescription: 'JWT assertion is expired' + }; + } + + // JWTExpired extends JWTClaimValidationFailed; check aud specifically so + // other claim failures (iss, nbf, etc.) fall through to malformed. + if ( + e instanceof jose.errors.JWTClaimValidationFailed && + e.claim === 'aud' + ) { + this.checks.push({ + id: 'wif-assertion-audience', + name: 'WifAssertionAudience', + description: `JWT-bearer assertion audience claim is invalid: ${msg}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_grant', + errorDescription: 'JWT assertion audience is invalid' + }; + } + + this.checks.push({ + id: 'wif-assertion-malformed', + name: 'WifAssertionMalformed', + description: `JWT-bearer assertion is malformed or has an invalid signature: ${msg}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + this.failedOnce = true; + return { + error: 'invalid_grant', + errorDescription: `JWT assertion verification failed: ${msg}` + }; + } + } + }); + + await this.authServer.start(authApp); + + const authServerUrl = this.authServer.getUrl(); + + const [validJwt, wrongAudienceJwt, expiredJwt] = await Promise.all([ + createWorkloadJwt({ + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: authServerUrl, + privateKey + }), + createWorkloadJwt({ + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: 'https://wrong.example', + privateKey + }), + createWorkloadJwt({ + issuer: WIF_ISSUER, + subject: WIF_SUBJECT, + audience: authServerUrl, + privateKey, + // Absolute epoch seconds in the past; jose treats a number as an absolute + // epoch timestamp, producing a token that is already expired. + expiresIn: '-60s' + }) + ]); + + const app = createServer( + ctx, + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { tokenVerifier } + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: WIF_CLIENT_ID, + valid_jwt: validJwt, + wrong_audience_jwt: wrongAudienceJwt, + expired_jwt: expiredJwt + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + if (!this.tokenRequestReceived) { + this.checks.push({ + id: 'wif-assertion-verified', + name: 'WifAssertionVerified', + description: 'Client did not make a JWT-bearer token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_1933_WIF + ] + }); + } + return this.checks; + } +} diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts new file mode 100644 index 0000000..6ccd0bf --- /dev/null +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -0,0 +1,625 @@ +import { describe, it, expect } from 'vitest'; +import { testScenarioContext } from '../../mock-server/testing'; +import { withRequiredDraftResultFields } from '../../mock-server'; +import { wireSchemaErrors } from '../../validation/wire-schema'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; +import { HttpStandardHeadersScenario } from './http-standard-headers'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario +} from './http-custom-headers'; +import type { BaseHttpScenario } from './http-base'; +import { RequestMetadataScenario } from './request-metadata'; +import { MRTRClientScenario } from './mrtr-client'; +import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref'; +import { SkillsNoPrefetchScenario } from './skills/no-prefetch'; +import { SkillsVerificationScenario } from './skills/verification'; + +/** + * Pins that the hand-rolled mock servers used by client-direction scenarios + * at 2026-07-28 return spec-valid results: every result carries `resultType`, + * and the cacheable list/read/discover results also carry `ttlMs` and + * `cacheScope`. Without these, a strictly-conforming client is failed by the + * suite's own non-conformant mock. The shared stateless mock is covered in + * src/mock-server/mock-server.test.ts. + */ + +const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +async function post( + url: string, + body: object, + headers: Record = {} +) { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body) + }); + return { status: r.status, body: await r.json() }; +} + +const CACHEABLE_FIELDS = { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' +}; + +const DISCOVER_FIELDS = { + ...CACHEABLE_FIELDS, + supportedVersions: [DRAFT_PROTOCOL_VERSION] +}; + +describe('hand-rolled mock servers serve server/discover (2026-07-28)', () => { + const cases = [ + { + name: 'http-standard-headers', + make: () => new HttpStandardHeadersScenario(), + capabilities: { tools: {}, resources: {}, prompts: {} } + }, + { + name: 'http-custom-headers', + make: () => new HttpCustomHeadersScenario(), + capabilities: { tools: {} } + }, + { + name: 'http-invalid-tool-headers', + make: () => new HttpInvalidToolHeadersScenario(), + capabilities: { tools: {} } + }, + { + name: 'sep-2322-client-request-state', + make: () => new MRTRClientScenario(), + capabilities: { tools: {} } + }, + { + name: 'json-schema-ref-no-deref', + make: () => new JsonSchemaRefDerefScenario(), + capabilities: { tools: {} } + } + ]; + + for (const c of cases) { + it(`${c.name} returns a valid DiscoverResult`, async () => { + const scenario = c.make(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const { status, body } = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(200); + expect(body.result).toMatchObject({ + ...DISCOVER_FIELDS, + capabilities: c.capabilities + }); + expect(body.result.serverInfo?.name).toBeTypeOf('string'); + } finally { + await scenario.stop(); + } + }); + } +}); + +describe('http-standard-headers mock results (2026-07-28)', () => { + it('carries the draft-required result members on every handled method', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const cases: Array<{ + method: string; + params?: object; + cacheable: boolean; + }> = [ + { method: 'initialize', cacheable: false }, + { method: 'tools/list', cacheable: true }, + { + method: 'tools/call', + params: { name: 'test_headers', arguments: {} }, + cacheable: false + }, + { method: 'resources/list', cacheable: true }, + { + method: 'resources/read', + params: { uri: 'file:///path/to/file%20name.txt' }, + cacheable: true + }, + // Not explicitly handled by the scenario — exercises the method-aware + // generic fallback, which must still add the caching hints and the + // required (empty) list member. + { method: 'resources/templates/list', cacheable: true }, + { method: 'prompts/list', cacheable: true }, + { + method: 'prompts/get', + params: { name: 'test_prompt' }, + cacheable: false + }, + { method: 'unknown/method', cacheable: false } + ]; + let id = 1; + for (const c of cases) { + const { status, body } = await post( + serverUrl, + { + jsonrpc: '2.0', + id: id++, + method: c.method, + params: c.params ?? {} + }, + { 'Mcp-Method': c.method } + ); + expect(status, c.method).toBe(200); + expect(body.result.resultType, c.method).toBe('complete'); + if (c.cacheable) { + expect(body.result, c.method).toMatchObject({ + ttlMs: 0, + cacheScope: 'private' + }); + } + } + } finally { + await scenario.stop(); + } + }); +}); + +describe('generic fallback answers unrouted list methods schema-valid (#474)', () => { + // http-standard-headers advertises `resources` but has no + // resources/templates/list route; the request falls through to the generic + // fallback in BaseHttpScenario. Before the fix the reply had no + // `resourceTemplates` member, so strictly-validating clients (e.g. + // cloudflare/agents MCPClientManager) dropped the connection before the + // scenario's header checks ran. + it('http-standard-headers answers resources/templates/list with a valid empty result', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const { status, body } = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'resources/templates/list', + params: {} + }, + { 'Mcp-Method': 'resources/templates/list' } + ); + expect(status).toBe(200); + expect(body.result).toEqual({ + ...CACHEABLE_FIELDS, + resourceTemplates: [] + }); + expect( + wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + body, + 'resources/templates/list' + ) + ).toEqual([]); + } finally { + await scenario.stop(); + } + }); + + // Every list-shaped method a scenario does not route must reach the + // fallback and come back with its required (empty) list member, on all + // three BaseHttpScenario subclasses. http-standard-headers routes the + // tools/resources/prompts lists itself; the custom-headers scenarios route + // only initialize and tools/*. tasks/list has no typed result in the draft + // schema (2025-11-25 only), so it is additionally validated at 2025-11-25. + const listMember = new Map([ + ['resources/list', 'resources'], + ['resources/templates/list', 'resourceTemplates'], + ['prompts/list', 'prompts'], + ['roots/list', 'roots'], + ['tasks/list', 'tasks'] + ]); + const subclasses: Array<{ + name: string; + make: () => BaseHttpScenario; + unrouted: string[]; + }> = [ + { + name: 'http-standard-headers', + make: () => new HttpStandardHeadersScenario(), + unrouted: ['resources/templates/list', 'roots/list', 'tasks/list'] + }, + { + name: 'http-custom-headers', + make: () => new HttpCustomHeadersScenario(), + unrouted: [...listMember.keys()] + }, + { + name: 'http-invalid-tool-headers', + make: () => new HttpInvalidToolHeadersScenario(), + unrouted: [...listMember.keys()] + } + ]; + + for (const s of subclasses) { + it(`${s.name} answers unrouted list methods with valid empty results`, async () => { + const scenario = s.make(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + let id = 1; + for (const method of s.unrouted) { + const { status, body } = await post( + serverUrl, + { jsonrpc: '2.0', id: id++, method, params: {} }, + { 'Mcp-Method': method } + ); + expect(status, method).toBe(200); + expect(body.result[listMember.get(method)!], method).toEqual([]); + expect( + wireSchemaErrors(DRAFT_PROTOCOL_VERSION, body, method), + method + ).toEqual([]); + if (method === 'tasks/list') { + expect(wireSchemaErrors('2025-11-25', body, method)).toEqual([]); + } + } + } finally { + await scenario.stop(); + } + }); + } + + // Method names that collide with Object.prototype must miss the empty-list + // lookup and get the plain generic result — an object-literal map here once + // returned Object.prototype functions, which JSON.stringify drops, leaving + // a reply with neither result nor error. + for (const s of subclasses) { + it(`${s.name} answers Object.prototype-colliding method names with a valid generic result`, async () => { + const scenario = s.make(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + let id = 1; + for (const method of [ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty' + ]) { + const { status, body } = await post( + serverUrl, + { jsonrpc: '2.0', id: id++, method, params: {} }, + { 'Mcp-Method': method } + ); + expect(status, method).toBe(200); + expect(body.result, method).toEqual({ resultType: 'complete' }); + expect( + wireSchemaErrors(DRAFT_PROTOCOL_VERSION, body, method), + method + ).toEqual([]); + } + } finally { + await scenario.stop(); + } + }); + } + + // Regression pin: the pre-fix fallback payload (required draft fields only, + // no list member) is rejected by the spec schema — proves the assertions + // above have teeth. + it('rejects the pre-fix bare fallback result for resources/templates/list', () => { + const preFix = { + jsonrpc: '2.0', + id: 1, + result: withRequiredDraftResultFields('resources/templates/list', {}) + }; + const errors = wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + preFix, + 'resources/templates/list' + ); + expect(errors.join('\n')).toContain( + "must have required property 'resourceTemplates'" + ); + }); +}); + +describe('http-custom-headers mock results (2026-07-28)', () => { + it('carries the draft-required result members', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }); + expect(list.body.result).toMatchObject({ + resultType: 'complete', + cacheScope: 'private' + }); + expect(list.body.result.ttlMs).toBeGreaterThan(0); + + const call = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'test_custom_headers', + arguments: { region: 'us-east', priority: 1, query: 'q' } + } + }); + expect(call.body.result.resultType).toBe('complete'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('http-invalid-tool-headers mock results (2026-07-28)', () => { + it('carries the draft-required result members', async () => { + const scenario = new HttpInvalidToolHeadersScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const call = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'valid_tool', arguments: { region: 'us-east' } } + }); + expect(call.body.result.resultType).toBe('complete'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('request-metadata mock results (2026-07-28)', () => { + it('carries the draft-required result members after the simulated rejection', async () => { + const scenario = new RequestMetadataScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + const headers = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; + try { + // The first request is always answered with the simulated -32022 + // rejection (retry probe); results are served from the second on. + const first = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(first.status).toBe(400); + + const discover = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 2, + method: 'server/discover', + params: { _meta: meta } + }, + headers + ); + expect(discover.body.result).toMatchObject(CACHEABLE_FIELDS); + + const list = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/list', + params: { _meta: meta } + }, + headers + ); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const call = await post( + serverUrl, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { _meta: meta, name: 'x' } + }, + headers + ); + expect(call.body.result.resultType).toBe('complete'); + + const other = await post( + serverUrl, + { jsonrpc: '2.0', id: 5, method: 'ping', params: { _meta: meta } }, + headers + ); + expect(other.body.result.resultType).toBe('complete'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('sep-2322-client-request-state mock results (2026-07-28)', () => { + it('carries the draft-required result members on conformant results and keeps the deliberate omission', async () => { + const scenario = new MRTRClientScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + }); + expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + + const initial = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'test_mrtr_echo_state', arguments: {} } + }); + expect(initial.body.result.resultType).toBe('input_required'); + + const retry = await post(serverUrl, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'test_mrtr_echo_state', + arguments: {}, + inputResponses: { confirm: { action: 'accept' } }, + requestState: initial.body.result.requestState + } + }); + expect(retry.body.result.resultType).toBe('complete'); + + const unrelated = await post(serverUrl, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'test_mrtr_unrelated', arguments: {} } + }); + expect(unrelated.body.result.resultType).toBe('complete'); + + // The default-resultType probe deliberately omits resultType — that is + // the check's stimulus and must not be "fixed". + const noResultType = await post(serverUrl, { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'test_mrtr_no_result_type', arguments: {} } + }); + expect(noResultType.body.result.content).toBeDefined(); + expect(noResultType.body.result).not.toHaveProperty('resultType'); + } finally { + await scenario.stop(); + } + }); +}); + +describe('sep-2640 skills client mock results', () => { + const skillsScenarios: Array<{ + name: string; + make: () => BaseHttpScenario; + }> = [ + { + name: 'sep-2640-client-no-prefetch', + make: () => new SkillsNoPrefetchScenario() + }, + { + name: 'sep-2640-client-verify-digest', + make: () => new SkillsVerificationScenario('digest') + }, + { + name: 'sep-2640-client-verify-size', + make: () => new SkillsVerificationScenario('size') + }, + { + name: 'sep-2640-client-verify-frontmatter', + make: () => new SkillsVerificationScenario('frontmatter') + } + ]; + + for (const s of skillsScenarios) { + it(`${s.name} carries the draft-required members on skills/list and resources/read`, async () => { + const scenario = s.make(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + let id = 1; + for (const [method, params] of [ + ['skills/list', {}], + ['resources/read', { uri: 'skill://pdf-processing/SKILL.md' }] + ] as const) { + const { status, body } = await post( + serverUrl, + { + jsonrpc: '2.0', + id: id++, + method, + params: { ...params, _meta: meta } + }, + { + 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION, + 'Mcp-Method': method + } + ); + expect(status, method).toBe(200); + expect(body.result, method).toMatchObject(CACHEABLE_FIELDS); + if (method === 'resources/read') { + expect( + wireSchemaErrors(DRAFT_PROTOCOL_VERSION, body, method), + method + ).toEqual([]); + } + } + } finally { + await scenario.stop(); + } + }); + + // The runner hands these scenarios' clients 2025-11-25 unless + // --spec-version says otherwise, and the extension has no 2026-07-28 + // dependency, so the mock answers at the version the client asked for. + it(`${s.name} answers initialize at the requested protocol version`, async () => { + const scenario = s.make(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + let id = 1; + for (const protocolVersion of ['2025-11-25', DRAFT_PROTOCOL_VERSION]) { + const { status, body } = await post(serverUrl, { + jsonrpc: '2.0', + id: id++, + method: 'initialize', + params: { + protocolVersion, + capabilities: {}, + clientInfo: { name: 'test', version: '1.0' } + } + }); + expect(status, protocolVersion).toBe(200); + expect(body.result.protocolVersion, protocolVersion).toBe( + protocolVersion + ); + } + } finally { + await scenario.stop(); + } + }); + } +}); diff --git a/src/scenarios/client/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index 73bc07c..d417029 100644 --- a/src/scenarios/client/elicitation-defaults.ts +++ b/src/scenarios/client/elicitation-defaults.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * SEP-1034: Elicitation defaults test * Validates that clients properly apply default values for omitted fields @@ -11,9 +12,9 @@ import { ListToolsRequestSchema, ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck, SpecVersion } from '../../types'; +import type { Scenario, ConformanceCheck } from '../../types'; import express, { Request, Response } from 'express'; -import { ScenarioUrls } from '../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../types'; import { createRequestLogger } from '../request-logger'; import { randomUUID } from 'crypto'; @@ -474,7 +475,10 @@ function createServer(checks: ConformanceCheck[]): { export class ElicitationClientDefaultsScenario implements Scenario { name = 'elicitation-sep1034-client-defaults'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Tests client applies default values for omitted elicitation fields (SEP-1034)'; private app: express.Application | null = null; @@ -482,7 +486,7 @@ export class ElicitationClientDefaultsScenario implements Scenario { private checks: ConformanceCheck[] = []; private cleanup: (() => void) | null = null; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const { app, cleanup } = createServer(this.checks); this.app = app; diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts new file mode 100644 index 0000000..6751ded --- /dev/null +++ b/src/scenarios/client/http-base.ts @@ -0,0 +1,215 @@ +import { + withRequiredDraftResultFields, + type ScenarioContext +} from '../../mock-server'; +/** + * Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios. + * + * A scenario that needs to act as a Streamable-HTTP MCP server, inspect + * incoming client requests, and emit ConformanceChecks should extend this + * class and implement handlePost() + getChecks(). start()/stop() and the + * GET/DELETE/body-parse boilerplate are handled here. + */ + +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + ScenarioSource, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; + +/** + * Schema-valid empty results for the standard list-shaped methods, keyed by + * method. A Map, not an object literal, so a method name that collides with + * Object.prototype ("constructor", "toString", ...) misses instead of + * returning a function. Merged into the generic fallback so a list method a + * scenario does not route still carries its required list member — a bare + * `{}` fails schema validation and strict clients drop the connection before + * the scenario's real checks run (#474). `tasks/list` exists only at + * 2025-11-25 (the draft schema has no ListTasksResult); the empty member is + * harmless on the draft wire. Non-list results (tools/call, resources/read, + * prompts/get, ...) have no meaningful empty default and keep the bare + * stamped fallback, so a route a scenario forgot surfaces instead of being + * masked. + */ +const EMPTY_LIST_RESULTS: ReadonlyMap = new Map([ + ['tools/list', { tools: [] }], + ['resources/list', { resources: [] }], + ['resources/templates/list', { resourceTemplates: [] }], + ['prompts/list', { prompts: [] }], + ['roots/list', { roots: [] }], + ['tasks/list', { tasks: [] }] +]); + +export abstract class BaseHttpScenario implements Scenario { + abstract name: string; + abstract description: string; + readonly source: ScenarioSource = { introducedIn: DRAFT_PROTOCOL_VERSION }; + allowClientError?: boolean; + + protected server: http.Server | null = null; + protected checks: ConformanceCheck[] = []; + protected port: number = 0; + protected sessionId: string = `session-${Date.now()}`; + + async start(_ctx: ScenarioContext): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + this.server.on('error', reject); + this.server.listen(0, () => { + const address = this.server!.address(); + if (address && typeof address === 'object') { + this.port = address.port; + resolve({ serverUrl: `http://localhost:${this.port}` }); + } else { + reject(new Error('Failed to get server address')); + } + }); + }); + } + + async stop(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + this.server.close((err) => { + if (err) reject(err); + else { + this.server = null; + resolve(); + } + }); + } else { + resolve(); + } + }); + } + + abstract getChecks(): ConformanceCheck[]; + + protected handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'mcp-session-id': this.sessionId + }); + res.write('data: \n\n'); + return; + } + if (req.method === 'DELETE') { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== 'POST') { + res.writeHead(405); + res.end('Method Not Allowed'); + return; + } + + // Decode the stream as UTF-8 so multi-byte characters that straddle a + // chunk boundary aren't corrupted by per-chunk Buffer.toString(). + req.setEncoding('utf8'); + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + const request = JSON.parse(body); + if (request.method === 'server/discover') { + this.sendDiscover(res, request); + return; + } + this.handlePost(req, res, request); + } catch (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32700, message: `Parse error: ${error}` } + }) + ); + } + }); + } + + protected abstract handlePost( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void; + + protected sendJson(res: http.ServerResponse, body: object): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + res.end(JSON.stringify(body)); + } + + /** + * Capabilities advertised to a 2026-07-28 client via `server/discover` (and + * defaulted in the legacy `initialize` reply). Subclasses override to match + * the methods they actually serve. + */ + protected discoverCapabilities(): object { + return { tools: {} }; + } + + protected sendDiscover(res: http.ServerResponse, request: any): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: withRequiredDraftResultFields('server/discover', { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: this.discoverCapabilities(), + serverInfo: { name: this.name + '-server', version: '1.0.0' } + }) + }); + } + + protected sendInitialize( + res: http.ServerResponse, + request: any, + capabilities: object = this.discoverCapabilities() + ): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + protocolVersion: DRAFT_PROTOCOL_VERSION, + serverInfo: { name: this.name + '-server', version: '1.0.0' }, + capabilities + } + }); + } + + protected sendNotificationAck(res: http.ServerResponse): void { + res.writeHead(202); + res.end(); + } + + protected sendGenericResult(res: http.ServerResponse, request: any): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + // Method-aware so cacheable methods that fall through to the generic + // reply still carry the ttlMs/cacheScope the draft revision requires, + // and unrouted standard list methods carry their required list member. + result: withRequiredDraftResultFields( + request.method, + EMPTY_LIST_RESULTS.get(request.method) ?? {} + ) + }); + } +} diff --git a/src/scenarios/client/http-custom-headers.test.ts b/src/scenarios/client/http-custom-headers.test.ts new file mode 100644 index 0000000..2834191 --- /dev/null +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -0,0 +1,329 @@ +import { testScenarioContext } from '../../mock-server/testing'; +import { describe, it, expect } from 'vitest'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario, + CUSTOM_HEADERS_DECLARED_CHECK_IDS, + INVALID_TOOL_DECLARED_CHECK_IDS +} from './http-custom-headers'; + +/** + * Pins the SEP-2243 requirement-level check IDs emitted by the custom-header + * client scenarios so the traceability manifest's join (yaml `check:` == + * emitted id) cannot silently drift again. Each declared ID must be emitted + * on every run — exercised, backfilled as FAILURE, or SKIPPED — never absent. + */ + +async function post( + serverUrl: string, + body: object, + headers: Record = {} +): Promise<{ status: number; body: any }> { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); + return { status: response.status, body: await response.json() }; +} + +async function postJson(serverUrl: string, body: object): Promise { + const res = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(body) + }); + return res.json(); +} + +function idsOf(checks: { id: string }[]): Set { + return new Set(checks.map((c) => c.id)); +} + +function statusesFor( + checks: { id: string; status: string }[], + id: string +): string[] { + return checks.filter((c) => c.id === id).map((c) => c.status); +} + +describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { + it('emits exactly the declared requirement IDs as FAILURE when the client never connects', async () => { + const scenario = new HttpCustomHeadersScenario(); + await scenario.start(testScenarioContext()); + try { + const checks = scenario.getChecks(); + expect(idsOf(checks)).toEqual(new Set(CUSTOM_HEADERS_DECLARED_CHECK_IDS)); + for (const check of checks) { + expect(check.status).toBe('FAILURE'); + } + } finally { + await scenario.stop(); + } + }); + + it('maps each parameter kind to its requirement ID on a conforming tool call', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const nonAscii = 'Hello, 世界'; + const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64'); + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_custom_headers', + arguments: { + region: 'us-west1', + priority: 42, + non_ascii_val: nonAscii, + query: 'SELECT 1' + } + } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers', + 'Mcp-Param-Region': 'us-west1', + 'Mcp-Param-Priority': '42', + 'Mcp-Param-NonAscii': `=?base64?${nonAsciiB64}?=` + } + ); + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'test_custom_headers_null', + arguments: { + region: 'us-east1', + priority: 1, + verbose: null, + query: 'SELECT 1' + } + } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers_null', + 'Mcp-Param-Region': 'us-east1', + 'Mcp-Param-Priority': '1' + // Mcp-Param-Verbose deliberately omitted: value is null + } + ); + + const checks = scenario.getChecks(); + for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { + const statuses = statusesFor(checks, id); + expect(statuses.length, id).toBeGreaterThan(0); + expect(statuses, id).not.toContain('FAILURE'); + } + } finally { + await scenario.stop(); + } + }); + + it('FAILs client-mirrors-designated-params when an annotated header is missing', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_custom_headers', + arguments: { region: 'us-west1', priority: 42, query: 'SELECT 1' } + } + }, + { + // Mcp-Param-Region deliberately omitted + 'Mcp-Param-Priority': '42' + } + ); + const checks = scenario.getChecks(); + expect( + statusesFor(checks, 'sep-2243-client-mirrors-designated-params') + ).toContain('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('serves a fresh tools/list TTL before requiring schema-derived custom headers', async () => { + const scenario = new HttpCustomHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const toolsList = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list' + }); + const schemaIsFresh = toolsList.body.result.ttlMs > 0; + + const nonAscii = 'Hello, 世界'; + const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64'); + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'test_custom_headers', + arguments: { + region: 'us-west1', + priority: 42, + non_ascii_val: nonAscii, + query: 'SELECT 1' + } + } + }, + schemaIsFresh + ? { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers', + 'Mcp-Param-Region': 'us-west1', + 'Mcp-Param-Priority': '42', + 'Mcp-Param-NonAscii': `=?base64?${nonAsciiB64}?=` + } + : {} + ); + await post( + serverUrl, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'test_custom_headers_null', + arguments: { + region: 'us-east1', + priority: 1, + verbose: null, + query: 'SELECT 1' + } + } + }, + schemaIsFresh + ? { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'test_custom_headers_null', + 'Mcp-Param-Region': 'us-east1', + 'Mcp-Param-Priority': '1' + } + : {} + ); + + expect(toolsList.body.result.ttlMs).toBeGreaterThan(0); + const checks = scenario.getChecks(); + for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { + const statuses = statusesFor(checks, id); + expect(statuses.length, id).toBeGreaterThan(0); + expect(statuses, id).not.toContain('FAILURE'); + } + } finally { + await scenario.stop(); + } + }); +}); + +describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => { + it('emits every x-mcp-header constraint ID, SUCCESS when only valid_tool is called', async () => { + const scenario = new HttpInvalidToolHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'valid_tool', arguments: { region: 'us-west1' } } + }); + const checks = scenario.getChecks(); + for (const id of INVALID_TOOL_DECLARED_CHECK_IDS) { + const statuses = statusesFor(checks, id); + expect(statuses.length, id).toBeGreaterThan(0); + expect(statuses, id).not.toContain('FAILURE'); + } + } finally { + await scenario.stop(); + } + }); + + it('FAILs the violated constraint ID when the client calls an invalid tool', async () => { + const scenario = new HttpInvalidToolHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'invalid_empty_header', arguments: { value: 'x' } } + }); + const checks = scenario.getChecks(); + expect(statusesFor(checks, 'sep-2243-x-mcp-header-not-empty')).toContain( + 'FAILURE' + ); + // The other constraints were not violated. + expect( + statusesFor(checks, 'sep-2243-x-mcp-header-charset') + ).not.toContain('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('FAILs primitive-only when the client calls the number-typed tool', async () => { + const scenario = new HttpInvalidToolHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const listed = await postJson(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list' + }); + // SEP-2243 permits x-mcp-header only on integer/string/boolean, so the + // number-typed tool must be served for the client to reject it. + const numberTool = listed.result.tools.find( + (t: { name: string }) => t.name === 'invalid_number_header' + ); + expect(numberTool?.inputSchema.properties.score).toEqual({ + type: 'number', + 'x-mcp-header': 'Score' + }); + + await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'invalid_number_header', arguments: { score: 1.5 } } + }); + const checks = scenario.getChecks(); + expect( + statusesFor(checks, 'sep-2243-x-mcp-header-primitive-only') + ).toContain('FAILURE'); + // The other constraints were not violated. + expect( + statusesFor(checks, 'sep-2243-x-mcp-header-not-empty') + ).not.toContain('FAILURE'); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts new file mode 100644 index 0000000..062efd7 --- /dev/null +++ b/src/scenarios/client/http-custom-headers.ts @@ -0,0 +1,988 @@ +import type { ScenarioContext } from '../../mock-server'; +/** + * HTTP Custom Headers conformance test scenario for MCP clients (SEP-2243) + * + * Tests that clients correctly handle the `x-mcp-header` extension property: + * 1. Mirror annotated tool parameter values into `Mcp-Param-{Name}` headers + * 2. Apply correct value encoding (plain ASCII, Base64 for non-ASCII) + * 3. Reject tool definitions with invalid `x-mcp-header` annotations + * + * This is a Scenario (acts as a test server that inspects incoming requests + * from the client under test). + */ + +import http from 'http'; +import { ScenarioUrls, ConformanceCheck } from '../../types.js'; +import { BaseHttpScenario } from './http-base.js'; + +const SPEC_REFERENCE_CUSTOM = { + id: 'SEP-2243-Custom-Headers', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#custom-headers-from-tool-parameters' +}; + +const SPEC_REFERENCE_ENCODING = { + id: 'SEP-2243-Value-Encoding', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding' +}; + +const SPEC_REFERENCE_TOOL_DEF = { + id: 'SEP-2243-x-mcp-header', + url: 'https://modelcontextprotocol.io/specification/draft/server/tools#x-mcp-header' +}; + +/** + * Every requirement-level check ID HttpCustomHeadersScenario can emit + * (SEP-2243). Declared-but-unemitted checks are backfilled as FAILURE by + * getChecks() — same pattern as request-metadata.ts — so the emitted ID set + * is stable for traceability even when the client never exercises the + * feature. + */ +export const CUSTOM_HEADERS_DECLARED_CHECK_IDS = [ + 'sep-2243-client-supports-custom-headers', + 'sep-2243-client-mirrors-designated-params', + 'sep-2243-client-encode-values', + 'sep-2243-client-base64-unsafe', + 'sep-2243-client-omit-null' +] as const; + +/** + * Every requirement-level check ID HttpInvalidToolHeadersScenario can emit + * (SEP-2243). + */ +export const INVALID_TOOL_DECLARED_CHECK_IDS = [ + 'sep-2243-client-reject-invalid-tool', + 'sep-2243-x-mcp-header-not-empty', + 'sep-2243-x-mcp-header-charset', + 'sep-2243-x-mcp-header-unique', + 'sep-2243-x-mcp-header-primitive-only' +] as const; + +/** + * Invalid tool definitions served by HttpInvalidToolHeadersScenario, keyed by + * tool name, mapped to the SEP-2243 x-mcp-header constraint each one + * violates. The check for "client did not call this tool" is emitted under + * the constraint's requirement ID so each constraint traces to a check. + */ +const INVALID_TOOL_CONSTRAINT_IDS: Record = { + invalid_empty_header: 'sep-2243-x-mcp-header-not-empty', + invalid_object_header: 'sep-2243-x-mcp-header-primitive-only', + invalid_array_header: 'sep-2243-x-mcp-header-primitive-only', + invalid_null_header: 'sep-2243-x-mcp-header-primitive-only', + invalid_number_header: 'sep-2243-x-mcp-header-primitive-only', + invalid_duplicate_same_case: 'sep-2243-x-mcp-header-unique', + invalid_duplicate_diff_case: 'sep-2243-x-mcp-header-unique', + invalid_space_in_name: 'sep-2243-x-mcp-header-charset', + invalid_colon_in_name: 'sep-2243-x-mcp-header-charset', + invalid_non_ascii_name: 'sep-2243-x-mcp-header-charset', + invalid_control_char_name: 'sep-2243-x-mcp-header-charset' +}; + +/** + * Decodes a header value that may be Base64-encoded. + * Base64-encoded values use the format: =?base64?{Base64EncodedValue}?= + */ +function decodeHeaderValue(value: string): string { + const base64Match = value.match(/^=\?base64\?(.*)\?=$/); + if (base64Match) { + return Buffer.from(base64Match[1], 'base64').toString('utf-8'); + } + return value; +} + +/** + * Check if a value needs Base64 encoding per the spec: + * - Non-ASCII characters + * - Control characters + * - Leading/trailing whitespace + */ +function needsBase64Encoding(value: string): boolean { + // Check for non-ASCII or control characters + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + // Allow space (0x20) and tab (0x09) only inside values, not at edges + if (code === 0x09) return true; // tab always needs encoding + if (code < 0x20) return true; // other control chars + if (code > 0x7e) return true; // non-ASCII + } + } + // Check for leading/trailing whitespace + if (value !== value.trim()) return true; + return false; +} + +/** + * Checks if a raw header value is properly encoded for a body value that + * needs Base64 encoding. Returns null if valid, error string if invalid. + */ +function validateEncodedHeader( + rawHeader: string, + bodyValue: string, + valueType?: string +): string | null { + if (needsBase64Encoding(bodyValue)) { + // Value requires Base64 encoding + const base64Match = rawHeader.match(/^=\?base64\?(.*)\?=$/); + + if (!base64Match) { + return `Value '${bodyValue}' requires Base64 encoding but header was sent as plain: '${rawHeader}'`; + } + const decoded = Buffer.from(base64Match[1], 'base64').toString('utf-8'); + if (valueType === 'number' || valueType === 'integer') { + return compareNumericValues(decoded, bodyValue); + } + if (decoded !== bodyValue) { + return `Base64-decoded header value '${decoded}' does not match body value '${bodyValue}'`; + } + return null; + } + // Plain ASCII - compare directly (after decoding if Base64 was used) + const decoded = decodeHeaderValue(rawHeader); + if (valueType === 'number' || valueType === 'integer') { + return compareNumericValues(decoded, bodyValue); + } + if (decoded !== bodyValue) { + return `Header value '${decoded}' (raw: '${rawHeader}') does not match body value '${bodyValue}'`; + } + return null; +} + +/** + * Compare two string representations of numbers numerically. + * For integers, requires exact match. For decimals, allows + * a tolerance of 1e-9 to account for cross-SDK floating point + * representation differences. + */ +function compareNumericValues( + headerValue: string, + bodyValue: string +): string | null { + const headerNum = Number(headerValue); + const bodyNum = Number(bodyValue); + if (isNaN(headerNum) || isNaN(bodyNum)) { + return `Non-numeric value in number comparison: header='${headerValue}', body='${bodyValue}'`; + } + if (Number.isInteger(bodyNum)) { + // Integer: require exact numeric match (e.g. 42 === 42.0) + if (headerNum !== bodyNum) { + return `Numeric header value ${headerNum} does not match body value ${bodyNum}`; + } + } else { + // Decimal: allow tolerance of 1e-9 + if (Math.abs(headerNum - bodyNum) > 1e-9) { + return `Numeric header value ${headerNum} does not match body value ${bodyNum} (difference ${Math.abs(headerNum - bodyNum)} exceeds tolerance 1e-9)`; + } + } + return null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// HttpCustomHeadersScenario - tests that clients mirror x-mcp-header params +// ───────────────────────────────────────────────────────────────────────────── + +export class HttpCustomHeadersScenario extends BaseHttpScenario { + name = 'http-custom-headers'; + description = + 'Tests that client mirrors x-mcp-header tool parameters into Mcp-Param headers with correct encoding (SEP-2243)'; + + private toolCallReceived: boolean = false; + private nullToolCallReceived: boolean = false; + + async start(_ctx: ScenarioContext): Promise { + const urls = await super.start(_ctx); + // Pass test values via context for encoding edge cases. + // The conformance client should use these values when calling test_custom_headers. + urls.context = { + toolCalls: [ + { + name: 'test_custom_headers', + arguments: { + region: 'us-west1', + priority: 42, + verbose: false, + debug: true, + empty_val: '', + method_val: 'test-method', + float_val: 3.14159, + non_ascii_val: 'Hello, 世界', + whitespace_val: ' padded ', + leading_space_val: ' us-west1', + trailing_space_val: 'us-west1 ', + internal_space_val: 'us west 1', + control_char_val: 'line1\nline2', + crlf_val: 'line1\r\nline2', + tab_val: '\tindented', + query: 'SELECT * FROM users' + } + }, + { + name: 'test_custom_headers_null', + arguments: { + region: 'us-east1', + priority: 1, + verbose: null, + query: 'SELECT 1' + } + } + ] + }; + return urls; + } + + getChecks(): ConformanceCheck[] { + // Declared-but-unemitted -> FAILURE (request-metadata.ts pattern). Keeps + // the emitted ID set stable for traceability even when the client never + // calls the annotated tools. The `some()` guard makes this idempotent. + for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) { + if (this.checks.some((c) => c.id === id)) continue; + const missingNullCall = + id === 'sep-2243-client-omit-null' && !this.nullToolCallReceived; + this.checks.push({ + id, + name: 'NotObserved', + description: `Declared check ${id} was never emitted`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: missingNullCall + ? 'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.' + : this.toolCallReceived + ? 'Check was not observed: no tool call exercised it.' + : 'Client did not send a tools/call request for test_custom_headers.', + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + return this.checks; + } + + protected handlePost( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + if (request.method === 'initialize') { + this.sendInitialize(res, request); + } else if (request.method === 'tools/list') { + this.handleToolsList(res, request); + } else if (request.method === 'tools/call') { + this.handleToolsCall(req, res, request); + } else if (request.id === undefined) { + this.sendNotificationAck(res); + } else { + this.sendGenericResult(res, request); + } + } + + private handleToolsList(res: http.ServerResponse, request: any): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 300000, + cacheScope: 'private', + tools: [ + { + name: 'test_custom_headers', + description: + 'A tool with x-mcp-header annotations to test custom header mirroring and encoding', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + description: 'Plain ASCII string value', + 'x-mcp-header': 'Region' + }, + priority: { + type: 'integer', + description: 'Integer numeric value', + 'x-mcp-header': 'Priority' + }, + verbose: { + type: 'boolean', + description: 'Boolean value', + 'x-mcp-header': 'Verbose' + }, + debug: { + type: 'boolean', + description: 'Boolean true value', + 'x-mcp-header': 'Debug' + }, + empty_val: { + type: 'string', + description: 'Empty string value', + 'x-mcp-header': 'EmptyVal' + }, + method_val: { + type: 'string', + description: + 'Value for header named "Method" — tests that x-mcp-header "Method" produces Mcp-Param-Method (not Mcp-Method)', + 'x-mcp-header': 'Method' + }, + float_val: { + type: 'number', + description: + 'Floating point value — no x-mcp-header annotation, should not be mirrored' + }, + non_ascii_val: { + type: 'string', + description: + 'Non-ASCII string value — requires Base64 encoding', + 'x-mcp-header': 'NonAscii' + }, + whitespace_val: { + type: 'string', + description: + 'String with leading/trailing whitespace — requires Base64 encoding', + 'x-mcp-header': 'Whitespace' + }, + leading_space_val: { + type: 'string', + description: + 'String with leading space only — requires Base64 encoding', + 'x-mcp-header': 'LeadingSpace' + }, + trailing_space_val: { + type: 'string', + description: + 'String with trailing space only — requires Base64 encoding', + 'x-mcp-header': 'TrailingSpace' + }, + internal_space_val: { + type: 'string', + description: + 'String with internal spaces only — plain ASCII, no Base64', + 'x-mcp-header': 'InternalSpace' + }, + control_char_val: { + type: 'string', + description: + 'String with control characters — requires Base64 encoding', + 'x-mcp-header': 'ControlChar' + }, + crlf_val: { + type: 'string', + description: + 'String with carriage return and line feed — requires Base64 encoding', + 'x-mcp-header': 'CrLf' + }, + tab_val: { + type: 'string', + description: + 'String with leading tab — requires Base64 encoding', + 'x-mcp-header': 'Tab' + }, + query: { + type: 'string', + description: + 'No x-mcp-header annotation - should not be mirrored' + } + }, + required: ['region', 'priority', 'query'] + } + }, + { + name: 'test_custom_headers_null', + description: + 'A tool for testing null/omitted x-mcp-header parameter handling', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + description: 'Plain ASCII string value', + 'x-mcp-header': 'Region' + }, + priority: { + type: 'integer', + description: 'Integer numeric value', + 'x-mcp-header': 'Priority' + }, + verbose: { + type: 'boolean', + description: 'Boolean value — will be null to test omission', + 'x-mcp-header': 'Verbose' + }, + query: { + type: 'string', + description: 'No x-mcp-header annotation' + } + }, + required: ['region', 'priority', 'query'] + } + } + ] + } + }); + } + + private handleToolsCall( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + const toolName = request.params?.name; + const args = request.params?.arguments || {}; + + if (toolName === 'test_custom_headers') { + this.toolCallReceived = true; + + // SEP-2243 "MCP clients MUST support this feature": observable as the + // client calling the annotated tool with at least one mirrored + // Mcp-Param-* header. The per-parameter checks below then validate each + // mirrored value individually. + const hasAnyParamHeader = Object.keys(req.headers).some((h) => + h.startsWith('mcp-param-') + ); + this.checks.push({ + id: 'sep-2243-client-supports-custom-headers', + name: 'ClientSupportsCustomHeaders', + description: + 'Client supports custom headers: calls the x-mcp-header annotated tool and mirrors at least one parameter', + status: hasAnyParamHeader ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: hasAnyParamHeader + ? undefined + : 'Client called test_custom_headers but sent no Mcp-Param-* headers.', + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + + // Check Mcp-Param-Region header (plain ASCII string) + this.checkParamHeader(req, 'Region', args.region, 'string'); + + // Check Mcp-Param-Priority header (integer) + this.checkParamHeader(req, 'Priority', args.priority, 'integer'); + + // Check Mcp-Param-Verbose header (boolean value) + // checkParamHeader already FAILs on missing header, so this also covers + // "optional parameter present → client MUST include header" without a + // separate check id. + if (args.verbose !== undefined && args.verbose !== null) { + this.checkParamHeader(req, 'Verbose', args.verbose, 'boolean'); + } + + // Check Mcp-Param-Debug header (boolean true value) + if (args.debug !== undefined && args.debug !== null) { + this.checkParamHeader(req, 'Debug', args.debug, 'boolean'); + } + + // Check Mcp-Param-EmptyVal header (empty string → empty header value) + if (args.empty_val !== undefined && args.empty_val !== null) { + this.checkParamHeader(req, 'EmptyVal', args.empty_val, 'string'); + } + + // Check Mcp-Param-Method header (x-mcp-header "Method" → Mcp-Param-Method, NOT Mcp-Method) + if (args.method_val !== undefined && args.method_val !== null) { + this.checkParamHeader(req, 'Method', args.method_val, 'string'); + } + + // float_val is intentionally unannotated: SEP-2243 forbids x-mcp-header on + // `number`-typed properties, so it is served without one. Assert no header + // was sent — same "designated params only" rule as the `query` check below. + const floatHeader = req.headers['mcp-param-floatval'] as + | string + | undefined; + this.checks.push({ + id: 'sep-2243-client-mirrors-designated-params', + name: 'ClientCustomHeaderNoMirrorNumber', + description: + 'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header (number-typed float_val is served unannotated per SEP-2243)', + status: floatHeader === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + floatHeader !== undefined + ? `Found unexpected Mcp-Param-FloatVal header '${floatHeader}' for an unannotated number parameter` + : undefined, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + + // Check Mcp-Param-NonAscii header (requires Base64 encoding) + if (args.non_ascii_val !== undefined && args.non_ascii_val !== null) { + this.checkParamHeader(req, 'NonAscii', args.non_ascii_val, 'string'); + } + + // Check Mcp-Param-Whitespace header (leading/trailing whitespace → Base64) + if (args.whitespace_val !== undefined && args.whitespace_val !== null) { + this.checkParamHeader(req, 'Whitespace', args.whitespace_val, 'string'); + } + + // Check Mcp-Param-LeadingSpace header (leading space only → Base64) + if ( + args.leading_space_val !== undefined && + args.leading_space_val !== null + ) { + this.checkParamHeader( + req, + 'LeadingSpace', + args.leading_space_val, + 'string' + ); + } + + // Check Mcp-Param-TrailingSpace header (trailing space only → Base64) + if ( + args.trailing_space_val !== undefined && + args.trailing_space_val !== null + ) { + this.checkParamHeader( + req, + 'TrailingSpace', + args.trailing_space_val, + 'string' + ); + } + + // Check Mcp-Param-InternalSpace header (internal spaces only → plain ASCII, no Base64) + if ( + args.internal_space_val !== undefined && + args.internal_space_val !== null + ) { + this.checkParamHeader( + req, + 'InternalSpace', + args.internal_space_val, + 'string' + ); + } + + // Check Mcp-Param-ControlChar header (control characters → Base64) + if ( + args.control_char_val !== undefined && + args.control_char_val !== null + ) { + this.checkParamHeader( + req, + 'ControlChar', + args.control_char_val, + 'string' + ); + } + + // Check Mcp-Param-CrLf header (carriage return + line feed → Base64) + if (args.crlf_val !== undefined && args.crlf_val !== null) { + this.checkParamHeader(req, 'CrLf', args.crlf_val, 'string'); + } + + // Check Mcp-Param-Tab header (leading tab → Base64) + if (args.tab_val !== undefined && args.tab_val !== null) { + this.checkParamHeader(req, 'Tab', args.tab_val, 'string'); + } + + // Check that 'query' (no x-mcp-header) is NOT mirrored — the negative + // half of "mirror the *designated* parameter values". + const queryHeader = req.headers['mcp-param-query'] as string | undefined; + this.checks.push({ + id: 'sep-2243-client-mirrors-designated-params', + name: 'ClientCustomHeaderNoMirrorUnannotated', + description: + 'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header', + status: queryHeader === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + queryHeader !== undefined + ? `Found unexpected Mcp-Param-Query header '${queryHeader}' for unannotated parameter` + : undefined, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } else if (toolName === 'test_custom_headers_null') { + this.nullToolCallReceived = true; + + // When value is null or not provided, client MUST omit the header + const verboseHeader = req.headers['mcp-param-verbose'] as + | string + | undefined; + this.checks.push({ + id: 'sep-2243-client-omit-null', + name: 'ClientCustomHeaderOmitNull', + description: + 'Client MUST omit Mcp-Param header when parameter value is null or not provided', + status: verboseHeader === undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + verboseHeader !== undefined + ? `Mcp-Param-Verbose should be omitted when null/undefined, but got '${verboseHeader}'` + : undefined, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'Custom headers test completed' }] + } + }); + } + + private checkParamHeader( + req: http.IncomingMessage, + headerName: string, + bodyValue: any, + valueType: string + ): void { + const headerKey = `mcp-param-${headerName.toLowerCase()}`; + const rawHeaderValue = req.headers[headerKey] as string | undefined; + + if (bodyValue === undefined || bodyValue === null) return; + + const errors: string[] = []; + + if (rawHeaderValue === undefined) { + errors.push( + `Missing Mcp-Param-${headerName} header. Client MUST include headers for x-mcp-header parameters.` + ); + } else { + // Convert body value to expected string representation + let expectedString: string; + switch (valueType) { + case 'number': + expectedString = String(bodyValue); + break; + case 'boolean': + expectedString = bodyValue ? 'true' : 'false'; + break; + default: + expectedString = String(bodyValue); + } + + // For numbers, compare numerically to allow for cross-SDK + // floating point representation differences (e.g., "42" vs "42.0"). + // See SEP-2243 discussion on number precision. + const validationError = validateEncodedHeader( + rawHeaderValue, + expectedString, + valueType + ); + if (validationError) { + errors.push(validationError); + } + } + + // One check ID per SEP-2243 requirement, picked by what this parameter + // actually exercises: Base64 wrapping for unsafe values, primitive + // (number/boolean) string encoding, or plain mirroring. The per-parameter + // detail lives in `name`/`details`. + const needsBase64 = + typeof bodyValue === 'string' && needsBase64Encoding(String(bodyValue)); + const checkId = needsBase64 + ? 'sep-2243-client-base64-unsafe' + : valueType === 'number' || + valueType === 'integer' || + valueType === 'boolean' + ? 'sep-2243-client-encode-values' + : 'sep-2243-client-mirrors-designated-params'; + + this.checks.push({ + id: checkId, + name: `ClientCustomHeader_${headerName}`, + description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE_ENCODING], + details: { + headerName: `Mcp-Param-${headerName}`, + rawHeaderValue, + bodyValue, + valueType, + needsBase64 + } + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HttpInvalidToolHeadersScenario - tests that clients reject invalid tools +// ───────────────────────────────────────────────────────────────────────────── + +export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { + name = 'http-invalid-tool-headers'; + description = + 'Tests that client rejects tools with invalid x-mcp-header annotations (SEP-2243)'; + allowClientError = true; + + private calledTools: Set = new Set(); + private toolsListSent = false; + + getChecks(): ConformanceCheck[] { + if (!this.toolsListSent) { + this.checks.push({ + id: 'sep-2243-invalid-tool-tools-list-gate', + name: 'ClientInvalidToolHeadersToolsList', + description: 'Client requests tools/list', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'Client did not send a tools/list request.', + specReferences: [SPEC_REFERENCE_TOOL_DEF] + }); + } + + // Check that valid_tool WAS called — proves client kept valid tools + const validToolCalled = this.calledTools.has('valid_tool'); + this.checks.push({ + id: 'sep-2243-client-reject-invalid-tool', + name: 'ClientKeepsValidTool', + description: 'Client MUST keep valid tools while excluding invalid ones', + status: validToolCalled ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: validToolCalled + ? undefined + : "Client did not call 'valid_tool'. A single malformed tool definition must not prevent other valid tools from being used.", + specReferences: [SPEC_REFERENCE_TOOL_DEF] + }); + + // Check that the client did NOT call any of the invalid tools. Each + // invalid tool violates one specific x-mcp-header constraint, so the + // check is emitted under that constraint's requirement ID — the client + // rejecting the tool is how the constraint is enforced on the wire. + for (const [toolName, constraintId] of Object.entries( + INVALID_TOOL_CONSTRAINT_IDS + )) { + const called = this.calledTools.has(toolName); + this.checks.push({ + id: constraintId, + name: `ClientRejectsInvalidTool_${toolName}`, + description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`, + status: called ? 'FAILURE' : 'SUCCESS', + timestamp: new Date().toISOString(), + errorMessage: called + ? `Client called '${toolName}' which has an invalid x-mcp-header. Clients MUST reject (exclude) such tools.` + : undefined, + specReferences: [SPEC_REFERENCE_TOOL_DEF] + }); + } + + return this.checks; + } + + protected handlePost( + _req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + if (request.method === 'initialize') { + this.sendInitialize(res, request); + } else if (request.method === 'tools/list') { + this.handleToolsList(res, request); + } else if (request.method === 'tools/call') { + this.handleToolsCall(res, request); + } else if (request.id === undefined) { + this.sendNotificationAck(res); + } else { + this.sendGenericResult(res, request); + } + } + + private handleToolsList(res: http.ServerResponse, request: any): void { + this.toolsListSent = true; + + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [ + // ── Valid tool (should be kept by client) ── + { + name: 'valid_tool', + description: 'A valid tool with correct x-mcp-header', + inputSchema: { + type: 'object', + properties: { + region: { + type: 'string', + 'x-mcp-header': 'Region' + } + }, + required: ['region'] + } + }, + + // ── Invalid: empty x-mcp-header value ── + { + name: 'invalid_empty_header', + description: + 'x-mcp-header MUST NOT be empty (MUST be rejected by client)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': '' } + }, + required: ['value'] + } + }, + + // ── Invalid: x-mcp-header on object type ── + { + name: 'invalid_object_header', + description: + 'x-mcp-header MUST only be on primitive types (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + data: { type: 'object', 'x-mcp-header': 'Data' } + }, + required: ['data'] + } + }, + + // ── Invalid: x-mcp-header on array type ── + { + name: 'invalid_array_header', + description: + 'x-mcp-header MUST only be on primitive types (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'string' }, + 'x-mcp-header': 'Items' + } + }, + required: ['items'] + } + }, + + // ── Invalid: x-mcp-header on null type ── + { + name: 'invalid_null_header', + description: + 'x-mcp-header MUST only be on primitive types (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + nil: { type: 'null', 'x-mcp-header': 'Nil' } + }, + required: ['nil'] + } + }, + + // ── Invalid: x-mcp-header on number type ── + // `number` is a JSON Schema primitive but SEP-2243 excludes it from + // the permitted set: "Parameters with type `number` are not + // permitted." Only integer, string and boolean may be annotated. + { + name: 'invalid_number_header', + description: + 'x-mcp-header MUST NOT be on number type (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + score: { type: 'number', 'x-mcp-header': 'Score' } + }, + required: ['score'] + } + }, + + // ── Invalid: duplicate same-case x-mcp-header values ── + { + name: 'invalid_duplicate_same_case', + description: + 'Duplicate x-mcp-header "Region" on two properties (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + field1: { type: 'string', 'x-mcp-header': 'Region' }, + field2: { type: 'string', 'x-mcp-header': 'Region' } + }, + required: ['field1', 'field2'] + } + }, + + // ── Invalid: duplicate case-insensitive x-mcp-header values ── + { + name: 'invalid_duplicate_diff_case', + description: + 'Duplicate case-insensitive x-mcp-header "MyField"/"myfield" (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + field1: { type: 'string', 'x-mcp-header': 'MyField' }, + field2: { type: 'string', 'x-mcp-header': 'myfield' } + }, + required: ['field1', 'field2'] + } + }, + + // ── Invalid: space in x-mcp-header name ── + { + name: 'invalid_space_in_name', + description: + 'x-mcp-header MUST NOT contain space (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': 'My Region' } + }, + required: ['value'] + } + }, + + // ── Invalid: colon in x-mcp-header name ── + { + name: 'invalid_colon_in_name', + description: + 'x-mcp-header MUST NOT contain colon (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { + type: 'string', + 'x-mcp-header': 'Region:Primary' + } + }, + required: ['value'] + } + }, + + // ── Invalid: non-ASCII in x-mcp-header name ── + { + name: 'invalid_non_ascii_name', + description: + 'x-mcp-header MUST contain only ASCII chars (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': 'Région' } + }, + required: ['value'] + } + }, + + // ── Invalid: control character in x-mcp-header name ── + { + name: 'invalid_control_char_name', + description: + 'x-mcp-header MUST NOT contain control chars (MUST be rejected)', + inputSchema: { + type: 'object', + properties: { + value: { type: 'string', 'x-mcp-header': 'Region\t1' } + }, + required: ['value'] + } + } + ] + } + }); + } + + private handleToolsCall(res: http.ServerResponse, request: any): void { + const toolName = request.params?.name; + if (toolName) this.calledTools.add(toolName); + + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'Tool call received' }] + } + }); + } +} diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts new file mode 100644 index 0000000..9467456 --- /dev/null +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -0,0 +1,82 @@ +import { testScenarioContext } from '../../mock-server/testing'; +import { describe, it, expect } from 'vitest'; +import { HttpStandardHeadersScenario } from './http-standard-headers'; + +/** + * Negative test for SEP-2243 standard-header checks: a client that omits + * Mcp-Method on a POST must produce a FAILURE row, and one that includes it + * must produce SUCCESS. Pins the check id so coverage is tracked. + */ +describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { + async function postInitialize( + serverUrl: string, + extraHeaders: Record + ): Promise { + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...extraHeaders + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2026-07-28', + clientInfo: { name: 'neg-test', version: '0' }, + capabilities: {} + } + }) + }); + } + + // The coarse check id is emitted once per method/name case, so we narrow to + // the initialize Mcp-Method emission via its (case-specific) name. + const COARSE_ID = 'sep-2243-client-includes-standard-headers'; + const INIT_METHOD_NAME = 'ClientMcpMethodHeader_initialize'; + + it('FAILs the initialize Mcp-Method emission when Mcp-Method is missing', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await postInitialize(serverUrl, {}); // no Mcp-Method header + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === COARSE_ID && c.name === INIT_METHOD_NAME + ); + expect(check?.status).toBe('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('SUCCEEDs the initialize Mcp-Method emission when Mcp-Method matches', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === COARSE_ID && c.name === INIT_METHOD_NAME + ); + expect(check?.status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); + + it('getChecks() is idempotent', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); + const first = scenario.getChecks(); + const second = scenario.getChecks(); + expect(second.length).toBe(first.length); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts new file mode 100644 index 0000000..227546b --- /dev/null +++ b/src/scenarios/client/http-standard-headers.ts @@ -0,0 +1,370 @@ +/** + * HTTP Standard Headers conformance test scenario for MCP clients (SEP-2243) + * + * Tests that clients include the required standard MCP request headers on + * Streamable HTTP POST requests: + * - `Mcp-Method`: mirrors the `method` field from the JSON-RPC request body + * - `Mcp-Name`: mirrors `params.name` or `params.uri` for tools/call, + * resources/read, and prompts/get requests + * + * This is a Scenario (acts as a test server that inspects incoming requests + * from the client under test). + */ + +import http from 'http'; +import { ConformanceCheck } from '../../types.js'; +import { BaseHttpScenario } from './http-base.js'; + +const SPEC_REFERENCE = { + id: 'SEP-2243-Standard-Headers', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers' +}; + +export class HttpStandardHeadersScenario extends BaseHttpScenario { + name = 'http-standard-headers'; + description = + 'Tests that client includes Mcp-Method and Mcp-Name headers on HTTP POST requests (SEP-2243)'; + + // Track which header checks have been recorded + private methodHeaderChecks = new Map(); + // Track which Mcp-Name checks have been recorded + private nameHeaderChecks = new Map(); + + getChecks(): ConformanceCheck[] { + // Build a fresh array each call so getChecks() is idempotent — the runner + // may call it more than once and we must not accumulate duplicates. + const result = [...this.checks]; + + // SEP-2243 requires Mcp-Method on "all requests and notifications". A + // client that never sent prompts/list isn't violating SEP-2243 — it just + // didn't exercise that path. Emit SKIPPED (not FAILURE) so a prompts-less + // client doesn't show red, but the gap is still visible in the report. + const expectedMethods = [ + 'initialize', + 'notifications/initialized', + 'tools/list', + 'tools/call', + 'resources/list', + 'resources/read', + 'prompts/list', + 'prompts/get' + ]; + + for (const method of expectedMethods) { + if (!this.methodHeaderChecks.has(method)) { + result.push({ + id: 'sep-2243-client-includes-standard-headers', + name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, + description: `Client sends correct Mcp-Method header on ${method} request`, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: `Client did not send a ${method} request; Mcp-Method header was not exercised for this method.`, + specReferences: [SPEC_REFERENCE] + }); + } + } + + const expectedNameMethods = ['tools/call', 'resources/read', 'prompts/get']; + for (const method of expectedNameMethods) { + if (!this.nameHeaderChecks.has(method)) { + result.push({ + id: 'sep-2243-client-includes-standard-headers', + name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, + description: `Client sends correct Mcp-Name header on ${method} request`, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: `Client did not send a ${method} request; Mcp-Name header was not exercised for this method.`, + specReferences: [SPEC_REFERENCE] + }); + } + } + + return result; + } + + protected handlePost( + req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + // Check Mcp-Method header for every request + this.checkMcpMethodHeader(req, request); + + // Route to handlers + if (request.method === 'initialize') { + this.handleInitialize(res, request); + } else if (request.method === 'tools/list') { + this.handleToolsList(res, request); + } else if (request.method === 'tools/call') { + this.checkMcpNameHeader(req, request, 'params.name'); + this.handleToolsCall(res, request); + } else if (request.method === 'resources/list') { + this.handleResourcesList(res, request); + } else if (request.method === 'resources/read') { + this.checkMcpNameHeader(req, request, 'params.uri'); + this.handleResourcesRead(res, request); + } else if (request.method === 'prompts/list') { + this.handlePromptsList(res, request); + } else if (request.method === 'prompts/get') { + this.checkMcpNameHeader(req, request, 'params.name'); + this.handlePromptsGet(res, request); + } else if (request.id === undefined) { + // Notifications - return 202 (Mcp-Method already checked above) + this.sendNotificationAck(res); + } else { + this.sendGenericResult(res, request); + } + } + + private checkMcpMethodHeader(req: http.IncomingMessage, request: any): void { + const method = request.method; + if (!method) return; + + // Already recorded a check for this method + if (this.methodHeaderChecks.has(method)) return; + + // Header names are lowercased by Node.js http parser + const mcpMethodHeader = req.headers['mcp-method'] as string | undefined; + + const errors: string[] = []; + if (!mcpMethodHeader) { + errors.push( + `Missing Mcp-Method header on ${method} request. Clients MUST include the Mcp-Method header on all POST requests.` + ); + } else if (mcpMethodHeader !== method) { + // Header values are case-sensitive + errors.push( + `Mcp-Method header value '${mcpMethodHeader}' does not match body method '${method}'. Header values are case-sensitive.` + ); + } + + this.methodHeaderChecks.set(method, errors.length === 0); + + this.checks.push({ + id: 'sep-2243-client-includes-standard-headers', + name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, + description: `Client sends correct Mcp-Method header on ${method} request`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE], + details: { + expectedMethod: method, + actualHeader: mcpMethodHeader + } + }); + } + + private checkMcpNameHeader( + req: http.IncomingMessage, + request: any, + sourceField: string + ): void { + const method = request.method; + + // Same de-dup guard as checkMcpMethodHeader: the harness advertises two + // tools and two resources, so a client that calls both would otherwise + // produce duplicate check rows for the same id. + if (this.nameHeaderChecks.has(method)) return; + + const expectedValue = + sourceField === 'params.uri' ? request.params?.uri : request.params?.name; + + const mcpNameHeader = req.headers['mcp-name'] as string | undefined; + + const errors: string[] = []; + if (!mcpNameHeader) { + errors.push( + `Missing Mcp-Name header on ${method} request. Clients MUST include the Mcp-Name header for ${method} requests.` + ); + } else if (mcpNameHeader !== expectedValue) { + errors.push( + `Mcp-Name header value '${mcpNameHeader}' does not match body ${sourceField} '${expectedValue}'.` + ); + } + + this.nameHeaderChecks.set(method, errors.length === 0); + + this.checks.push({ + id: 'sep-2243-client-includes-standard-headers', + name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, + description: `Client sends correct Mcp-Name header on ${method} request`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [SPEC_REFERENCE], + details: { + method, + sourceField, + expectedValue, + actualHeader: mcpNameHeader + } + }); + } + + protected discoverCapabilities(): object { + return { tools: {}, resources: {}, prompts: {} }; + } + + private handleInitialize(res: http.ServerResponse, request: any): void { + this.sendInitialize(res, request); + } + + private handleToolsList(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [ + { + name: 'test_headers', + description: + 'A simple tool used to test that HTTP headers are sent correctly', + inputSchema: { + type: 'object', + properties: {}, + required: [] + } + }, + { + name: 'my-hyphenated-tool', + description: + 'Tool with hyphen in name to test special chars in Mcp-Name header', + inputSchema: { + type: 'object', + properties: {}, + required: [] + } + } + ] + } + }) + ); + } + + private handleToolsCall(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + content: [ + { + type: 'text', + text: 'Headers test completed' + } + ] + } + }) + ); + } + + private handleResourcesList(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + resources: [ + { + uri: 'file:///path/to/file%20name.txt', + name: 'File with spaces', + description: 'Resource URI with percent-encoded spaces' + }, + { + uri: 'https://example.com/resource?id=123', + name: 'Resource with query string', + description: 'Resource URI with query string' + } + ] + } + }) + ); + } + + private handleResourcesRead(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + contents: [] + } + }) + ); + } + + private handlePromptsList(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + prompts: [ + { + name: 'test_prompt', + description: 'A simple prompt for header testing' + } + ] + } + }) + ); + } + + private handlePromptsGet(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + resultType: 'complete', + messages: [] + } + }) + ); + } +} diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index 70fb0d1..9906622 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -1,22 +1,28 @@ +import type { ScenarioContext } from '../../mock-server'; import http from 'http'; import { Scenario, ScenarioUrls, ConformanceCheck, - SpecVersion + LATEST_SPEC_VERSION, + NEGOTIABLE_PROTOCOL_VERSIONS, + DRAFT_PROTOCOL_VERSION } from '../../types'; import { clientChecks } from '../../checks/index'; export class InitializeScenario implements Scenario { name = 'initialize'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Tests MCP client initialization handshake'; private server: http.Server | null = null; private checks: ConformanceCheck[] = []; private port: number = 0; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); @@ -77,6 +83,12 @@ export class InitializeScenario implements Scenario { this.handleInitialize(request, res); } else if (request.method === 'tools/list') { this.handleToolsList(request, res); + } else if (request.method === 'notifications/initialized') { + // Per MCP spec 2025-11-25 section 2.1 (Sending Messages to the Server), + // point 4: "If the input is a JSON-RPC response or notification ... + // the server MUST return HTTP status code 202 Accepted with no body." + res.writeHead(202); + res.end(); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( @@ -117,11 +129,10 @@ export class InitializeScenario implements Scenario { this.checks.push(clientChecks.createServerInfoCheck(serverInfo)); // Echo back client's version if valid, otherwise use latest - const VALID_VERSIONS = ['2025-06-18', '2025-11-25']; const clientVersion = initializeRequest?.protocolVersion; - const responseVersion = VALID_VERSIONS.includes(clientVersion) + const responseVersion = NEGOTIABLE_PROTOCOL_VERSIONS.includes(clientVersion) ? clientVersion - : '2025-11-25'; + : LATEST_SPEC_VERSION; const response = { jsonrpc: '2.0', diff --git a/src/scenarios/client/json-schema-2020-12-preservation.test.ts b/src/scenarios/client/json-schema-2020-12-preservation.test.ts new file mode 100644 index 0000000..ca80346 --- /dev/null +++ b/src/scenarios/client/json-schema-2020-12-preservation.test.ts @@ -0,0 +1,271 @@ +import { testScenarioContext } from '../../mock-server/testing'; +import { describe, it, expect } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { JsonSchema2020_12PreservationScenario } from './json-schema-2020-12-preservation'; +import { sendStatelessRequest } from '../../connection/stateless'; +import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; +import { JSON_SCHEMA_2020_12_FIXTURE } from '../server/json-schema-2020-12'; + +const FOCAL_TOOL = 'json_schema_2020_12_tool'; +const ECHO_TOOL = 'json_schema_echo'; + +/** + * Connect a real SDK client to the scenario's mock server, list tools, and + * echo back the focal tool's inputSchema (verbatim or after the caller- + * supplied transform). Returns nothing — the scenario observes the echo + * internally. + */ +async function runEchoClient( + serverUrl: string, + transform: (schema: Record) => Record = ( + s + ) => s +): Promise { + const client = new Client( + { name: 'preservation-test-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + try { + const listed = await client.listTools(); + const focal = listed.tools.find((t) => t.name === FOCAL_TOOL); + if (!focal) { + throw new Error(`Focal tool ${FOCAL_TOOL} not advertised`); + } + const observed = focal.inputSchema as Record; + await client.callTool({ + name: ECHO_TOOL, + arguments: { schema: transform(observed) } + }); + } finally { + await transport.close(); + } +} + +/** + * Drive the scenario's mock server through the SEP-2575 stateless lifecycle + * that the draft protocol version uses: list tools, then echo the focal + * tool's inputSchema back (verbatim or after the caller-supplied transform). + * The SDK `Client` does not support the stateless lifecycle, so these + * requests go through the raw stateless helper instead. + */ +async function runStatelessEchoClient( + serverUrl: string, + transform: (schema: Record) => Record = ( + s + ) => s +): Promise { + const listed = await sendStatelessRequest(serverUrl, 'tools/list'); + const tools = ( + listed.body?.result as + | { tools?: { name: string; inputSchema: Record }[] } + | undefined + )?.tools; + const focal = tools?.find((t) => t.name === FOCAL_TOOL); + if (!focal) { + throw new Error(`Focal tool ${FOCAL_TOOL} not advertised`); + } + await sendStatelessRequest(serverUrl, 'tools/call', { + name: ECHO_TOOL, + arguments: { schema: transform(focal.inputSchema) } + }); +} + +describe('json-schema-2020-12-preservation scenario', () => { + it('emits FAILURE preconditions and SKIPPED preservation checks when no client connects', async () => { + const scenario = new JsonSchema2020_12PreservationScenario(); + await scenario.start(testScenarioContext()); + try { + const checks = scenario.getChecks(); + expect(checks).toHaveLength(8); + expect( + checks.find((c) => c.id === 'json-schema-2020-12-client-tool-found') + ?.status + ).toBe('FAILURE'); + expect( + checks.find((c) => c.id === 'json-schema-2020-12-client-echo-completed') + ?.status + ).toBe('FAILURE'); + const preservationIds = [ + 'json-schema-2020-12-client-$schema-preserved', + 'json-schema-2020-12-client-$defs-preserved', + 'json-schema-2020-12-client-additionalProperties-preserved', + 'sep-2106-client-composition-keywords-preserved', + 'sep-2106-client-conditional-keywords-preserved', + 'sep-2106-client-anchor-keyword-preserved' + ]; + for (const id of preservationIds) { + expect(checks.find((c) => c.id === id)?.status).toBe('SKIPPED'); + } + } finally { + await scenario.stop(); + } + }); + + it('emits SUCCESS for SEP-1613 keywords and SKIPPED for SEP-2106 when a compliant client echoes back on a dated target', async () => { + const scenario = new JsonSchema2020_12PreservationScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(LATEST_SPEC_VERSION) + ); + try { + await runEchoClient(serverUrl); + + const first = scenario.getChecks(); + expect(first).toHaveLength(8); + const byId = (id: string) => + first.find((c) => c.id === id) ?? expect.fail(`missing check ${id}`); + expect(byId('json-schema-2020-12-client-tool-found').status).toBe( + 'SUCCESS' + ); + expect(byId('json-schema-2020-12-client-echo-completed').status).toBe( + 'SUCCESS' + ); + expect(byId('json-schema-2020-12-client-$schema-preserved').status).toBe( + 'SUCCESS' + ); + expect(byId('json-schema-2020-12-client-$defs-preserved').status).toBe( + 'SUCCESS' + ); + expect( + byId('json-schema-2020-12-client-additionalProperties-preserved').status + ).toBe('SUCCESS'); + // SEP-2106 keywords were preserved (compliant client), so the soft gate + // returns SUCCESS regardless of target version. + expect( + byId('sep-2106-client-composition-keywords-preserved').status + ).toBe('SUCCESS'); + expect( + byId('sep-2106-client-conditional-keywords-preserved').status + ).toBe('SUCCESS'); + expect(byId('sep-2106-client-anchor-keyword-preserved').status).toBe( + 'SUCCESS' + ); + + // getChecks() must be idempotent — duplicate IDs would break the + // traceability manifest. + expect(scenario.getChecks()).toHaveLength(8); + } finally { + await scenario.stop(); + } + }); + + it('emits all SUCCESS when a compliant client echoes back on the draft target', async () => { + const scenario = new JsonSchema2020_12PreservationScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + await runStatelessEchoClient(serverUrl); + + const checks = scenario.getChecks(); + for (const c of checks) { + expect(c.status, `check ${c.id}`).toBe('SUCCESS'); + } + } finally { + await scenario.stop(); + } + }); + + it('flags SEP-1613 FAILURE when a client strips $schema and $defs before echoing', async () => { + const scenario = new JsonSchema2020_12PreservationScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(LATEST_SPEC_VERSION) + ); + try { + await runEchoClient(serverUrl, (schema) => { + const stripped = { ...schema }; + delete stripped['$schema']; + delete stripped['$defs']; + return stripped; + }); + + const checks = scenario.getChecks(); + const byId = (id: string) => + checks.find((c) => c.id === id) ?? expect.fail(`missing check ${id}`); + + expect(byId('json-schema-2020-12-client-tool-found').status).toBe( + 'SUCCESS' + ); + expect(byId('json-schema-2020-12-client-echo-completed').status).toBe( + 'SUCCESS' + ); + expect(byId('json-schema-2020-12-client-$schema-preserved').status).toBe( + 'FAILURE' + ); + expect(byId('json-schema-2020-12-client-$defs-preserved').status).toBe( + 'FAILURE' + ); + // additionalProperties was left intact, so it still passes. + expect( + byId('json-schema-2020-12-client-additionalProperties-preserved').status + ).toBe('SUCCESS'); + // $anchor lives inside the stripped $defs, so it's also missing — but + // the soft gate is SKIPPED on the dated target rather than FAILURE. + expect(byId('sep-2106-client-anchor-keyword-preserved').status).toBe( + 'SKIPPED' + ); + } finally { + await scenario.stop(); + } + }); + + it('flags SEP-2106 FAILURE on the draft target when composition keywords are stripped', async () => { + const scenario = new JsonSchema2020_12PreservationScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + await runStatelessEchoClient(serverUrl, (schema) => { + const stripped = { ...schema }; + delete stripped['allOf']; + delete stripped['anyOf']; + delete stripped['if']; + delete stripped['then']; + delete stripped['else']; + return stripped; + }); + + const checks = scenario.getChecks(); + const byId = (id: string) => + checks.find((c) => c.id === id) ?? expect.fail(`missing check ${id}`); + + expect(byId('json-schema-2020-12-client-$schema-preserved').status).toBe( + 'SUCCESS' + ); + expect( + byId('sep-2106-client-composition-keywords-preserved').status + ).toBe('FAILURE'); + expect( + byId('sep-2106-client-conditional-keywords-preserved').status + ).toBe('FAILURE'); + // $anchor is preserved inside $defs.address, which was left intact. + expect(byId('sep-2106-client-anchor-keyword-preserved').status).toBe( + 'SUCCESS' + ); + } finally { + await scenario.stop(); + } + }); + + it('preserves the focal fixture verbatim through the echo when the client does not transform it', async () => { + // Sanity check that the round-trip itself does not introduce drift; if + // this fails, all the other tests are unreliable. + const scenario = new JsonSchema2020_12PreservationScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + await runEchoClient(serverUrl); + const checks = scenario.getChecks(); + const preserved = checks.find( + (c) => c.id === 'json-schema-2020-12-client-$defs-preserved' + ); + expect(preserved?.details).toMatchObject({ + hasDefs: true, + defsKeys: Object.keys(JSON_SCHEMA_2020_12_FIXTURE['$defs']) + }); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/json-schema-2020-12-preservation.ts b/src/scenarios/client/json-schema-2020-12-preservation.ts new file mode 100644 index 0000000..1518595 --- /dev/null +++ b/src/scenarios/client/json-schema-2020-12-preservation.ts @@ -0,0 +1,403 @@ +/** + * Client-side JSON Schema 2020-12 keyword preservation (SEP-1613, SEP-2106) + * + * Validates that a client receiving a tool definition via `tools/list` + * preserves the full JSON Schema 2020-12 vocabulary in its internal + * representation, rather than silently stripping `$schema`, `$defs`, + * `additionalProperties`, `$anchor`, composition (`allOf`/`anyOf`), or + * conditional (`if`/`then`/`else`) keywords during parsing. + * + * This is the client-side counterpart of the server-side + * `json-schema-2020-12` scenario. + * This scenario exercises: + * + * 1. The mock server advertises a focal tool (`json_schema_2020_12_tool`) + * whose inputSchema contains the full SEP-1613/SEP-2106 vocabulary, plus + * a permissive echo tool (`json_schema_echo`). + * 2. The client under test calls `tools/list`, observes the focal tool's + * inputSchema, then round-trips it back via `tools/call json_schema_echo` + * with `{ schema: }`. + * 3. The scenario diffs the echoed schema against the original fixture and + * flags any missing or altered keywords. + * + * SEP-2106 vocabulary checks are soft-gated via `sep2106KeywordCheckStatus`: + * stripping is FAILURE only when the run targets the draft version + * (`2026-07-28`); on earlier dated versions the check reports SKIPPED. + */ + +import type { ScenarioContext, MockServer } from '../../mock-server'; +import type { + CallToolRequest, + ListToolsResult +} from '../../spec-types/2025-11-25'; +import { + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION, + type ConformanceCheck, + type Scenario, + type ScenarioUrls, + type SpecVersion +} from '../../types'; +import { + EXPECTED_SCHEMA_DIALECT, + EXPECTED_TOOL_NAME, + JSON_SCHEMA_2020_12_FIXTURE, + sep2106KeywordCheckStatus +} from '../server/json-schema-2020-12'; + +const ECHO_TOOL_NAME = 'json_schema_echo'; + +const SEP_1613_REF = { + id: 'SEP-1613', + url: 'https://github.com/modelcontextprotocol/specification/pull/655' +}; +const SEP_2106_REF = { + id: 'SEP-2106', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106' +}; + +export class JsonSchema2020_12PreservationScenario implements Scenario { + name = 'json-schema-2020-12-preservation'; + readonly source = { introducedIn: '2025-11-25' } as const; + description = `Validates client-side preservation of JSON Schema 2020-12 keywords (SEP-1613, SEP-2106). + +**Client Implementation Requirements:** + +1. Call \`tools/list\`. The mock server advertises two tools: \`${EXPECTED_TOOL_NAME}\` (focal, carries the rich inputSchema) and \`${ECHO_TOOL_NAME}\` (permissive echo). +2. Round-trip the focal tool's \`inputSchema\` back via \`tools/call\` on \`${ECHO_TOOL_NAME}\`, passing it verbatim as the \`schema\` argument: + +\`\`\`json +{ + "name": "${ECHO_TOOL_NAME}", + "arguments": { + "schema": "" + } +} +\`\`\` + +The scenario compares the echoed schema against the original fixture and flags any keyword that was stripped or altered during the client's internal parsing. + +**Verification**: \`$schema\`, \`$defs\`, and \`additionalProperties\` must be preserved (SEP-1613). The SEP-2106 vocabulary (\`$anchor\` inside \`$defs\`, composition \`allOf\`/\`anyOf\`, conditional \`if\`/\`then\`/\`else\`) must also survive; for dated protocol versions these checks are SKIPPED rather than FAILURE since SEP-2106 applies from the draft version.`; + + private srv: MockServer | null = null; + private specVersion: SpecVersion | null = null; + private observedSchema: Record | null = null; + private toolsListed = false; + + async start(ctx: ScenarioContext): Promise { + this.specVersion = ctx.specVersion; + this.observedSchema = null; + this.toolsListed = false; + + this.srv = await ctx.createServer({ + 'tools/list': (): ListToolsResult => { + this.toolsListed = true; + return { + tools: [ + { + name: EXPECTED_TOOL_NAME, + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: JSON_SCHEMA_2020_12_FIXTURE + }, + { + name: ECHO_TOOL_NAME, + description: + 'Echoes back an arbitrary JSON Schema object so the harness can observe what the client preserved.', + inputSchema: { + type: 'object', + properties: { + schema: { + type: 'object', + description: `The inputSchema the client observed for ${EXPECTED_TOOL_NAME}, passed back verbatim.` + } + }, + required: ['schema'] + } + } + ] + }; + }, + 'tools/call': (params) => { + const p = params as unknown as CallToolRequest['params']; + if (p.name === ECHO_TOOL_NAME) { + const args = p.arguments as { schema?: unknown } | undefined; + if (args && typeof args.schema === 'object' && args.schema !== null) { + this.observedSchema = args.schema as Record; + } + return { content: [{ type: 'text', text: 'echoed' }] }; + } + if (p.name === EXPECTED_TOOL_NAME) { + return { content: [{ type: 'text', text: 'ok' }] }; + } + throw new Error(`Unknown tool: ${p.name}`); + } + }); + return { serverUrl: this.srv.url }; + } + + async stop(): Promise { + await this.srv?.close(); + this.srv = null; + } + + getChecks(): ConformanceCheck[] { + // Built fresh on every call so getChecks() is idempotent — the runner may + // call it more than once and we must not accumulate duplicates. + const timestamp = new Date().toISOString(); + const targetVersion = this.specVersion ?? LATEST_SPEC_VERSION; + const echo = this.observedSchema; + + const checks: ConformanceCheck[] = []; + + checks.push({ + id: 'json-schema-2020-12-client-tool-found', + name: 'JsonSchema2020_12ClientToolFound', + description: `Client called tools/list and the mock server advertised '${EXPECTED_TOOL_NAME}'`, + status: this.toolsListed ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: this.toolsListed + ? undefined + : 'Client never called tools/list; the scenario cannot observe schema preservation', + details: { toolsListCalled: this.toolsListed } + }); + + checks.push({ + id: 'json-schema-2020-12-client-echo-completed', + name: 'JsonSchema2020_12ClientEchoCompleted', + description: `Client called tools/call '${ECHO_TOOL_NAME}' with a 'schema' object argument`, + status: echo !== null ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: + echo !== null + ? undefined + : 'Client did not echo back the inputSchema; downstream preservation checks are SKIPPED', + details: { echoCompleted: echo !== null } + }); + + // If the client never echoed, the downstream preservation checks cannot + // be evaluated. Emit SKIPPED for each so the SEP traceability manifest + // still records all known check IDs from this scenario. + if (echo === null) { + const skipReason = + 'Skipped because the client did not complete the schema echo'; + checks.push( + { + id: 'json-schema-2020-12-client-$schema-preserved', + name: 'JsonSchema2020_12Client$SchemaPreserved', + description: + 'Client preserved the $schema field of the focal tool inputSchema', + status: 'SKIPPED', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: skipReason + }, + { + id: 'json-schema-2020-12-client-$defs-preserved', + name: 'JsonSchema2020_12Client$DefsPreserved', + description: + 'Client preserved the $defs field of the focal tool inputSchema', + status: 'SKIPPED', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: skipReason + }, + { + id: 'json-schema-2020-12-client-additionalProperties-preserved', + name: 'JsonSchema2020_12ClientAdditionalPropertiesPreserved', + description: + 'Client preserved the additionalProperties field of the focal tool inputSchema', + status: 'SKIPPED', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: skipReason + }, + { + id: 'sep-2106-client-composition-keywords-preserved', + name: 'JsonSchema2020_12ClientCompositionKeywordsPreserved', + description: + 'Client preserved composition keywords (allOf/anyOf) on the focal tool inputSchema', + status: 'SKIPPED', + timestamp, + specReferences: [SEP_2106_REF], + errorMessage: skipReason + }, + { + id: 'sep-2106-client-conditional-keywords-preserved', + name: 'JsonSchema2020_12ClientConditionalKeywordsPreserved', + description: + 'Client preserved conditional keywords (if/then/else) on the focal tool inputSchema', + status: 'SKIPPED', + timestamp, + specReferences: [SEP_2106_REF], + errorMessage: skipReason + }, + { + id: 'sep-2106-client-anchor-keyword-preserved', + name: 'JsonSchema2020_12ClientAnchorKeywordPreserved', + description: + 'Client preserved the $anchor keyword inside $defs.address', + status: 'SKIPPED', + timestamp, + specReferences: [SEP_2106_REF], + errorMessage: skipReason + } + ); + return checks; + } + + // SEP-1613: $schema preserved + const hasSchema = '$schema' in echo; + const schemaCorrect = echo['$schema'] === EXPECTED_SCHEMA_DIALECT; + checks.push({ + id: 'json-schema-2020-12-client-$schema-preserved', + name: 'JsonSchema2020_12Client$SchemaPreserved', + description: `Client preserved $schema = '${EXPECTED_SCHEMA_DIALECT}'`, + status: hasSchema && schemaCorrect ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: !hasSchema + ? '$schema missing from echoed inputSchema — client likely stripped it during parsing' + : !schemaCorrect + ? `$schema has unexpected value: ${JSON.stringify(echo['$schema'])}` + : undefined, + details: { hasSchema, schemaValue: echo['$schema'] } + }); + + // SEP-1613: $defs preserved + const defs = echo['$defs'] as Record | undefined; + const hasDefs = '$defs' in echo; + const defsHasAddress = hasDefs && defs !== undefined && 'address' in defs; + checks.push({ + id: 'json-schema-2020-12-client-$defs-preserved', + name: 'JsonSchema2020_12Client$DefsPreserved', + description: + 'Client preserved $defs with the expected nested definitions', + status: hasDefs && defsHasAddress ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: !hasDefs + ? '$defs missing from echoed inputSchema — client likely stripped it during parsing' + : !defsHasAddress + ? '$defs exists but missing expected "address" definition' + : undefined, + details: { + hasDefs, + defsKeys: defs !== undefined ? Object.keys(defs) : [] + } + }); + + // SEP-1613: additionalProperties preserved + const hasAdditionalProps = 'additionalProperties' in echo; + const additionalPropsCorrect = echo['additionalProperties'] === false; + checks.push({ + id: 'json-schema-2020-12-client-additionalProperties-preserved', + name: 'JsonSchema2020_12ClientAdditionalPropertiesPreserved', + description: + 'Client preserved additionalProperties = false on the focal inputSchema', + status: + hasAdditionalProps && additionalPropsCorrect ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SEP_1613_REF], + errorMessage: !hasAdditionalProps + ? 'additionalProperties missing from echoed inputSchema — client likely stripped it during parsing' + : !additionalPropsCorrect + ? `additionalProperties has unexpected value: ${JSON.stringify(echo['additionalProperties'])}` + : undefined, + details: { hasAdditionalProps, value: echo['additionalProperties'] } + }); + + // SEP-2106 vocabulary — soft-gated via sep2106KeywordCheckStatus. + const skippedSuffix = ` (run targets protocol version ${targetVersion}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`; + + // SEP-2106: composition (allOf/anyOf) preserved + const allOf = echo['allOf']; + const hasAllOf = Array.isArray(allOf) && allOf.length > 0; + const hasNestedAnyOf = + Array.isArray(allOf) && + allOf.some( + (sub) => + typeof sub === 'object' && + sub !== null && + Array.isArray((sub as Record)['anyOf']) + ); + const compositionPreserved = hasAllOf && hasNestedAnyOf; + const compositionStatus = sep2106KeywordCheckStatus( + compositionPreserved, + targetVersion + ); + checks.push({ + id: 'sep-2106-client-composition-keywords-preserved', + name: 'JsonSchema2020_12ClientCompositionKeywordsPreserved', + description: + 'Client preserved composition keywords (allOf/anyOf) on the focal inputSchema', + status: compositionStatus, + timestamp, + specReferences: [SEP_2106_REF], + errorMessage: compositionPreserved + ? undefined + : (!hasAllOf + ? 'allOf missing from echoed inputSchema — composition keyword was likely stripped' + : 'nested anyOf missing from echoed allOf — composition keyword was likely stripped') + + (compositionStatus === 'SKIPPED' ? skippedSuffix : ''), + details: { + hasAllOf, + hasNestedAnyOf, + targetProtocolVersion: targetVersion + } + }); + + // SEP-2106: conditional (if/then/else) preserved + const hasIf = 'if' in echo; + const hasThen = 'then' in echo; + const hasElse = 'else' in echo; + const conditionalPreserved = hasIf && hasThen && hasElse; + const conditionalStatus = sep2106KeywordCheckStatus( + conditionalPreserved, + targetVersion + ); + checks.push({ + id: 'sep-2106-client-conditional-keywords-preserved', + name: 'JsonSchema2020_12ClientConditionalKeywordsPreserved', + description: + 'Client preserved conditional keywords (if/then/else) on the focal inputSchema', + status: conditionalStatus, + timestamp, + specReferences: [SEP_2106_REF], + errorMessage: conditionalPreserved + ? undefined + : `Conditional keywords missing (if=${hasIf}, then=${hasThen}, else=${hasElse}) — likely stripped` + + (conditionalStatus === 'SKIPPED' ? skippedSuffix : ''), + details: { + hasIf, + hasThen, + hasElse, + targetProtocolVersion: targetVersion + } + }); + + // SEP-2106: $anchor inside $defs.address preserved + const address = defs?.['address'] as Record | undefined; + const hasAnchor = !!address && '$anchor' in address; + const anchorStatus = sep2106KeywordCheckStatus(hasAnchor, targetVersion); + checks.push({ + id: 'sep-2106-client-anchor-keyword-preserved', + name: 'JsonSchema2020_12ClientAnchorKeywordPreserved', + description: 'Client preserved $anchor inside $defs.address', + status: anchorStatus, + timestamp, + specReferences: [SEP_2106_REF], + errorMessage: hasAnchor + ? undefined + : '$anchor missing from echoed $defs.address — reference keyword was likely stripped' + + (anchorStatus === 'SKIPPED' ? skippedSuffix : ''), + details: { + hasAnchor, + anchorValue: address?.['$anchor'] + } + }); + + return checks; + } +} diff --git a/src/scenarios/client/json-schema-ref-deref.test.ts b/src/scenarios/client/json-schema-ref-deref.test.ts new file mode 100644 index 0000000..8445f1b --- /dev/null +++ b/src/scenarios/client/json-schema-ref-deref.test.ts @@ -0,0 +1,146 @@ +import { testScenarioContext } from '../../mock-server/testing'; +import { describe, test, expect } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + runClientAgainstScenario, + InlineClientRunner +} from './auth/test_helpers/testClient'; +import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref'; +import { getScenario } from '../index'; +import { sendStatelessRequest } from '../../connection/stateless'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +/** + * SEP-2106: implementations MUST NOT automatically dereference $ref values + * that resolve to a network URI. + * + * Positive: a compliant client lists tools and never touches the canary URL. + * Negative: a client that walks tool schemas and fetches network $refs must + * produce a FAILURE for sep-2106-no-network-ref-deref. + */ + +/** Recursively collect string `$ref` values that look like network URIs. */ +function collectNetworkRefs(node: unknown, out: string[] = []): string[] { + if (Array.isArray(node)) { + for (const item of node) collectNetworkRefs(item, out); + return out; + } + if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if ( + key === '$ref' && + typeof value === 'string' && + /^https?:/.test(value) + ) { + out.push(value); + } else { + collectNetworkRefs(value, out); + } + } + } + return out; +} + +async function compliantClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + await client.listTools(); + await transport.close(); +} + +async function dereferencingClient(serverUrl: string): Promise { + const client = new Client( + { name: 'deref-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + const result = await client.listTools(); + + // Naive schema processing: resolve every $ref, including network URIs. + // This is exactly the behavior SEP-2106 forbids. + for (const tool of result.tools ?? []) { + for (const ref of collectNetworkRefs(tool.inputSchema)) { + await fetch(ref); + } + } + + await transport.close(); +} + +/** + * SEP-2575 stateless client: probe `server/discover` first, then honor the + * negotiated protocol version on subsequent requests. Regression client for + * issue #397 — the scenario's hand-rolled `server/discover` advertises the + * draft version, so the pinned SDK transport must not reject that version's + * MCP-Protocol-Version header on the follow-up tools/list. + */ +async function statelessDraftClient(serverUrl: string): Promise { + const discover = await sendStatelessRequest(serverUrl, 'server/discover'); + const supportedVersions = ( + discover.body?.result as { supportedVersions?: string[] } | undefined + )?.supportedVersions; + if (!supportedVersions?.includes(DRAFT_PROTOCOL_VERSION)) { + throw new Error( + `server/discover did not advertise ${DRAFT_PROTOCOL_VERSION}: ` + + JSON.stringify(supportedVersions) + ); + } + + // sendStatelessRequest sends MCP-Protocol-Version: by default — + // exactly what a client that honors the negotiated version would do. + const tools = await sendStatelessRequest(serverUrl, 'tools/list'); + if (tools.status !== 200 || tools.body?.result === undefined) { + throw new Error( + `tools/list with the negotiated draft version failed: HTTP ${tools.status} ` + + JSON.stringify(tools.body ?? tools.text) + ); + } +} + +describe('json-schema-ref-no-deref (SEP-2106)', () => { + test('scenario is registered', () => { + expect(getScenario('json-schema-ref-no-deref')).toBeDefined(); + }); + + test('compliant client passes: network $ref is not fetched', async () => { + await runClientAgainstScenario( + new InlineClientRunner(compliantClient), + 'json-schema-ref-no-deref' + ); + }); + + test('stateless draft client passes: negotiated version reaches tools/list (issue #397)', async () => { + await runClientAgainstScenario( + new InlineClientRunner(statelessDraftClient), + 'json-schema-ref-no-deref' + ); + }); + + test('dereferencing client fails: canary fetch is detected', async () => { + await runClientAgainstScenario( + new InlineClientRunner(dereferencingClient), + 'json-schema-ref-no-deref', + { expectedFailureSlugs: ['sep-2106-no-network-ref-deref'] } + ); + }); + + test('client that never lists tools fails: requirement cannot be evaluated', async () => { + const scenario = new JsonSchemaRefDerefScenario(); + await scenario.start(testScenarioContext()); + try { + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === 'sep-2106-no-network-ref-deref' + ); + expect(check?.status).toBe('FAILURE'); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts new file mode 100644 index 0000000..aff791d --- /dev/null +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -0,0 +1,246 @@ +import type { ScenarioContext } from '../../mock-server'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + ListToolsRequestSchema, + LATEST_PROTOCOL_VERSION as SDK_LATEST_PROTOCOL_VERSION +} from '@modelcontextprotocol/sdk/types.js'; +import type { Scenario, ConformanceCheck } from '../../types'; +import express, { Request, Response } from 'express'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../types'; + +/** + * Scenario: JSON Schema network $ref dereferencing (SEP-2106) + * + * SEP-2106 permits the full JSON Schema 2020-12 vocabulary in tool schemas, + * including `$ref`. A `$ref` that resolves to a network URI is an SSRF / + * fetch-DoS vector: a malicious tool definition could point at internal + * infrastructure and have the *client* fetch it during schema processing. + * + * Per SEP-2106, implementations MUST NOT automatically dereference `$ref` + * values that resolve to a network URI (anything that is not a same-document + * JSON Pointer such as `#/$defs/Foo` or an internal `$anchor`). + * + * This scenario advertises a tool whose inputSchema contains both a + * same-document `$ref` and a `$ref` to a canary URL hosted by the scenario + * itself. If the client fetches the canary URL at any point before the + * scenario stops, the check fails. + */ + +const TOOL_NAME = 'lookup_user'; +const CANARY_PATH = '/canary/profile-schema.json'; +const CHECK_ID = 'sep-2106-no-network-ref-deref'; + +const SPEC_REFERENCES = [ + { + id: 'SEP-2106', + url: 'https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications' + } +]; + +function createMcpServer(canaryUrl: string, onToolsListed: () => void): Server { + const server = new Server( + { name: 'json-schema-ref-deref-server', version: '1.0.0' }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + onToolsListed(); + return { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [ + { + name: TOOL_NAME, + description: 'Look up a user profile by id', + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + $defs: { + userId: { type: 'string', pattern: '^[a-z0-9-]+$' } + }, + properties: { + // Same-document $ref: safe, expected to be resolvable locally. + id: { $ref: '#/$defs/userId' }, + // Network $ref: the canary. Implementations MUST NOT fetch this + // automatically while processing the schema. + profile: { $ref: canaryUrl } + }, + required: ['id'] + } + } + ] + }; + }); + + return server; +} + +export class JsonSchemaRefDerefScenario implements Scenario { + name = 'json-schema-ref-no-deref'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Tests that a client does not automatically dereference a network-URI \`$ref\` in a tool's inputSchema (SEP-2106). + +The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at a canary URL. The client should list tools (and may otherwise process the schema), but must not fetch the canary URL. Same-document refs (\`#/$defs/...\`) remain safe to resolve.`; + + private app: express.Application | null = null; + private httpServer: ReturnType | null = null; + private canaryRequests: Array<{ method: string; userAgent?: string }> = []; + private toolsListed = false; + + async start(_ctx: ScenarioContext): Promise { + this.canaryRequests = []; + this.toolsListed = false; + + const app = express(); + app.use(express.json()); + + // Canary endpoint: any request here means the client dereferenced the + // network $ref. Return a valid schema so a dereferencing client gets a + // realistic response rather than an error it might silently swallow. + app.all(CANARY_PATH, (req: Request, res: Response) => { + this.canaryRequests.push({ + method: req.method, + userAgent: req.headers['user-agent'] + }); + res.json({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { displayName: { type: 'string' } } + }); + }); + + app.post('/mcp', async (req: Request, res: Response) => { + // The bundled SDK server below predates the 2026-07-28 lifecycle and + // does not implement server/discover; answer it directly so a client + // that negotiates first can proceed to tools/list. + if ( + (req.body as Record | undefined)?.method === + 'server/discover' + ) { + return res.json({ + jsonrpc: '2.0', + id: (req.body as Record).id ?? null, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { tools: {} }, + serverInfo: { + name: 'json-schema-ref-deref-server', + version: '1.0.0' + } + } + }); + } + // Second half of the same workaround: the pinned SDK transport + // whitelists MCP-Protocol-Version headers and would reject the draft + // version that the server/discover response above advertises with an + // HTTP 400. Rewrite it to the newest version the SDK understands so a + // client that honors the negotiated version can reach tools/list. + if (req.headers['mcp-protocol-version'] === DRAFT_PROTOCOL_VERSION) { + req.headers['mcp-protocol-version'] = SDK_LATEST_PROTOCOL_VERSION; + // The SDK's Node adapter rebuilds its web-standard Request from + // rawHeaders, not the parsed headers object, so patch those too. + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (req.rawHeaders[i].toLowerCase() === 'mcp-protocol-version') { + req.rawHeaders[i + 1] = SDK_LATEST_PROTOCOL_VERSION; + } + } + } + try { + // Stateless: fresh server and transport per request + const server = createMcpServer(this.canaryUrl(), () => { + this.toolsListed = true; + }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: `Internal error: ${error instanceof Error ? error.message : String(error)}` + }, + id: null + }); + } + } + }); + + this.app = app; + this.httpServer = app.listen(0); + return { serverUrl: `${this.baseUrl()}/mcp` }; + } + + private baseUrl(): string { + const address = this.httpServer?.address(); + if (!address || typeof address === 'string') { + throw new Error('Scenario server is not listening'); + } + return `http://localhost:${address.port}`; + } + + private canaryUrl(): string { + return `${this.baseUrl()}${CANARY_PATH}`; + } + + async stop() { + if (this.httpServer) { + await new Promise((resolve) => this.httpServer!.close(resolve)); + this.httpServer = null; + } + this.app = null; + } + + getChecks(): ConformanceCheck[] { + // Built fresh on every call so getChecks() is idempotent — the runner may + // call it more than once and we must not accumulate duplicates. + const timestamp = new Date().toISOString(); + const fetched = this.canaryRequests.length > 0; + + if (!this.toolsListed) { + return [ + { + id: CHECK_ID, + name: 'NoNetworkRefDereference', + description: + 'Client never requested tools/list, so $ref handling could not be evaluated', + status: 'FAILURE', + timestamp, + errorMessage: + 'Client did not call tools/list against a server advertising a tool with a network $ref', + specReferences: SPEC_REFERENCES, + details: { toolsListed: false } + } + ]; + } + + return [ + { + id: CHECK_ID, + name: 'NoNetworkRefDereference', + description: fetched + ? 'Client automatically dereferenced a network-URI $ref in a tool inputSchema. Implementations MUST NOT automatically dereference $ref values that resolve to a network URI (SEP-2106).' + : 'Client did not dereference the network-URI $ref in the tool inputSchema', + status: fetched ? 'FAILURE' : 'SUCCESS', + timestamp, + errorMessage: fetched + ? `Canary URL ${CANARY_PATH} was fetched ${this.canaryRequests.length} time(s)` + : undefined, + specReferences: SPEC_REFERENCES, + details: { + toolsListed: true, + canaryRequestCount: this.canaryRequests.length, + canaryRequests: this.canaryRequests + } + } + ]; + } +} diff --git a/src/scenarios/client/mrtr-client.test.ts b/src/scenarios/client/mrtr-client.test.ts new file mode 100644 index 0000000..46268aa --- /dev/null +++ b/src/scenarios/client/mrtr-client.test.ts @@ -0,0 +1,41 @@ +/** + * Integration test for MRTR client conformance scenario (SEP-2322). + * + * Runs the everything-client's MRTR handler in-process against the scenario server + * and verifies all checks pass. + */ +import { describe, test, expect } from 'vitest'; +import { + runClientAgainstScenario, + InlineClientRunner +} from './auth/test_helpers/testClient'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { getScenario } from '../index'; + +describe('MRTR client scenario (SEP-2322)', () => { + test('everything-client passes sep-2322-client-request-state scenario', async () => { + const clientFn = getHandler('sep-2322-client-request-state'); + if (!clientFn) { + throw new Error( + 'No handler registered for scenario: sep-2322-client-request-state' + ); + } + + const scenario = getScenario('sep-2322-client-request-state'); + if (!scenario) { + throw new Error('Scenario not found: sep-2322-client-request-state'); + } + + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, 'sep-2322-client-request-state'); + + const checks = scenario.getChecks(); + + for (const check of checks) { + expect( + check.status, + `Check "${check.id}" failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + }); +}); diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts new file mode 100644 index 0000000..2bd5692 --- /dev/null +++ b/src/scenarios/client/mrtr-client.ts @@ -0,0 +1,518 @@ +import type { ScenarioContext } from '../../mock-server'; +/** + * SEP-2322: MRTR Client Conformance Tests + * + * Tests that clients correctly handle the MRTR (Multi-Round Tool Resolution) flow: + * - Echo requestState back unchanged when retrying + * - Don't include requestState when server didn't send one + * - Use a different JSON-RPC id on retry + * + * The server exposes two tools. The client calls each tool, gets InputRequiredResult, + * fulfills the elicitation, and retries. The server verifies correct client behavior. + */ + +import type { Scenario, ConformanceCheck } from '../../types'; +import { DRAFT_PROTOCOL_VERSION, ScenarioUrls } from '../../types'; +import express, { Request, Response } from 'express'; +import { randomUUID } from 'crypto'; + +const MRTR_SPEC_REFERENCES = [ + { + id: 'SEP-2322-MRTR', + url: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr' + } +]; + +const TOOLS = [ + { + name: 'test_mrtr_echo_state', + description: + 'Test tool: triggers MRTR flow with requestState. Client must echo state back unchanged.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, + { + name: 'test_mrtr_no_state', + description: + 'Test tool: triggers MRTR flow WITHOUT requestState. Client must NOT include requestState in retry.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, + { + name: 'test_mrtr_unrelated', + description: + 'Test tool: simple tool called between MRTR rounds. Must NOT carry inputResponses or requestState from another tool.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, + { + name: 'test_mrtr_no_result_type', + description: + 'Test tool: returns a result without resultType. Client must treat it as complete (default).', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + } +]; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: Record; +} + +function createMRTRServer(checks: ConformanceCheck[]): express.Application { + const app = express(); + app.use(express.json()); + + // Track original JSON-RPC ids per tool to verify they change on retry + const originalIds = new Map(); + // Track the exact requestState string sent, to verify byte-exact echo on retry + const sentStates = new Map(); + + app.post('/mcp', (req: Request, res: Response) => { + const body = req.body as JsonRpcRequest; + const { id, method, params } = body; + + switch (method) { + case 'server/discover': { + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { tools: {} }, + serverInfo: { name: 'mrtr-mock-server', version: '1.0.0' } + } + }); + return; + } + + case 'notifications/initialized': { + res.status(204).end(); + return; + } + + case 'tools/list': { + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: TOOLS + } + }); + return; + } + + case 'tools/call': { + const toolName = (params as Record)?.name as string; + const inputResponses = (params as Record) + ?.inputResponses as Record | undefined; + const requestState = (params as Record) + ?.requestState as string | undefined; + + if (toolName === 'test_mrtr_echo_state') { + handleEchoState(id, inputResponses, requestState, checks, res); + return; + } + + if (toolName === 'test_mrtr_no_state') { + handleNoState(id, inputResponses, requestState, checks, res); + return; + } + + if (toolName === 'test_mrtr_unrelated') { + handleUnrelated(inputResponses, requestState, checks, res, id); + return; + } + + if (toolName === 'test_mrtr_no_result_type') { + handleNoResultType(id, inputResponses, checks, res); + return; + } + + res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Unknown tool: ${toolName}` } + }); + return; + } + + case 'elicitation/create': { + // Client is fulfilling our ElicitRequest — accept it + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + action: 'accept', + content: { confirmed: true } + } + }); + return; + } + + default: { + res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` } + }); + return; + } + } + }); + + function handleEchoState( + id: string | number, + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response + ) { + if (!inputResponses) { + // Initial call — store original id, return InputRequiredResult with requestState + originalIds.set('echo_state', id); + const state = JSON.stringify({ + nonce: randomUUID(), + originalId: id + }); + sentStates.set('echo_state', state); + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm to continue', + requestedSchema: { + type: 'object', + properties: { + confirmed: { type: 'boolean', description: 'Confirm?' } + } + } + } + } + }, + requestState: state + } + }); + return; + } + + // Retry — verify requestState was echoed back correctly + const originalId = originalIds.get('echo_state'); + const sentState = sentStates.get('echo_state'); + + // Check 1: requestState must be present and byte-for-byte identical to + // what the server sent. The spec requires the client to echo back the + // *exact value* without inspecting, parsing, or modifying it — so a + // semantically-equal but re-serialized string is still a failure. + const stateErrors: string[] = []; + if (!requestState) { + stateErrors.push('Client did not include requestState in retry'); + } else if (requestState !== sentState) { + stateErrors.push( + 'requestState was not echoed back exactly — clients MUST NOT inspect, parse, or modify it' + ); + } + + checks.push({ + id: 'sep-2322-client-request-state-echoed', + name: 'MRTRClientRequestStateEchoed', + description: + 'Client MUST echo back the exact value of requestState when retrying', + status: stateErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: stateErrors.length > 0 ? stateErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + requestStateSent: sentState, + requestStateReceived: requestState, + originalId + } + }); + + // Check 2: JSON-RPC id must differ from original + const idErrors: string[] = []; + if (id === originalId) { + idErrors.push( + `JSON-RPC id is the same on retry (${id}) — MUST be different` + ); + } + + checks.push({ + id: 'sep-2322-client-jsonrpc-id-different', + name: 'MRTRClientJsonRpcIdDifferent', + description: + 'The JSON-RPC id MUST be different between the initial request and the retry', + status: idErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: idErrors.length > 0 ? idErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + originalId, + retryId: id + } + }); + + // Return complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'echo-state-ok' }] + } + }); + } + + function handleNoState( + id: string | number, + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response + ) { + if (!inputResponses) { + // Initial call — return InputRequiredResult WITHOUT requestState + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm to continue (no state test)', + requestedSchema: { + type: 'object', + properties: { + confirmed: { type: 'boolean', description: 'Confirm?' } + } + } + } + } + } + // No requestState field! + } + }); + return; + } + + // Retry — verify client did NOT include requestState + const errors: string[] = []; + if (requestState !== undefined) { + errors.push( + `Client included requestState ("${requestState}") but server did not send one — MUST NOT include it` + ); + } + + checks.push({ + id: 'sep-2322-client-no-state-omitted', + name: 'MRTRClientNoStateOmitted', + description: + 'If InputRequiredResult does not contain requestState, client MUST NOT include one in the retry', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + requestStateReceived: requestState + } + }); + + // Return complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'no-state-ok' }] + } + }); + } + + function handleUnrelated( + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response, + id: string | number + ) { + // This tool should NEVER receive inputResponses or requestState — + // those belong to a different tool's MRTR flow + const errors: string[] = []; + if (inputResponses !== undefined) { + errors.push( + `Unrelated tool call included inputResponses from another tool's MRTR flow` + ); + } + if (requestState !== undefined) { + errors.push( + `Unrelated tool call included requestState from another tool's MRTR flow` + ); + } + + checks.push({ + id: 'sep-2322-client-parallel-isolation', + name: 'MRTRClientParallelIsolation', + description: + 'inputRequests and requestState MUST NOT be used for any other request the client may be sending', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + inputResponsesReceived: inputResponses, + requestStateReceived: requestState + } + }); + + // Return a normal complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'unrelated-ok' }] + } + }); + } + + function handleNoResultType( + id: string | number, + inputResponses: Record | undefined, + checks: ConformanceCheck[], + res: Response + ) { + const checkId = 'sep-2322-default-result-type-complete'; + + // If the client retries this tool, it means it did NOT treat the result as complete + if (inputResponses) { + // Remove any prior SUCCESS for this check (emitted when we first sent the response) + const existingIdx = checks.findIndex((c) => c.id === checkId); + if (existingIdx !== -1) checks.splice(existingIdx, 1); + + checks.push({ + id: checkId, + name: 'DefaultResultTypeComplete', + description: + 'Client MUST assume resultType "complete" when not specified', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Client retried with inputResponses even though the result had no resultType (should default to complete)', + specReferences: MRTR_SPEC_REFERENCES + }); + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'unexpected-retry' }] + } + }); + return; + } + + // Return a result WITHOUT resultType — client should treat as complete + checks.push({ + id: checkId, + name: 'DefaultResultTypeComplete', + description: + 'Client MUST assume resultType "complete" when not specified', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: MRTR_SPEC_REFERENCES + }); + + res.json({ + jsonrpc: '2.0', + id, + result: { + // Deliberately NO resultType field + content: [{ type: 'text', text: 'no-result-type-test-ok' }] + } + }); + } + + return app; +} + +export class MRTRClientScenario implements Scenario { + name = 'sep-2322-client-request-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests client MRTR behavior: requestState echo, no-state omission, and JSON-RPC id uniqueness (SEP-2322)'; + private app: express.Application | null = null; + private httpServer: ReturnType | null = null; + private checks: ConformanceCheck[] = []; + + async start(_ctx: ScenarioContext): Promise { + this.checks = []; + this.app = createMRTRServer(this.checks); + this.httpServer = this.app.listen(0); + const addr = this.httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { serverUrl: `http://localhost:${port}/mcp` }; + } + + async stop() { + if (this.httpServer) { + await new Promise((resolve) => this.httpServer!.close(resolve)); + this.httpServer = null; + } + this.app = null; + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'sep-2322-client-request-state-echoed', + 'sep-2322-client-jsonrpc-id-different', + 'sep-2322-client-no-state-omitted', + 'sep-2322-client-parallel-isolation', + 'sep-2322-default-result-type-complete' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: slug, + description: `MRTR client check: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + details: { + message: 'Tool was not called by client or MRTR flow not completed' + }, + specReferences: MRTR_SPEC_REFERENCES + }); + } + } + return this.checks; + } +} diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts new file mode 100644 index 0000000..0227d3f --- /dev/null +++ b/src/scenarios/client/request-metadata.test.ts @@ -0,0 +1,342 @@ +import { testScenarioContext } from '../../mock-server/testing'; +import { describe, test, expect } from 'vitest'; +import { + runClientAgainstScenario, + InlineClientRunner +} from './auth/test_helpers/testClient'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { getScenario } from '../index'; +import { + DECLARED_CHECK_IDS, + RequestMetadataScenario +} from './request-metadata'; + +// A bad client that does not send _meta +async function badClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} // Missing _meta + }) + }); + return response.json(); +} + +const goodMeta = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} +}; + +// A client that misses the HTTP header +async function missingHeaderClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, // Missing MCP-Protocol-Version header + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: goodMeta } + }) + }); + return response.json(); +} + +// A client whose header disagrees with _meta.protocolVersion +async function mismatchedHeaderClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2025-11-25' // != _meta.protocolVersion + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: goodMeta } + }) + }); + return response.json(); +} + +// A client that fails to negotiate/retry on a 400 response +async function nonRetryingClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2026-07-28' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: goodMeta } + }) + }); + return response.json(); +} + +// A client that has empty version intersection and terminates +async function incompatibleVersionClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': 'UNSUPPORTED-VERSION' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + ...goodMeta, + 'io.modelcontextprotocol/protocolVersion': 'UNSUPPORTED-VERSION' + } + } + }) + }); + + if (response.status === 400) { + const body = await response.json(); + if (body.error?.code === -32022) { + return body; // Abort cleanly + } + } + return response.json(); +} + +// A client that sends invalid (non-object) capabilities +async function malformedCapabilitiesClient(serverUrl: string) { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2026-07-28' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { + _meta: { + ...goodMeta, + 'io.modelcontextprotocol/clientCapabilities': { + roots: 'malformed-string', + sampling: {}, + elicitation: true + } + } + } + }) + }); + return response.json(); +} + +describe('request-metadata client scenario — positive test', () => { + test('everything-client passes request-metadata scenario with success status for optional capabilities', async () => { + const clientFn = getHandler('request-metadata'); + if (!clientFn) { + throw new Error('No handler registered for scenario: request-metadata'); + } + + const scenario = getScenario('request-metadata'); + if (!scenario) { + throw new Error('Scenario not found'); + } + + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, 'request-metadata'); + + // Extract checks directly from the scenario instance + const checks = scenario.getChecks(); + + // 4-Line Bulk Assertion Loop + for (const check of checks) { + expect(check.status).not.toBe('FAILURE'); + expect(check.status).not.toBe('WARNING'); + } + + // Strategic Targeted Optional Assertions + expect( + checks.find((c) => c.id === 'sep-2575-client-declares-roots-capability') + ?.status + ).toBe('SUCCESS'); + expect( + checks.find( + (c) => c.id === 'sep-2575-client-declares-sampling-capability' + )?.status + ).toBe('SUCCESS'); + expect( + checks.find( + (c) => c.id === 'sep-2575-client-declares-elicitation-capability' + )?.status + ).toBe('SUCCESS'); + + // Assert version negotiation retry succeeded + expect( + checks.find((c) => c.id === 'sep-2575-client-retry-supported-version') + ?.status + ).toBe('SUCCESS'); + + // Declared ↔ emitted completeness, both directions: every declared check + // is genuinely observed, and every emitted check is declared. + for (const check of checks) { + expect(DECLARED_CHECK_IDS).toContain(check.id); + expect(check.errorMessage ?? '').not.toContain('not observed'); + } + expect(new Set(checks.map((c) => c.id))).toEqual( + new Set(DECLARED_CHECK_IDS) + ); + }); +}); + +describe('request-metadata client scenario — client never connects', () => { + // A client with no handler for this scenario exits without sending a + // request. Every declared check must still be emitted (as FAILURE) instead + // of reporting "0 passed, 0 failed". + test('emits every declared check as FAILURE when no request is received', async () => { + const scenario = getScenario('request-metadata'); + if (!scenario) { + throw new Error('Scenario not found'); + } + + await scenario.start(testScenarioContext()); + try { + const checks = scenario.getChecks(); + const byId = new Map(checks.map((c) => [c.id, c])); + + for (const id of DECLARED_CHECK_IDS) { + const check = byId.get(id); + expect(check, `expected check ${id} to be emitted`).toBeDefined(); + expect(check?.status, `expected ${id} to be FAILURE`).toBe('FAILURE'); + expect(check?.errorMessage).toContain('never sent a request'); + } + expect(checks).toHaveLength(DECLARED_CHECK_IDS.length); + } finally { + await scenario.stop(); + } + }); + + test('does not overwrite checks recorded from a real request', async () => { + const runner = new InlineClientRunner(badClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: [ + 'sep-2575-client-populates-meta', + 'sep-2575-http-client-sends-version-header' + ] + }); + + const scenario = getScenario('request-metadata'); + const checks = scenario!.getChecks(); + // badClient connects, so its failures must carry the observed request's + // details, not the "never sent a request" backfill. + const populatesMeta = checks.find( + (c) => c.id === 'sep-2575-client-populates-meta' + ); + expect(populatesMeta?.errorMessage ?? '').not.toContain( + 'never sent a request' + ); + }); +}); + +describe('request-metadata client scenario — HTTP handling', () => { + test.each(['GET', 'DELETE'])( + 'rejects an empty-body %s request without parsing it as JSON', + async (method) => { + const scenario = new RequestMetadataScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + + try { + const response = await fetch(serverUrl, { method }); + + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + expect(await response.text()).toBe('Method Not Allowed'); + } finally { + await scenario.stop(); + } + } + ); + + test('returns a JSON-RPC parse error for malformed POST bodies', async () => { + const scenario = new RequestMetadataScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + + try { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{' + }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + jsonrpc: '2.0', + id: null, + error: { code: -32700 } + }); + } finally { + await scenario.stop(); + } + }); +}); + +describe('request-metadata client scenario — negative tests', () => { + test('client fails when omitting _meta', async () => { + const runner = new InlineClientRunner(badClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: [ + 'sep-2575-client-populates-meta', + 'sep-2575-http-client-sends-version-header' + ] + }); + }); + + test('client fails when missing version header', async () => { + const runner = new InlineClientRunner(missingHeaderClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-http-client-sends-version-header'] + }); + }); + + test('client fails when header disagrees with _meta', async () => { + const runner = new InlineClientRunner(mismatchedHeaderClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-http-version-header-matches-meta'] + }); + }); + + test('client fails retry check when it does not handle 400 rejection', async () => { + const runner = new InlineClientRunner(nonRetryingClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-client-retry-supported-version'] + }); + }); + + test('client aborts cleanly without hanging when negotiation has empty version intersection', async () => { + const runner = new InlineClientRunner(incompatibleVersionClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: ['sep-2575-client-retry-supported-version'] + }); + }); + + test('client triggers failures for malformed capabilities', async () => { + const runner = new InlineClientRunner(malformedCapabilitiesClient); + await runClientAgainstScenario(runner, 'request-metadata', { + expectedFailureSlugs: [ + 'sep-2575-client-declares-roots-capability', + 'sep-2575-client-declares-elicitation-capability' + ] + }); + }); +}); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts new file mode 100644 index 0000000..54673b0 --- /dev/null +++ b/src/scenarios/client/request-metadata.ts @@ -0,0 +1,400 @@ +import { + withRequiredDraftResultFields, + type ScenarioContext +} from '../../mock-server'; +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + CheckStatus, + DRAFT_PROTOCOL_VERSION +} from '../../types'; + +/** + * Severity ranking used to latch per-id check results: a single + * non-conformant request is a violation even if later requests are + * conformant, so a later better status must never overwrite a worse one. + */ +const STATUS_SEVERITY: Record = { + FAILURE: 3, + WARNING: 2, + SUCCESS: 1, + INFO: 1, + SKIPPED: 0 +}; + +/** + * Every check ID this scenario can emit. Declared-but-unemitted checks are + * backfilled as FAILURE by getChecks(), so the emitted ID set is the same for + * every client. The positive-path test asserts this list is exact. + */ +export const DECLARED_CHECK_IDS = [ + 'sep-2575-http-client-sends-version-header', + 'sep-2575-client-populates-meta', + 'sep-2575-client-sends-client-info', + 'sep-2575-http-version-header-matches-meta', + 'sep-2575-client-declares-roots-capability', + 'sep-2575-client-declares-sampling-capability', + 'sep-2575-client-declares-elicitation-capability', + 'sep-2575-client-retry-supported-version' +] as const; + +export class RequestMetadataScenario implements Scenario { + name = 'request-metadata'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Per-request _meta and MCP-Protocol-Version header obligations (SEP-2575)'; + + private server: http.Server | null = null; + private checks: ConformanceCheck[] = []; + private hasSimulatedRejection = false; + private requestsObserved = 0; + + async start(_ctx: ScenarioContext): Promise { + this.hasSimulatedRejection = false; + this.checks = []; + this.requestsObserved = 0; + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + this.server.on('error', reject); + this.server.listen(0, () => { + const address = this.server!.address(); + if (address && typeof address === 'object') { + resolve({ serverUrl: `http://localhost:${address.port}` }); + } + }); + }); + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => { + resolve(); + }); + } else { + resolve(); + } + }); + } + + getChecks(): ConformanceCheck[] { + // Declared but never emitted -> FAILURE. A check that is legitimately not + // applicable must be emitted as SKIPPED explicitly to avoid this. + for (const id of DECLARED_CHECK_IDS) { + if (!this.checks.some((c) => c.id === id)) { + this.checks.push({ + id, + name: 'NotObserved', + description: `Declared check ${id} was never emitted`, + status: 'FAILURE', + errorMessage: + this.requestsObserved === 0 + ? 'Check was not observed: the client never sent a request to the scenario server (no handler registered for this scenario?)' + : 'Check was not observed: no request exercised it', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/index#meta' + } + ], + details: { observed: false, requestsObserved: this.requestsObserved } + }); + } + } + return this.checks; + } + + private addOrUpdateCheck(check: ConformanceCheck): void { + const index = this.checks.findIndex((c) => c.id === check.id); + if (index === -1) { + this.checks.push(check); + return; + } + // Keep the worst status observed for this id (FAILURE > WARNING > SUCCESS + // > SKIPPED): an equal-or-worse result replaces the stored check (so its + // details stay fresh), but a better result must not erase a violation + // recorded from an earlier request. + const existing = this.checks[index]; + if (STATUS_SEVERITY[check.status] >= STATUS_SEVERITY[existing.status]) { + this.checks[index] = check; + } + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + // This scenario only evaluates the metadata attached to POST requests. + // Clients may probe a Streamable HTTP endpoint with an empty-body GET + // before sending MCP requests, so reject unsupported methods before + // attempting to parse a JSON-RPC body. + if (req.method !== 'POST') { + res.writeHead(405, { Allow: 'POST' }); + res.end('Method Not Allowed'); + return; + } + + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + let request; + try { + request = JSON.parse(body); + } catch (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { + code: -32700, + message: `Parse error: ${String(error)}` + } + }) + ); + return; + } + + this.requestsObserved++; + + // Extract version and headers + const meta = request.params?._meta; + const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; + const headerVersion = req.headers['mcp-protocol-version']; + + // 1. "Every POST request to the MCP endpoint MUST include an + // MCP-Protocol-Version header." — unconditional, so this fires for + // server/discover too. + this.addOrUpdateCheck({ + id: 'sep-2575-http-client-sends-version-header', + name: 'ClientSendsVersionHeader', + description: 'Client sends MCP-Protocol-Version header on every POST', + status: headerVersion !== undefined ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header' + } + ], + details: { method: request.method, headerVersion } + }); + + // 2. "Every client request MUST include the following + // io.modelcontextprotocol/* fields in _meta: protocolVersion, + // clientCapabilities." clientInfo is a SHOULD since spec PR #3002 and + // is tracked by its own check below, so requirement levels never blend + // in one check id. + const hasClientInfo = meta?.['io.modelcontextprotocol/clientInfo']; + const hasCapabilities = + meta?.['io.modelcontextprotocol/clientCapabilities']; + const metaIsValid = metaVersion && hasCapabilities; + + this.addOrUpdateCheck({ + id: 'sep-2575-client-populates-meta', + name: 'ClientPopulatesMeta', + description: + 'Client populates _meta on every request with the required fields (protocolVersion, clientCapabilities)', + status: metaIsValid ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/index#meta' + } + ], + details: { method: request.method, meta } + }); + + // 2b. clientInfo SHOULD (spec PR #3002): absence is a WARNING, never a + // FAILURE — a dedicated id so adoption is trackable on its own. + this.addOrUpdateCheck({ + id: 'sep-2575-client-sends-client-info', + name: 'ClientSendsClientInfo', + description: + "Client SHOULD include io.modelcontextprotocol/clientInfo in every request's _meta (spec PR #3002)", + status: hasClientInfo ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/index#meta' + } + ], + details: { method: request.method, meta } + }); + + // 3. "The header value MUST match the io.modelcontextprotocol/protocolVersion + // field carried in the request body's _meta." Only comparable when both + // are present; absence is already covered by the two checks above, so + // emit SKIPPED rather than falling through to the declared-check failure. + const bothVersionsPresent = + headerVersion !== undefined && metaVersion !== undefined; + this.addOrUpdateCheck({ + id: 'sep-2575-http-version-header-matches-meta', + name: 'ClientVersionHeaderMatchesMeta', + description: + 'MCP-Protocol-Version header matches _meta.protocolVersion', + status: !bothVersionsPresent + ? 'SKIPPED' + : headerVersion === metaVersion + ? 'SUCCESS' + : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header' + } + ], + details: { headerVersion, metaVersion } + }); + + // 4. Optional client capabilities conditional verification + const capabilities = meta?.['io.modelcontextprotocol/clientCapabilities']; + const checkOptionalCapability = ( + capabilityName: string, + checkId: string, + checkName: string + ) => { + let status: 'SUCCESS' | 'FAILURE' | 'SKIPPED' = 'SKIPPED'; + if (capabilities && capabilityName in capabilities) { + const val = capabilities[capabilityName]; + const isValidObject = + typeof val === 'object' && val !== null && !Array.isArray(val); + status = isValidObject ? 'SUCCESS' : 'FAILURE'; + } + this.addOrUpdateCheck({ + id: checkId, + name: checkName, + description: `Client declares valid ${capabilityName} capability if present`, + status, + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/index#capabilities' + } + ], + details: { capabilityValue: capabilities?.[capabilityName] } + }); + }; + + checkOptionalCapability( + 'roots', + 'sep-2575-client-declares-roots-capability', + 'ClientDeclaresRootsCapability' + ); + checkOptionalCapability( + 'sampling', + 'sep-2575-client-declares-sampling-capability', + 'ClientDeclaresSamplingCapability' + ); + checkOptionalCapability( + 'elicitation', + 'sep-2575-client-declares-elicitation-capability', + 'ClientDeclaresElicitationCapability' + ); + + // 5. Simulated Version Negotiation Retry Check + if (!this.hasSimulatedRejection) { + this.hasSimulatedRejection = true; + + this.addOrUpdateCheck({ + id: 'sep-2575-client-retry-supported-version', + name: 'ClientRetrySupportedVersion', + description: + 'Client retries with a supported version when first choice is rejected', + status: 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header' + } + ], + details: { headerVersion } + }); + + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id ?? null, + error: { + // UnsupportedProtocolVersionError per the draft schema. + code: -32022, + message: 'Unsupported protocol version', + data: { + supported: [DRAFT_PROTOCOL_VERSION], + requested: String(headerVersion ?? metaVersion ?? '') + } + } + }) + ); + return; + } + + const retryCheck = this.checks.find( + (c) => c.id === 'sep-2575-client-retry-supported-version' + ); + if (retryCheck) { + if ( + headerVersion === DRAFT_PROTOCOL_VERSION && + metaVersion === DRAFT_PROTOCOL_VERSION + ) { + retryCheck.status = 'SUCCESS'; + } else { + retryCheck.status = 'WARNING'; + } + retryCheck.details = { + ...retryCheck.details, + retryHeaderVersion: headerVersion, + retryMetaVersion: metaVersion + }; + } + + // server/discover is optional for clients (spec: "Clients MAY call it"), + // so no check is emitted; we still respond so a client that does call it + // proceeds normally and exercises the per-request _meta/header checks above. + if (request.method === 'server/discover') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: withRequiredDraftResultFields(request.method, { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: {}, + serverInfo: { name: 'test', version: '1.0' } + }) + }) + ); + return; + } + + // Return generic response to unblock client + let result: object = {}; + if (request.method === 'tools/list') { + result = { tools: [] }; + } else if (request.method === 'tools/call') { + result = { content: [] }; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: withRequiredDraftResultFields(request.method, result) + }) + ); + }); + } +} diff --git a/src/scenarios/client/skills/capabilities.test.ts b/src/scenarios/client/skills/capabilities.test.ts new file mode 100644 index 0000000..e1bdb90 --- /dev/null +++ b/src/scenarios/client/skills/capabilities.test.ts @@ -0,0 +1,83 @@ +import { describe, test, expect } from 'vitest'; +import { testScenarioContext } from '../../../mock-server/testing'; +import { sendStatelessRequest } from '../../../connection/stateless'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { SkillsNoPrefetchScenario } from './no-prefetch'; +import { SkillsVerificationScenario } from './verification'; + +/** + * Regression for PR #330 review: both skills client scenarios declared the + * skills extension only in their `initialize` reply, but `BaseHttpScenario` + * intercepts `server/discover` before `handlePost` runs. A 2026-07-28 + * discover-first client therefore saw the base class default (`{tools: {}}`) + * and had nothing to gate a `skills/list` call on. + * + * The scenarios passed anyway because `server/discover` is optional for + * clients and the clients we ran did not use it. That is exactly the failure + * mode where a check goes green because its condition never arose, so both + * lifecycles are asserted here rather than left to the next client to notice. + */ + +const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +type Caps = { + resources?: unknown; + extensions?: Record; +}; + +function capsOf(result: unknown): Caps { + return ((result as { capabilities?: Caps })?.capabilities ?? {}) as Caps; +} + +function expectSkillsAdvertised(caps: Caps, where: string): void { + expect(caps.extensions, `${where}: no extensions block`).toBeDefined(); + expect( + caps.extensions?.[SKILLS_EXTENSION_ID], + `${where}: skills extension not advertised` + ).toBeDefined(); + // resources/read is how every skill file is fetched, so a client that gates + // on capabilities needs this too. + expect(caps.resources, `${where}: resources not advertised`).toBeDefined(); +} + +const SCENARIOS: Array< + [string, () => SkillsNoPrefetchScenario | SkillsVerificationScenario] +> = [ + ['no-prefetch', () => new SkillsNoPrefetchScenario()], + ['verify-digest', () => new SkillsVerificationScenario('digest')], + ['verify-size', () => new SkillsVerificationScenario('size')], + ['verify-frontmatter', () => new SkillsVerificationScenario('frontmatter')] +]; + +describe('SEP-2640 client scenarios advertise skills on both lifecycles', () => { + test.each(SCENARIOS)('%s: server/discover', async (label, make) => { + const scenario = make(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const discover = await sendStatelessRequest(serverUrl, 'server/discover'); + expect(discover.status).toBe(200); + expectSkillsAdvertised( + capsOf(discover.body?.result), + `${label} discover` + ); + } finally { + await scenario.stop(); + } + }); + + test.each(SCENARIOS)('%s: initialize', async (label, make) => { + const scenario = make(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const init = await sendStatelessRequest(serverUrl, 'initialize', { + protocolVersion: DRAFT_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'capabilities-test', version: '1.0.0' } + }); + expect(init.status).toBe(200); + expectSkillsAdvertised(capsOf(init.body?.result), `${label} initialize`); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/skills/mock-results.ts b/src/scenarios/client/skills/mock-results.ts new file mode 100644 index 0000000..c2d7b3e --- /dev/null +++ b/src/scenarios/client/skills/mock-results.ts @@ -0,0 +1,52 @@ +/** + * Results for the hand-rolled SEP-2640 client-scenario mocks, shaped like a + * conformant server's so a strict client is graded on its skills behaviour + * rather than turned away by the harness. + */ + +import { + DRAFT_PROTOCOL_VERSION, + NEGOTIABLE_PROTOCOL_VERSIONS +} from '../../../types.js'; +import { withRequiredDraftResultFields } from '../../../mock-server/index.js'; + +/** + * `ListSkillsResult` is cacheable like `resources/list`, but `skills/list` is + * an extension method and not in the shared cacheable set, so the caching + * members are stamped here. + */ +export function skillsListResult(skills: object[]): object { + return { resultType: 'complete', ttlMs: 0, cacheScope: 'private', skills }; +} + +export function readResult(uri: unknown, text: string): unknown { + return withRequiredDraftResultFields('resources/read', { + contents: [{ uri, mimeType: 'text/markdown', text }] + }); +} + +/** + * The `initialize` result at the version the client asked for, when the suite + * can speak it. The extension has no 2026-07-28 dependency, and the runner + * hands these scenarios' clients 2025-11-25 unless `--spec-version` says + * otherwise, so a fixed 2026-07-28 reply would turn a correct stateful client + * away before it ever lists. + */ +export function initializeResult( + name: string, + request: { params?: { protocolVersion?: unknown } }, + capabilities: object +): object { + const requested = request.params?.protocolVersion; + const protocolVersion = + typeof requested === 'string' && + NEGOTIABLE_PROTOCOL_VERSIONS.includes(requested) + ? requested + : DRAFT_PROTOCOL_VERSION; + return { + resultType: 'complete', + protocolVersion, + serverInfo: { name: `${name}-server`, version: '1.0.0' }, + capabilities + }; +} diff --git a/src/scenarios/client/skills/no-prefetch.ts b/src/scenarios/client/skills/no-prefetch.ts new file mode 100644 index 0000000..0128203 --- /dev/null +++ b/src/scenarios/client/skills/no-prefetch.ts @@ -0,0 +1,189 @@ +/** + * SEP-2640 client scenario: hosts MUST NOT retrieve a skill's files ahead of + * need. + * + * This is a `Scenario` rather than a `ClientScenario`: the harness stands up + * the server and the client is the system under test. The rule reduces to + * "did a request arrive", which makes it one of the most wire-observable + * obligations in the SEP despite the first traceability pass filing it as an + * unobservable host obligation. + * + * Contract for the client under test, keyed on MCP_CONFORMANCE_SCENARIO: + * connect, call `skills/list`, then exit. Do not load a skill. A client that + * prefetches will read `SKILL.md` or a supporting file during that window and + * fail the check. + */ + +import http from 'http'; +import { ConformanceCheck } from '../../../types.js'; +import { BaseHttpScenario } from '../http-base.js'; +import { + initializeResult, + readResult, + skillsListResult +} from './mock-results.js'; + +const SPEC_REFERENCE = { + id: 'SEP-2640-Lazy-Retrieval', + url: 'https://modelcontextprotocol.io/seps/2640-skills-extension#integrity-and-verification' +}; + +const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +/** One skill with a supporting file, so a prefetch has something to grab. */ +const SKILL_URI = 'skill://pdf-processing/SKILL.md'; +const SUPPORTING_URI = 'skill://pdf-processing/references/FORMS.md'; + +const SKILL_MD = `--- +name: pdf-processing +description: Extract, fill, and assemble PDF documents +--- + +Body the client has no business fetching yet. +`; + +const SUPPORTING = + 'Supporting content the client has no business fetching yet.\n'; + +/** sha256 of the two bodies, computed at module load so the entry is honest. */ +import { createHash } from 'crypto'; +const digestOf = (s: string) => + 'sha256:' + createHash('sha256').update(s, 'utf8').digest('hex'); + +export class SkillsNoPrefetchScenario extends BaseHttpScenario { + name = 'sep-2640-client-no-prefetch'; + description = + 'A client MUST NOT retrieve a skill file before the skill is loaded'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + + /** Every resources/read URI the client asked for, in order. */ + private readsRequested: string[] = []; + private listCalled = false; + + /** + * Advertised on both lifecycles. `server/discover` is intercepted by the + * base class before `handlePost` runs, so declaring these only in the + * `initialize` reply left a 2026-07-28 discover-first client seeing a + * tools-only server, with nothing to gate a `skills/list` call on. + */ + protected discoverCapabilities(): object { + return { + resources: { listChanged: false }, + extensions: { [SKILLS_EXTENSION_ID]: {} } + }; + } + + protected handlePost( + _req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + switch (request.method) { + case 'initialize': + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: initializeResult( + this.name, + request, + this.discoverCapabilities() + ) + }); + return; + + case 'skills/list': + this.listCalled = true; + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: skillsListResult([ + { + uri: SKILL_URI, + frontmatter: { + name: 'pdf-processing', + description: 'Extract, fill, and assemble PDF documents' + }, + resources: [ + { + uri: SKILL_URI, + digest: digestOf(SKILL_MD), + size: Buffer.byteLength(SKILL_MD) + }, + { + uri: SUPPORTING_URI, + digest: digestOf(SUPPORTING), + size: Buffer.byteLength(SUPPORTING) + } + ] + } + ]) + }); + return; + + // Served, but reaching it during this scenario is the failure. + case 'resources/read': { + const uri = request.params?.uri; + if (typeof uri === 'string') this.readsRequested.push(uri); + const body = uri === SUPPORTING_URI ? SUPPORTING : SKILL_MD; + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: readResult(uri, body) + }); + return; + } + + default: + if (request.id === undefined) { + this.sendNotificationAck(res); + return; + } + this.sendGenericResult(res, request); + } + } + + getChecks(): ConformanceCheck[] { + const DESC = + "Hosts MUST NOT retrieve a skill's files ahead of need, not on connection, not on listing, and not at approval."; + + // Without a listing there is no window in which prefetching is even + // possible, so the run proves nothing rather than passing. + if (!this.listCalled) { + return [ + { + id: 'sep-2640-host-no-prefetch', + name: 'SkillsClientNoPrefetch', + description: DESC, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: + 'the client never called skills/list, so no retrieval window was opened', + specReferences: [SPEC_REFERENCE] + } + ]; + } + + const prefetched = this.readsRequested.filter( + (u) => u === SKILL_URI || u === SUPPORTING_URI + ); + + return [ + { + id: 'sep-2640-host-no-prefetch', + name: 'SkillsClientNoPrefetch', + description: DESC, + status: prefetched.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + prefetched.length === 0 + ? undefined + : `client read ${prefetched.length} skill file(s) without loading a skill: ${prefetched.join(', ')}`, + specReferences: [SPEC_REFERENCE], + details: { + skillsListCalled: true, + fileReads: prefetched.length + } + } + ]; + } +} diff --git a/src/scenarios/client/skills/verification.ts b/src/scenarios/client/skills/verification.ts new file mode 100644 index 0000000..ee6d2d1 --- /dev/null +++ b/src/scenarios/client/skills/verification.ts @@ -0,0 +1,280 @@ +/** + * SEP-2640 client scenarios for the read-time verification MUSTs. + * + * Each variant serves a listing that is internally honest, then tampers with + * exactly one thing on the wire and watches whether the client notices. The + * tamper always targets the skill's `SKILL.md`, and the client's contract is + * "load the skill, then read its supporting file". A client that verifies + * fails at the first step and never reaches the second, so **rejection is + * detected by the absence of the supporting-file read**, the same way + * `auth/resource-mismatch` detects rejection by the absence of an + * authorization request. + * + * Absence only means something once the client has actually loaded the skill. + * A client that lists and exits produces the same empty read log as one that + * verified and stopped, so every variant requires the manifest read as a + * prerequisite and reports untestable without it (#248). Before that gate a + * client implementing `skills/list` and nothing else passed every variant + * here. + * + * A fourth variant, `unlisted`, was retired 2026-09-10. Nothing was tampered + * in it, so the only thing under test was whether the driver asked for a URI + * it should not have, which grades the driver rather than the client. The + * requirement is recorded as excluded in src/seps/sep-2640.yaml. + */ + +import { createHash } from 'crypto'; +import http from 'http'; +import { ConformanceCheck } from '../../../types.js'; +import { untestableCheck } from '../../untestable.js'; +import { BaseHttpScenario } from '../http-base.js'; +import { + initializeResult, + readResult, + skillsListResult +} from './mock-results.js'; + +const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; + +const SPEC_REFERENCE = { + id: 'SEP-2640-Verification-On-Read', + url: 'https://modelcontextprotocol.io/seps/2640-skills-extension#integrity-and-verification' +}; + +const SKILL_URI = 'skill://pdf-processing/SKILL.md'; +const SUPPORTING_URI = 'skill://pdf-processing/references/FORMS.md'; + +const SKILL_MD = `--- +name: pdf-processing +description: Extract, fill, and assemble PDF documents +--- + +Skill body. +`; + +const SUPPORTING = 'Supporting content.\n'; + +const sha256 = (s: string) => + 'sha256:' + createHash('sha256').update(s, 'utf8').digest('hex'); + +export type VerificationMode = 'digest' | 'size' | 'frontmatter'; + +const MODES: Record< + VerificationMode, + { scenario: string; checkId: string; description: string } +> = { + digest: { + scenario: 'sep-2640-client-verify-digest', + checkId: 'sep-2640-host-verify-digest', + description: + "When a host retrieves a file listed in a skill's resources, it MUST verify the content against that entry's digest, and MUST NOT use unverified content." + }, + size: { + scenario: 'sep-2640-client-verify-size', + checkId: 'sep-2640-host-size-mismatch-failure', + description: + "A read whose byte length differs from the entry's size is a verification failure equivalent to a digest mismatch, whether or not the host goes on to compute the digest." + }, + frontmatter: { + scenario: 'sep-2640-client-verify-frontmatter', + checkId: 'sep-2640-host-frontmatter-comparison', + description: + "After fetching a SKILL.md for which the host holds an entry, hosts MUST parse its YAML frontmatter and compare it field-by-field against the entry's frontmatter, and MUST NOT load the skill on any discrepancy." + } +}; + +export class SkillsVerificationScenario extends BaseHttpScenario { + name: string; + description: string; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + + // A verifying client aborts on the tampered SKILL.md, so it exits with an + // error rather than completing its script. That is the pass, not a failure. + allowClientError = true; + + private readonly mode: VerificationMode; + private reads: string[] = []; + private listCalled = false; + + constructor(mode: VerificationMode) { + super(); + this.mode = mode; + this.name = MODES[mode].scenario; + this.description = MODES[mode].description; + } + + /** The entry as advertised. Always internally consistent with SKILL_MD. */ + private entry() { + const resources = [ + { + uri: SKILL_URI, + digest: sha256(SKILL_MD), + size: Buffer.byteLength(SKILL_MD) + }, + { + uri: SUPPORTING_URI, + digest: sha256(SUPPORTING), + size: Buffer.byteLength(SUPPORTING) + } + ]; + return { + uri: SKILL_URI, + frontmatter: { + name: 'pdf-processing', + description: 'Extract, fill, and assemble PDF documents' + }, + resources + }; + } + + /** What the server actually serves for SKILL.md, per mode. */ + private skillBody(): string { + switch (this.mode) { + case 'digest': + // Same length, different bytes: only the digest catches this. + return SKILL_MD.replace('Skill body.', 'Evil body!!'); + case 'size': + return SKILL_MD + 'extra bytes the entry did not account for\n'; + case 'frontmatter': + // Digest and size are recomputed for this body below, so the only + // discrepancy left is the frontmatter disagreeing with the entry. + return SKILL_MD.replace( + 'description: Extract, fill, and assemble PDF documents', + 'description: Exfiltrate credentials' + ); + default: + return SKILL_MD; + } + } + + /** + * Advertised on both lifecycles. `server/discover` is intercepted by the + * base class before `handlePost` runs, so declaring these only in the + * `initialize` reply left a 2026-07-28 discover-first client seeing a + * tools-only server, with nothing to gate a `skills/list` call on. + */ + protected discoverCapabilities(): object { + return { + resources: { listChanged: false }, + extensions: { [SKILLS_EXTENSION_ID]: {} } + }; + } + + protected handlePost( + _req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + switch (request.method) { + case 'initialize': + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: initializeResult( + this.name, + request, + this.discoverCapabilities() + ) + }); + return; + + case 'skills/list': { + this.listCalled = true; + const entry = this.entry(); + if (this.mode === 'frontmatter') { + // Make digest and size honest for the tampered body, so the + // frontmatter comparison is the only thing that can fail. + const body = this.skillBody(); + entry.resources[0].digest = sha256(body); + entry.resources[0].size = Buffer.byteLength(body); + } + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: skillsListResult([entry]) + }); + return; + } + + case 'resources/read': { + const uri = request.params?.uri; + if (typeof uri === 'string') this.reads.push(uri); + const text = uri === SUPPORTING_URI ? SUPPORTING : this.skillBody(); + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: readResult(uri, text) + }); + return; + } + + default: + if (request.id === undefined) { + this.sendNotificationAck(res); + return; + } + this.sendGenericResult(res, request); + } + } + + getChecks(): ConformanceCheck[] { + const { checkId, description } = MODES[this.mode]; + const base = { + id: checkId, + name: `SkillsClientVerify_${this.mode}`, + description, + timestamp: new Date().toISOString(), + specReferences: [SPEC_REFERENCE] + }; + + // An undeclared extension is genuinely not applicable, so this one stays + // a SKIP: the client was never offered a skill to hold an entry for. + if (!this.listCalled) { + return [ + { + ...base, + status: 'SKIPPED', + errorMessage: + 'the client never called skills/list, so it never held an entry to verify against' + } + ]; + } + + // The prerequisite, and the reason this scenario is worth anything. + // + // Rejection is detected by the absence of the supporting-file read, and a + // client that never loaded the skill produces exactly the same empty read + // log as one that loaded it and correctly stopped. Reporting SUCCESS for + // both means a client implementing `skills/list` and nothing else passes + // every variant here, which is what happened until 2026-09-10. + // + // A missing prerequisite is a FAILURE rather than a SKIP, per #248: + // SKIPPED is excluded from pass/fail counts and exit codes, so it would + // read as green and hide the gap from anyone burning down a list. + if (!this.reads.includes(SKILL_URI)) { + return [ + untestableCheck( + checkId, + base.name, + description, + `the client never read ${SKILL_URI}, so it never loaded the tampered skill and had nothing to reject. Absence of the supporting-file read proves nothing here.`, + [SPEC_REFERENCE] + ) + ]; + } + + // Proven by the client stopping before the supporting file, which it + // would only reach by having accepted the tampered SKILL.md. + const violated = this.reads.includes(SUPPORTING_URI); + const evidence = `client continued to ${SUPPORTING_URI} after being served a SKILL.md that fails ${this.mode} verification, so it did not reject the content`; + + return [ + { + ...base, + status: violated ? 'FAILURE' : 'SUCCESS', + errorMessage: violated ? evidence : undefined, + details: { mode: this.mode, reads: this.reads } + } + ]; + } +} diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index c8f0792..3cee1e1 100644 --- a/src/scenarios/client/sse-retry.ts +++ b/src/scenarios/client/sse-retry.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * SSE Retry conformance test scenarios for MCP clients (SEP-1699) * @@ -12,12 +13,15 @@ import { Scenario, ScenarioUrls, ConformanceCheck, - SpecVersion + DRAFT_PROTOCOL_VERSION } from '../../types.js'; export class SSERetryScenario implements Scenario { name = 'sse-retry'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)'; @@ -38,12 +42,10 @@ export class SSERetryScenario implements Scenario { private pendingToolCallId: number | string | null = null; private getResponseStream: http.ServerResponse | null = null; - // Tolerances for timing validation + // Tolerance for timing validation (early side only; lateness is not gated) private readonly EARLY_TOLERANCE = 50; // Allow 50ms early for scheduler variance - private readonly LATE_TOLERANCE = 200; // Allow 200ms late for network/event loop - private readonly VERY_LATE_MULTIPLIER = 2; // If >2x retry value, client is likely ignoring it - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); @@ -286,7 +288,8 @@ export class SSERetryScenario implements Scenario { jsonrpc: '2.0', id: request.id, result: { - protocolVersion: '2025-03-26', + // The retry MUST under test was introduced in 2025-11-25 (SEP-1699) + protocolVersion: '2025-11-25', serverInfo: { name: 'sse-retry-test-server', version: '1.0.0' @@ -455,39 +458,22 @@ export class SSERetryScenario implements Scenario { ) { const actualDelay = this.getReconnectionTime - this.toolStreamCloseTime; const minExpected = this.retryValue - this.EARLY_TOLERANCE; - const maxExpected = this.retryValue + this.LATE_TOLERANCE; + // The retry MUST is a lower bound ("waiting the given number of + // milliseconds before attempting to reconnect"), so only an early + // reconnect fails; lateness is environment latency and is not gated. const tooEarly = actualDelay < minExpected; - const slightlyLate = actualDelay > maxExpected; - const veryLate = - actualDelay > this.retryValue * this.VERY_LATE_MULTIPLIER; - const withinTolerance = !tooEarly && !slightlyLate; - - let status: 'SUCCESS' | 'FAILURE' | 'WARNING' = 'SUCCESS'; - let errorMessage: string | undefined; - - if (tooEarly) { - // Client reconnected too soon - MUST violation - status = 'FAILURE'; - errorMessage = `Client reconnected too early (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). Client MUST respect the retry field and wait the specified time.`; - } else if (veryLate) { - // Client reconnected way too late - likely ignoring retry field entirely - status = 'FAILURE'; - errorMessage = `Client reconnected very late (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). Client appears to be ignoring the retry field and using its own backoff strategy.`; - } else if (slightlyLate) { - // Client reconnected slightly late - not a spec violation but suspicious - status = 'WARNING'; - errorMessage = `Client reconnected slightly late (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). This is acceptable but may indicate network delays.`; - } this.checks.push({ id: 'client-sse-retry-timing', name: 'ClientRespectsRetryField', description: 'Client MUST respect the retry field, waiting the given number of milliseconds before attempting to reconnect', - status, + status: tooEarly ? 'FAILURE' : 'SUCCESS', timestamp: new Date().toISOString(), - errorMessage, + errorMessage: tooEarly + ? `Client reconnected too early (${actualDelay.toFixed(0)}ms instead of ${this.retryValue}ms). Client MUST respect the retry field and wait the specified time.` + : undefined, specReferences: [ { id: 'SEP-1699', @@ -498,14 +484,7 @@ export class SSERetryScenario implements Scenario { expectedRetryMs: this.retryValue, actualDelayMs: Math.round(actualDelay), minAcceptableMs: minExpected, - maxAcceptableMs: maxExpected, - veryLateThresholdMs: this.retryValue * this.VERY_LATE_MULTIPLIER, earlyToleranceMs: this.EARLY_TOLERANCE, - lateToleranceMs: this.LATE_TOLERANCE, - withinTolerance, - tooEarly, - slightlyLate, - veryLate, getConnectionCount: this.getConnectionCount } }); @@ -514,7 +493,7 @@ export class SSERetryScenario implements Scenario { id: 'client-sse-retry-timing', name: 'ClientRespectsRetryField', description: 'Client MUST respect the retry field timing', - status: 'WARNING', + status: 'INFO', timestamp: new Date().toISOString(), errorMessage: 'Could not measure timing - tool stream close time or GET reconnection time not recorded', diff --git a/src/scenarios/client/tools_call.test.ts b/src/scenarios/client/tools_call.test.ts new file mode 100644 index 0000000..ad6a58e --- /dev/null +++ b/src/scenarios/client/tools_call.test.ts @@ -0,0 +1,105 @@ +import { testScenarioContext } from '../../mock-server/testing'; +import { describe, it, expect } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { ToolsCallScenario } from './tools_call'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +describe('tools_call scenario', () => { + it('emits a single FAILURE check when the tool was never called', async () => { + const scenario = new ToolsCallScenario(); + await scenario.start(testScenarioContext()); + try { + const checks = scenario.getChecks(); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'tool-add-numbers', + status: 'FAILURE' + }); + } finally { + await scenario.stop(); + } + }); + + it('serves spec-valid results at the draft (2026-07-28) version', async () => { + const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + async function post(url: string, body: object) { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION + }, + body: JSON.stringify(body) + }); + return { status: r.status, body: await r.json() }; + } + + const scenario = new ToolsCallScenario(); + const { serverUrl } = await scenario.start( + testScenarioContext(DRAFT_PROTOCOL_VERSION) + ); + try { + const list = await post(serverUrl, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }); + expect(list.status).toBe(200); + expect(list.body.result).toMatchObject({ + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + }); + + const call = await post(serverUrl, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { _meta: meta, name: 'add_numbers', arguments: { a: 2, b: 3 } } + }); + expect(call.status).toBe(200); + expect(call.body.result.resultType).toBe('complete'); + + const checks = scenario.getChecks(); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); + + it('emits SUCCESS after a valid tools/call and getChecks() is idempotent', async () => { + const scenario = new ToolsCallScenario(); + const { serverUrl } = await scenario.start(testScenarioContext()); + try { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + await client.callTool({ name: 'add_numbers', arguments: { a: 2, b: 3 } }); + await transport.close(); + + const first = scenario.getChecks(); + expect(first).toHaveLength(1); + expect(first[0].status).toBe('SUCCESS'); + + // Repeated calls must not accumulate duplicate checks. + const second = scenario.getChecks(); + expect(second).toHaveLength(1); + expect(second[0].status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); +}); diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index 807ad55..b3dcb74 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -1,164 +1,83 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck, SpecVersion } from '../../types'; -import express, { Request, Response } from 'express'; -import { ScenarioUrls } from '../../types'; -import { createRequestLogger } from '../request-logger'; +import type { ScenarioContext, MockServer } from '../../mock-server'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../types'; +import type { CallToolRequest } from '../../spec-types/2025-06-18'; -function createMcpServer(checks: ConformanceCheck[]): Server { - const server = new Server( - { - name: 'add-numbers-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'add_numbers', - description: 'Add two numbers together', - inputSchema: { - type: 'object', - properties: { - a: { - type: 'number', - description: 'First number' - }, - b: { - type: 'number', - description: 'Second number' - } - }, - required: ['a', 'b'] - } - } - ] - }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === 'add_numbers') { - const { a, b } = request.params.arguments as { a: number; b: number }; - const result = a + b; - - checks.push({ - id: 'tool-add-numbers', - name: 'ToolAddNumbers', - description: 'Validates that the add_numbers tool works correctly', - status: 'SUCCESS', - timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'MCP-Tools', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' - } - ], - details: { - a, - b, - result - } - }); - - return { - content: [ - { - type: 'text', - text: `The sum of ${a} and ${b} is ${result}` - } - ] - }; - } - - throw new Error(`Unknown tool: ${request.params.name}`); - }); - - return server; -} - -function createServerApp(checks: ConformanceCheck[]): express.Application { - const app = express(); - app.use(express.json()); - - app.use( - createRequestLogger(checks, { - incomingId: 'incoming-request', - outgoingId: 'outgoing-response', - mcpRoute: '/mcp' - }) - ); - - app.post('/mcp', async (req: Request, res: Response) => { - // Stateless: create a fresh server and transport per request - const server = createMcpServer(checks); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - }); - - return app; -} +const SPEC_REF = { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' +}; export class ToolsCallScenario implements Scenario { name = 'tools_call'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = 'Tests calling tools with various parameter types'; - private app: express.Application | null = null; - private httpServer: any = null; - private checks: ConformanceCheck[] = []; + private srv: MockServer | null = null; - async start(): Promise { - this.checks = []; - this.app = createServerApp(this.checks); - this.httpServer = this.app.listen(0); - const port = this.httpServer.address().port; - return { serverUrl: `http://localhost:${port}/mcp` }; + async start(ctx: ScenarioContext): Promise { + this.srv = await ctx.createServer({ + 'tools/list': () => ({ + tools: [ + { + name: 'add_numbers', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }), + 'tools/call': (params) => { + const p = params as CallToolRequest['params']; + if (p.name !== 'add_numbers') { + throw new Error(`Unknown tool: ${p.name}`); + } + const { a, b } = p.arguments as { a: number; b: number }; + return { + content: [ + { type: 'text', text: `The sum of ${a} and ${b} is ${a + b}` } + ] + }; + } + }); + return { serverUrl: this.srv.url }; } async stop() { - if (this.httpServer) { - await new Promise((resolve) => this.httpServer.close(resolve)); - this.httpServer = null; - } - this.app = null; + await this.srv?.close(); + this.srv = null; } getChecks(): ConformanceCheck[] { - const expectedSlugs = ['tool-add-numbers']; - // add a failure if not in there already - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - // TODO: this is duplicated from above, refactor - this.checks.push({ - id: slug, - name: `ToolAddNumbers`, - description: `Validates that the add_numbers tool works correctly`, - status: 'FAILURE', - timestamp: new Date().toISOString(), - details: { message: 'Tool was not called by client' }, - specReferences: [ - { - id: 'MCP-Tools', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' + // Built fresh on every call so getChecks() is idempotent — the runner may + // call it more than once and we must not accumulate duplicates. + const call = this.srv?.recorded.find((r) => r.method === 'tools/call'); + const args = (call?.params as CallToolRequest['params'] | undefined) + ?.arguments as { a?: unknown; b?: unknown } | undefined; + const ok = + call !== undefined && + typeof args?.a === 'number' && + typeof args?.b === 'number'; + return [ + { + id: 'tool-add-numbers', + name: 'ToolAddNumbers', + description: 'Validates that the add_numbers tool works correctly', + status: ok ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REF], + details: ok + ? { + a: args!.a, + b: args!.b, + result: (args!.a as number) + (args!.b as number) } - ] - }); + : { message: 'Tool was not called by client' } } - } - return this.checks; + ]; } } diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index d67fae4..375d701 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -1,11 +1,27 @@ -import { Scenario, ClientScenario, SpecVersion } from '../types'; +import { + Scenario, + ClientScenario, + ClientScenarioForAuthorizationServer, + ScenarioSource, + SpecVersion, + DatedSpecVersion, + ScenarioSpecTag, + DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION +} from '../types'; import { InitializeScenario } from './client/initialize'; +import { SkillsNoPrefetchScenario } from './client/skills/no-prefetch'; +import { SkillsVerificationScenario } from './client/skills/verification'; import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; import { SSERetryScenario } from './client/sse-retry'; +import { RequestMetadataScenario } from './client/request-metadata'; +import { MRTRClientScenario } from './client/mrtr-client'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; +import { SessionLifecycleScenario } from './server/session-lifecycle'; +import { ServerStatelessScenario } from './server/stateless'; import { PingScenario, @@ -40,9 +56,14 @@ import { ResourcesReadBinaryScenario, ResourcesTemplateReadScenario, ResourcesSubscribeScenario, - ResourcesUnsubscribeScenario + ResourcesUnsubscribeScenario, + ResourcesNotFoundErrorScenario } from './server/resources'; +import { SkillsDirectoryReadScenario } from './server/skills/directory'; +import { SkillsEnumerationScenario } from './server/skills/enumeration'; +import { SkillsManifestScenario } from './server/skills/manifest'; + import { PromptsListScenario, PromptsGetSimpleScenario, @@ -52,13 +73,59 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { CachingScenario } from './server/caching'; + +// InputRequiredResult scenarios from (SEP-2322) +import { + InputRequiredResultBasicElicitationScenario, + InputRequiredResultBasicSamplingScenario, + InputRequiredResultBasicListRootsScenario, + InputRequiredResultRequestStateScenario, + InputRequiredResultMultipleInputRequestsScenario, + InputRequiredResultMultiRoundScenario, + InputRequiredResultMissingInputResponseScenario, + InputRequiredResultNonToolRequestScenario, + InputRequiredResultResultTypeScenario, + InputRequiredResultUnsupportedMethodsScenario, + InputRequiredResultTamperedStateScenario, + InputRequiredResultCapabilityCheckScenario, + InputRequiredResultIgnoreExtraParamsScenario, + InputRequiredResultValidateInputScenario +} from './server/input-required-result'; + +import { TasksLifecycleScenario } from './server/tasks/lifecycle'; +import { TasksCapabilityNegotiationScenario } from './server/tasks/capability'; +import { TasksWireFieldsScenario } from './server/tasks/wire-fields'; +import { TasksRequestStateRemovalScenario } from './server/tasks/request-state'; +import { TasksMRTRInputScenario } from './server/tasks/mrtr-input'; +import { TasksRequestHeadersScenario } from './server/tasks/headers'; +import { TasksDispatchScenario } from './server/tasks/dispatch'; +import { TasksStatusNotificationsScenario } from './server/tasks/notifications'; +import { TasksRequiredTaskErrorScenario } from './server/tasks/required-task-error'; +import { TasksMrtrCompositionScenario } from './server/tasks/composition'; + +import { + HttpHeaderValidationScenario, + HttpCustomHeaderServerValidationScenario +} from './server/http-standard-headers'; import { authScenariosList, backcompatScenariosList, + draftScenariosList, extensionScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; +import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; +import { AuthorizationCodeGrantScenario } from './authorization-server/authorization-code-grant'; + +import { HttpStandardHeadersScenario } from './client/http-standard-headers'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario +} from './client/http-custom-headers'; +import { JsonSchemaRefDerefScenario } from './client/json-schema-ref-deref'; +import { JsonSchema2020_12PreservationScenario } from './client/json-schema-2020-12-preservation'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -69,13 +136,44 @@ const pendingClientScenariosList: ClientScenario[] = [ // On hold until server-side SSE improvements are made // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 - new ServerSSEPollingScenario() + new ServerSSEPollingScenario(), + + // HTTP Standardization (SEP-2243) + // Pending until the everything-server fully implements SEP-2243 + // header validation (case-insensitive names, whitespace trimming, -32020 error code) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario(), + + // SEP-2663 Tasks extension. Pending because the everything-server + // does not implement io.modelcontextprotocol/tasks; targeted runs + // point at a SEP-2663-conformant fixture via + // `npm start -- server --scenario tasks-* --url `. + new TasksLifecycleScenario(), + new TasksCapabilityNegotiationScenario(), + new TasksWireFieldsScenario(), + new TasksRequestStateRemovalScenario(), + new TasksMRTRInputScenario(), + new TasksRequestHeadersScenario(), + new TasksDispatchScenario(), + new TasksStatusNotificationsScenario(), + new TasksRequiredTaskErrorScenario(), + new TasksMrtrCompositionScenario(), + + // SEP-2640 Skills extension. Pending because the everything-server does not + // implement io.modelcontextprotocol/skills; targeted runs point at a + // SEP-2640-conformant fixture via + // `npm start -- server --scenario sep-2640-skills-* --url `. + new SkillsDirectoryReadScenario(), + new SkillsEnumerationScenario(), + new SkillsManifestScenario() ]; // All client scenarios const allClientScenariosList: ClientScenario[] = [ // Lifecycle scenarios new ServerInitializeScenario(), + new SessionLifecycleScenario(), + new ServerStatelessScenario(), // Utilities scenarios new LoggingSetLevelScenario(), @@ -116,6 +214,15 @@ const allClientScenariosList: ClientScenario[] = [ new ResourcesSubscribeScenario(), new ResourcesUnsubscribeScenario(), + // Resources error handling (SEP-2164) + new ResourcesNotFoundErrorScenario(), + + // Skills extension (SEP-2640). Fixture-dependent (needs a SEP-2640 server); + // each scenario SKIPs cleanly when the extension is not declared. + new SkillsDirectoryReadScenario(), + new SkillsEnumerationScenario(), + new SkillsManifestScenario(), + // Prompts scenarios new PromptsListScenario(), new PromptsGetSimpleScenario(), @@ -124,16 +231,62 @@ const allClientScenariosList: ClientScenario[] = [ new PromptsGetWithImageScenario(), // Security scenarios - new DNSRebindingProtectionScenario() + new DNSRebindingProtectionScenario(), + + // Caching scenarios (SEP-2549) + new CachingScenario(), + // HTTP Standardization scenarios (SEP-2243) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario(), + + // SEP-2663 Tasks extension. Pending against the everything-server; + // targeted runs point at a SEP-2663-conformant fixture. + new TasksLifecycleScenario(), + new TasksCapabilityNegotiationScenario(), + new TasksWireFieldsScenario(), + new TasksRequestStateRemovalScenario(), + new TasksMRTRInputScenario(), + new TasksRequestHeadersScenario(), + new TasksDispatchScenario(), + new TasksStatusNotificationsScenario(), + new TasksRequiredTaskErrorScenario(), + new TasksMrtrCompositionScenario(), + + // InputRequiredResult scenarios (SEP-2322) + new InputRequiredResultBasicElicitationScenario(), + new InputRequiredResultBasicSamplingScenario(), + new InputRequiredResultBasicListRootsScenario(), + new InputRequiredResultRequestStateScenario(), + new InputRequiredResultMultipleInputRequestsScenario(), + new InputRequiredResultMultiRoundScenario(), + new InputRequiredResultMissingInputResponseScenario(), + new InputRequiredResultNonToolRequestScenario(), + new InputRequiredResultResultTypeScenario(), + new InputRequiredResultUnsupportedMethodsScenario(), + new InputRequiredResultTamperedStateScenario(), + new InputRequiredResultCapabilityCheckScenario(), + new InputRequiredResultIgnoreExtraParamsScenario(), + new InputRequiredResultValidateInputScenario() ]; -// Active client scenarios (excludes pending) +// Scenarios that test requirements introduced in the in-progress draft spec. +// They run via `--suite draft` (or `--suite all`) and are excluded from the +// default `active` suite until the draft is published as a dated release. +const draftClientScenariosList: ClientScenario[] = + allClientScenariosList.filter( + (scenario) => + 'introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ); + +// Active client scenarios (excludes pending and draft) const activeClientScenariosList: ClientScenario[] = allClientScenariosList.filter( (scenario) => !pendingClientScenariosList.some( (pending) => pending.name === scenario.name - ) + ) && + !draftClientScenariosList.some((draft) => draft.name === scenario.name) ); // Client scenarios map - built from list @@ -141,15 +294,58 @@ export const clientScenarios = new Map( allClientScenariosList.map((scenario) => [scenario.name, scenario]) ); +// All client scenarios for authorization server +const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizationServer[] = + [ + // Authorization server scenarios + new AuthorizationServerMetadataEndpointScenario(), + new AuthorizationCodeGrantScenario() + ]; + +// Client scenarios map for authorization server - built from list +export const clientScenariosForAuthorizationServer = new Map< + string, + ClientScenarioForAuthorizationServer +>( + allClientScenariosListForAuthorizationServer.map((scenario) => [ + scenario.name, + scenario + ]) +); + // All client test scenarios (core + backcompat + extensions) const scenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), new SSERetryScenario(), + new RequestMetadataScenario(), ...authScenariosList, ...backcompatScenariosList, - ...extensionScenariosList + ...draftScenariosList, + ...extensionScenariosList, + + // MRTR client conformance (SEP-2322) + new MRTRClientScenario(), + + // HTTP Standardization scenarios (SEP-2243) + new HttpStandardHeadersScenario(), + new HttpCustomHeadersScenario(), + new HttpInvalidToolHeadersScenario(), + + // JSON Schema network $ref dereferencing (SEP-2106) + new JsonSchemaRefDerefScenario(), + + // JSON Schema 2020-12 client-side keyword preservation (SEP-1613, SEP-2106) + new JsonSchema2020_12PreservationScenario(), + + // SEP-2640 skills, client side. The harness is the server and grades what + // the client requests, which is how the retrieval-policy MUSTs become + // observable at all. + new SkillsNoPrefetchScenario(), + new SkillsVerificationScenario('digest'), + new SkillsVerificationScenario('size'), + new SkillsVerificationScenario('frontmatter') ]; // Core scenarios (tier 1 requirements) @@ -178,6 +374,12 @@ export function getClientScenario(name: string): ClientScenario | undefined { return clientScenarios.get(name); } +export function getClientScenarioForAuthorizationServer( + name: string +): ClientScenarioForAuthorizationServer | undefined { + return clientScenariosForAuthorizationServer.get(name); +} + export function listScenarios(): string[] { return Array.from(scenarios.keys()); } @@ -210,35 +412,114 @@ export function listBackcompatScenarios(): string[] { return backcompatScenariosList.map((scenario) => scenario.name); } +export function listClientScenariosForAuthorizationServer(): string[] { + return Array.from(clientScenariosForAuthorizationServer.keys()); +} + +// All client-testing scenarios that target the draft spec, derived from the +// declared `source.introducedIn` rather than a hand-maintained list (covers +// both the auth draft scenarios and the non-auth ones, e.g. SEP-2243/2575). +const draftSpecScenariosList: Scenario[] = scenariosList.filter( + (scenario) => + 'introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION +); + +export function listDraftScenarios(): string[] { + return draftSpecScenariosList.map((scenario) => scenario.name); +} + +export function listDraftClientScenarios(): string[] { + return draftClientScenariosList.map((scenario) => scenario.name); +} + export { listMetadataScenarios }; // All valid spec versions, used by the CLI to validate --spec-version input. +// 'extension' is intentionally excluded — extension scenarios are off-timeline +// and selected via `--suite extensions`, not `--spec-version`. export const ALL_SPEC_VERSIONS: SpecVersion[] = [ - '2025-03-26', - '2025-06-18', - '2025-11-25', - 'draft', - 'extension' + ...DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION ]; +export function resolveSpecVersion(value: string): SpecVersion { + if (value === 'draft') return DRAFT_PROTOCOL_VERSION; + if (ALL_SPEC_VERSIONS.includes(value as SpecVersion)) { + return value as SpecVersion; + } + console.error(`Unknown spec version: ${value}`); + console.error( + `Valid versions: ${ALL_SPEC_VERSIONS.join(', ')} (or 'draft' as an alias for ${DRAFT_PROTOCOL_VERSION})` + ); + process.exit(1); +} + +function versionIndex( + v: DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION +): number { + return ALL_SPEC_VERSIONS.indexOf(v); +} + +// Off-timeline sources (extensions etc.) are never selected by --spec-version. +export function matchesSpecVersion( + source: ScenarioSource, + version: SpecVersion +): boolean { + if ('extensionId' in source) return false; + return ( + versionIndex(source.introducedIn) <= versionIndex(version) && + (source.removedIn === undefined || + versionIndex(version) < versionIndex(source.removedIn)) + ); +} + +/** + * Whether a scenario's applicability window covers `version`. Used by the + * runner to skip explicitly-requested scenario/spec-version combinations + * that contradict (e.g. a draft-only scenario at a dated spec version). + */ +export function isScenarioApplicableAt( + source: ScenarioSource, + version: SpecVersion +): boolean { + return matchesSpecVersion(source, version); +} + export function listScenariosForSpec(version: SpecVersion): string[] { return scenariosList - .filter((s) => s.specVersions.includes(version)) + .filter((s) => matchesSpecVersion(s.source, version)) .map((s) => s.name); } export function listClientScenariosForSpec(version: SpecVersion): string[] { return allClientScenariosList - .filter((s) => s.specVersions.includes(version)) + .filter((s) => matchesSpecVersion(s.source, version)) + .map((s) => s.name); +} + +export function listClientScenariosForAuthorizationServerForSpec( + version: SpecVersion +): string[] { + return allClientScenariosListForAuthorizationServer + .filter((s) => matchesSpecVersion(s.source, version)) .map((s) => s.name); } export function getScenarioSpecVersions( name: string -): SpecVersion[] | undefined { - return ( - scenarios.get(name)?.specVersions ?? clientScenarios.get(name)?.specVersions - ); +): ScenarioSpecTag[] | undefined { + const s = + scenarios.get(name) ?? + clientScenarios.get(name) ?? + clientScenariosForAuthorizationServer.get(name); + if (!s) return undefined; + if ('extensionId' in s.source) return ['extension']; + const result: ScenarioSpecTag[] = []; + for (const v of ALL_SPEC_VERSIONS) { + if (matchesSpecVersion(s.source, v)) result.push(v); + } + return result; } -export type { SpecVersion }; +export type { SpecVersion, ScenarioSpecTag }; diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 689d529..bce7845 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -1,14 +1,39 @@ +import { testContext } from '../../connection/testing'; import { spawn, ChildProcess } from 'child_process'; -import { getClientScenario, listActiveClientScenarios } from '../index'; +import { createServer } from 'net'; +import { + getClientScenario, + listActiveClientScenarios, + listDraftClientScenarios, + listPendingClientScenarios +} from '../index'; +import { + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION, + type SpecVersion +} from '../../types'; import path from 'path'; +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + describe('Server Scenarios', () => { let serverProcess: ChildProcess | null = null; - const TEST_PORT = 3001; - const SERVER_URL = `http://localhost:${TEST_PORT}/mcp`; + let serverUrl: string; const SERVER_STARTUP_TIMEOUT = 30000; // 30 seconds for CI beforeAll(async () => { + const testPort = await getFreePort(); + serverUrl = `http://localhost:${testPort}/mcp`; + // Start the everything-server once for all scenarios in this file const serverPath = path.join( process.cwd(), @@ -18,7 +43,7 @@ describe('Server Scenarios', () => { // Use shell: true on Windows only (npx is npx.cmd on Windows) const isWindows = process.platform === 'win32'; serverProcess = spawn('npx', ['tsx', serverPath], { - env: { ...process.env, PORT: TEST_PORT.toString() }, + env: { ...process.env, PORT: testPort.toString() }, stdio: ['ignore', 'pipe', 'pipe'], detached: false, shell: isWindows @@ -102,35 +127,76 @@ describe('Server Scenarios', () => { } }); - // Generate individual test for each scenario - const scenarios = listActiveClientScenarios(); + // Generate individual test for each scenario: the active suite plus the + // draft-spec scenarios that aren't parked in `pending` — the same set this + // file covered before draft scenarios were split out of `active`. + const pendingScenarios = new Set(listPendingClientScenarios()); + const scenarios = [ + ...listActiveClientScenarios(), + ...listDraftClientScenarios().filter((name) => !pendingScenarios.has(name)) + ]; + + async function expectScenarioToPass( + scenarioName: string, + specVersion?: SpecVersion + ): Promise { + const scenario = getClientScenario(scenarioName); + expect(scenario).toBeDefined(); + + if (!scenario) { + throw new Error(`Scenario ${scenarioName} not found`); + } + + // Draft-only scenarios expect the draft (stateless) connection. Other + // scenarios normally use the latest stateful wire unless a test overrides it. + const targetSpecVersion = + specVersion ?? + ('introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION); + + const checks = await scenario.run( + testContext(serverUrl, targetSpecVersion) + ); + + // Verify checks were returned + expect(checks.length).toBeGreaterThan(0); + + // Verify all checks passed + const failures = checks.filter((c) => c.status === 'FAILURE'); + if (failures.length > 0) { + const failureMessages = failures + .map((c) => `${c.name}: ${c.errorMessage || c.description}`) + .join('\n '); + throw new Error(`Scenario failed with checks:\n ${failureMessages}`); + } + + // All checks should be non-FAILURE (SUCCESS, WARNING, or INFO are acceptable) + const nonFailures = checks.filter((c) => c.status !== 'FAILURE'); + expect(nonFailures.length).toBe(checks.length); + } for (const scenarioName of scenarios) { it(`${scenarioName}`, async () => { - const scenario = getClientScenario(scenarioName); - expect(scenario).toBeDefined(); - - if (!scenario) { - throw new Error(`Scenario ${scenarioName} not found`); - } - - const checks = await scenario.run(SERVER_URL); - - // Verify checks were returned - expect(checks.length).toBeGreaterThan(0); - - // Verify all checks passed - const failures = checks.filter((c) => c.status === 'FAILURE'); - if (failures.length > 0) { - const failureMessages = failures - .map((c) => `${c.name}: ${c.errorMessage || c.description}`) - .join('\n '); - throw new Error(`Scenario failed with checks:\n ${failureMessages}`); - } - - // All checks should be non-FAILURE (SUCCESS, WARNING, or INFO are acceptable) - const nonFailures = checks.filter((c) => c.status !== 'FAILURE'); - expect(nonFailures.length).toBe(checks.length); + await expectScenarioToPass(scenarioName); }, 10000); // 10 second timeout per scenario } + + // These scenarios are introduced before the stateless protocol, so the normal + // fixture matrix exercises them on the latest stateful wire. Run them again on + // the modern wire to cover the streamed response adapter used by tools/call. + for (const scenarioName of [ + 'tools-call-simple-text', + 'tools-call-image', + 'tools-call-audio', + 'tools-call-embedded-resource', + 'tools-call-mixed-content', + 'tools-call-error', + 'tools-call-with-progress' + ]) { + it(`${scenarioName} on ${DRAFT_PROTOCOL_VERSION}`, async () => { + await expectScenarioToPass(scenarioName, DRAFT_PROTOCOL_VERSION); + }, 10000); + } }); diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts new file mode 100644 index 0000000..c7f4a5b --- /dev/null +++ b/src/scenarios/server/caching.ts @@ -0,0 +1,283 @@ +/** + * Caching (SEP-2549) test scenario for MCP servers + * + * Tests that servers include ttlMs and cacheScope on cacheable results: + * tools/list, prompts/list, resources/list, resources/templates/list, resources/read + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { + JsonRpcError, + type Connection, + type RunContext +} from '../../connection'; +import type { CacheableResult } from '../../spec-types/draft'; + +const SPEC_REFS = [ + { + id: 'MCP-Caching', + url: 'https://modelcontextprotocol.io/specification/draft/server/utilities/caching' + }, + { + id: 'SEP-2549', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549' + } +]; + +interface CachingFields { + ttlMs: unknown; + cacheScope: unknown; + hasTtlMs: boolean; + hasCacheScope: boolean; +} + +function extractCachingFields(result: Partial): CachingFields { + const hasTtlMs = 'ttlMs' in result; + const hasCacheScope = 'cacheScope' in result; + return { + ttlMs: result.ttlMs, + cacheScope: result.cacheScope, + hasTtlMs, + hasCacheScope + }; +} + +function buildPresenceCheck( + id: string, + name: string, + endpoint: string, + fields: CachingFields +): ConformanceCheck { + const errors: string[] = []; + + if (!fields.hasTtlMs) { + errors.push(`${endpoint} response missing ttlMs`); + } + if (!fields.hasCacheScope) { + errors.push(`${endpoint} response missing cacheScope`); + } + + return { + id, + name, + description: `${endpoint} response includes ttlMs and cacheScope caching hints`, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: SPEC_REFS, + details: { + ttlMs: fields.ttlMs, + cacheScope: fields.cacheScope, + hasTtlMs: fields.hasTtlMs, + hasCacheScope: fields.hasCacheScope + } + }; +} + +export class CachingScenario implements ClientScenario { + name = 'caching'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test that servers include caching hints (ttlMs and cacheScope) on cacheable results (SEP-2549). + +**Server Implementation Requirements:** + +Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "private") on results from: +- \`tools/list\` +- \`prompts/list\` +- \`resources/list\` +- \`resources/templates/list\` +- \`resources/read\``; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + const allFields: Array<{ endpoint: string; fields: CachingFields }> = []; + + // SEP-2549 only exists in the draft spec, so each cacheable endpoint is + // queried over the version-appropriate connection. Under --spec-version + // draft that resolves to the stateless impl (SEP-2575): protocolVersion + // 2026-07-28 plus the cross-cutting _meta and standard headers + // (issue #315). + let conn: Connection; + try { + conn = await ctx.connect(); + } catch (error) { + checks.push({ + id: 'sep-2549-caching-connection', + name: 'CachingConnection', + description: 'Caching hints scenario failed to connect', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Connection failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + return checks; + } + + const queryEndpoint = async ( + checkId: string, + checkName: string, + endpoint: string, + params?: Record + ): Promise<(CacheableResult & Record) | undefined> => { + const description = `${endpoint} response includes ttlMs and cacheScope caching hints`; + try { + const result = await conn.request< + CacheableResult & Record + >(endpoint, params); + const fields = extractCachingFields(result); + allFields.push({ endpoint, fields }); + checks.push(buildPresenceCheck(checkId, checkName, endpoint, fields)); + return result; + } catch (error) { + const rpcError = error instanceof JsonRpcError ? error : undefined; + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: rpcError + ? `${endpoint} returned JSON-RPC error ${rpcError.code}: ${rpcError.message}` + : `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS, + details: rpcError + ? { error: { code: rpcError.code, message: rpcError.message } } + : undefined + }); + return undefined; + } + }; + + // 1. tools/list + await queryEndpoint( + 'sep-2549-tools-list-caching-hints', + 'ToolsListCachingHints', + 'tools/list' + ); + + // 2. prompts/list + await queryEndpoint( + 'sep-2549-prompts-list-caching-hints', + 'PromptsListCachingHints', + 'prompts/list' + ); + + // 3. resources/list + const resourcesResult = await queryEndpoint( + 'sep-2549-resources-list-caching-hints', + 'ResourcesListCachingHints', + 'resources/list' + ); + + // 4. resources/templates/list + await queryEndpoint( + 'sep-2549-resources-templates-list-caching-hints', + 'ResourcesTemplatesListCachingHints', + 'resources/templates/list' + ); + + // 5. resources/read — use first resource from resources/list + const resources = resourcesResult?.resources as + | Array<{ uri?: string }> + | undefined; + const firstResourceUri = resources?.[0]?.uri; + if (firstResourceUri) { + await queryEndpoint( + 'sep-2549-resources-read-caching-hints', + 'ResourcesReadCachingHints', + 'resources/read', + { uri: firstResourceUri } + ); + } else { + // Keep the emitted check-ID set stable even when there is nothing to + // read (resources/list failed or the server exposes no resources). + checks.push({ + id: 'sep-2549-resources-read-caching-hints', + name: 'ResourcesReadCachingHints', + description: + 'resources/read response includes ttlMs and cacheScope caching hints', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: + 'resources/read was not exercised: resources/list failed or returned no resources.', + specReferences: SPEC_REFS + }); + } + + await conn.close(); + + // 6. Aggregate: ttlMs must be a non-negative integer + const ttlErrors: string[] = []; + const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs); + if (endpointsWithTtl.length === 0) { + ttlErrors.push('no endpoints returned ttlMs'); + } else { + for (const { endpoint, fields } of endpointsWithTtl) { + const val = fields.ttlMs; + if (typeof val !== 'number') { + ttlErrors.push( + `${endpoint}: ttlMs is ${typeof val}, expected number` + ); + } else if (!Number.isInteger(val)) { + ttlErrors.push(`${endpoint}: ttlMs is ${val}, expected integer`); + } else if (val < 0) { + ttlErrors.push(`${endpoint}: ttlMs is ${val}, must be >= 0`); + } + } + } + + checks.push({ + id: 'sep-2549-ttl-non-negative', + name: 'TtlNonNegative', + description: 'All ttlMs values are non-negative integers', + status: ttlErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: ttlErrors.length > 0 ? ttlErrors.join('; ') : undefined, + specReferences: SPEC_REFS, + details: { + endpoints: allFields.map((f) => ({ + endpoint: f.endpoint, + ttlMs: f.fields.ttlMs + })) + } + }); + + // 7. Aggregate: cacheScope must be "public" or "private" + const scopeErrors: string[] = []; + const endpointsWithScope = allFields.filter((f) => f.fields.hasCacheScope); + if (endpointsWithScope.length === 0) { + scopeErrors.push('no endpoints returned cacheScope'); + } else { + for (const { endpoint, fields } of endpointsWithScope) { + const val = fields.cacheScope; + if (val !== 'public' && val !== 'private') { + scopeErrors.push( + `${endpoint}: cacheScope is ${JSON.stringify(val)}, expected "public" or "private"` + ); + } + } + } + + checks.push({ + id: 'sep-2549-cache-scope-valid', + name: 'CacheScopeValid', + description: 'All cacheScope values are "public" or "private"', + status: scopeErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: scopeErrors.length > 0 ? scopeErrors.join('; ') : undefined, + specReferences: SPEC_REFS, + details: { + endpoints: allFields.map((f) => ({ + endpoint: f.endpoint, + cacheScope: f.fields.cacheScope + })) + } + }); + + return checks; + } +} diff --git a/src/scenarios/server/client-helper.ts b/src/scenarios/server/client-helper.ts deleted file mode 100644 index eebbd9b..0000000 --- a/src/scenarios/server/client-helper.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Helper utilities for creating MCP clients to test servers - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { - LoggingMessageNotificationSchema, - ProgressNotificationSchema -} from '@modelcontextprotocol/sdk/types.js'; - -export interface MCPClientConnection { - client: Client; - close: () => Promise; -} - -/** - * Create and connect an MCP client to a server - */ -export async function connectToServer( - serverUrl: string -): Promise { - const client = new Client( - { - name: 'conformance-test-client', - version: '1.0.0' - }, - { - capabilities: { - // Client capabilities - sampling: {}, - elicitation: {} - } - } - ); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - - await client.connect(transport); - - return { - client, - close: async () => { - await client.close(); - } - }; -} - -/** - * Helper to collect notifications (logging and progress) - */ -export class NotificationCollector { - private loggingNotifications: any[] = []; - private progressNotifications: any[] = []; - - constructor(client: Client) { - // Set up notification handler for logging messages - client.setNotificationHandler( - LoggingMessageNotificationSchema, - (notification) => { - this.loggingNotifications.push(notification); - } - ); - - // Set up notification handler for progress notifications - client.setNotificationHandler( - ProgressNotificationSchema, - (notification) => { - this.progressNotifications.push(notification); - } - ); - } - - /** - * Get all collected logging notifications - */ - getLoggingNotifications(): any[] { - return this.loggingNotifications; - } - - /** - * Get all collected progress notifications - */ - getProgressNotifications(): any[] { - return this.progressNotifications; - } - - /** - * Get all notifications (for backward compatibility) - */ - getNotifications(): any[] { - return this.loggingNotifications; - } -} diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index cd6c5f4..ab4ac8b 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -5,7 +5,12 @@ * to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { buildStandardHeaders, type RunContext } from '../../connection'; import { request } from 'undici'; const SPEC_REFERENCES = [ @@ -42,31 +47,116 @@ function getHostFromUrl(serverUrl: string): string { } /** - * Send an MCP initialize request with custom Host and Origin headers. + * Build a request body that any server for the given spec version should + * accept without prior setup (initialize for the stateful lifecycle, + * server/discover with _meta for the stateless lifecycle). + */ +function probeBody(specVersion: string): { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} { + const clientInfo = { + name: 'conformance-dns-rebinding-test', + version: '1.0.0' + }; + if (specVersion === DRAFT_PROTOCOL_VERSION) { + return { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': specVersion, + 'io.modelcontextprotocol/clientInfo': clientInfo, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }; + } + return { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: specVersion, + capabilities: {}, + clientInfo + } + }; +} + +function getResponseHeader( + headers: Record, + name: string +): string | undefined { + const value = headers[name] ?? headers[name.toLowerCase()]; + if (Array.isArray(value)) return value[0]; + return value; +} + +/** + * Send an MCP request with custom Host and Origin headers. * Both headers are set to the same value so that servers checking either * Host or Origin will properly detect the rebinding attempt. */ async function sendRequestWithHostAndOrigin( serverUrl: string, - hostOrOrigin: string -): Promise<{ statusCode: number; body: unknown }> { + hostOrOrigin: string, + specVersion: string +): Promise<{ statusCode: number; body: unknown; sessionId?: string }> { + // Build the SEP-2243 standard headers (Mcp-Method, Accept, ...) for the + // probe's JSON-RPC method so a strictly-conformant server only rejects the + // request for the Host/Origin values under test, then layer the + // scenario-specific headers on top. + const probe = probeBody(specVersion); const response = await request(serverUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Host: hostOrOrigin, - Origin: `http://${hostOrOrigin}`, - Accept: 'application/json, text/event-stream' - }, + headers: buildStandardHeaders(probe.method, probe.params, { + headers: { + Host: hostOrOrigin, + Origin: `http://${hostOrOrigin}`, + 'MCP-Protocol-Version': specVersion + } + }), + body: JSON.stringify(probe) + }); + + let body: unknown; + try { + body = await response.body.json(); + } catch { + body = null; + } + + return { + statusCode: response.statusCode, + body, + sessionId: getResponseHeader(response.headers, 'mcp-session-id') + }; +} + +async function sendInitializedNotification( + serverUrl: string, + hostOrOrigin: string, + specVersion: string, + sessionId?: string +): Promise<{ statusCode: number; body: unknown }> { + const method = 'notifications/initialized'; + const response = await request(serverUrl, { + method: 'POST', + headers: buildStandardHeaders(method, undefined, { + headers: { + Host: hostOrOrigin, + Origin: `http://${hostOrOrigin}`, + 'MCP-Protocol-Version': specVersion, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}) + } + }), body: JSON.stringify({ jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'conformance-dns-rebinding-test', version: '1.0.0' } - } + method }) }); @@ -85,7 +175,7 @@ async function sendRequestWithHostAndOrigin( export class DNSRebindingProtectionScenario implements ClientScenario { name = 'dns-rebinding-protection'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = `Test DNS rebinding protection for localhost servers. **Scope:** This test applies to localhost MCP servers running without HTTPS and without @@ -109,7 +199,8 @@ website tricks a user's browser into making requests to the local server. See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; const timestamp = new Date().toISOString(); @@ -160,7 +251,8 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ try { const response = await sendRequestWithHostAndOrigin( serverUrl, - attackerHost + attackerHost, + specVersion ); const isRejected = response.statusCode >= 400 && response.statusCode < 500; @@ -200,28 +292,57 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ // Check 2: Valid localhost Host/Origin headers should be accepted (2xx response) try { - const response = await sendRequestWithHostAndOrigin(serverUrl, validHost); + const response = await sendRequestWithHostAndOrigin( + serverUrl, + validHost, + specVersion + ); const isAccepted = response.statusCode >= 200 && response.statusCode < 300; + const initializedNotification = + isAccepted && specVersion !== DRAFT_PROTOCOL_VERSION + ? await sendInitializedNotification( + serverUrl, + validHost, + specVersion, + response.sessionId + ) + : undefined; + const initializedAccepted = + !initializedNotification || + (initializedNotification.statusCode >= 200 && + initializedNotification.statusCode < 300); const details = { hostHeader: validHost, originHeader: `http://${validHost}`, statusCode: response.statusCode, - body: response.body + body: response.body, + ...(initializedNotification + ? { + initializedNotification: { + statusCode: initializedNotification.statusCode, + body: initializedNotification.body, + sessionIdSent: response.sessionId ?? null + } + } + : {}) }; - if (isAccepted) { + if (isAccepted && initializedAccepted) { checks.push({ ...acceptedCheckBase, status: 'SUCCESS', details }); } else { + const errorMessage = !isAccepted + ? `Expected HTTP 2xx for valid localhost Host/Origin headers, got ${response.statusCode}` + : `Expected HTTP 2xx for initialized notification after valid localhost initialize, got ${initializedNotification?.statusCode}`; checks.push({ ...acceptedCheckBase, status: 'FAILURE', - errorMessage: `Expected HTTP 2xx for valid localhost Host/Origin headers, got ${response.statusCode}`, + errorMessage, details }); } diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index a458fec..20ed8ee 100644 --- a/src/scenarios/server/elicitation-defaults.ts +++ b/src/scenarios/server/elicitation-defaults.ts @@ -2,13 +2,21 @@ * SEP-1034: Elicitation default values test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer } from './client-helper'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import type { RunContext } from '../../connection'; +import { connectToServer } from '../../connection/sdk-client'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationDefaultsScenario implements ClientScenario { name = 'elicitation-sep1034-defaults'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test elicitation with default values for all primitive types (SEP-1034). **Server Implementation Requirements:** @@ -33,11 +41,12 @@ Implement a tool named \`test_elicitation_sep1034_defaults\` (no arguments) that } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); let capturedRequest: any = null; connection.client.setRequestHandler( diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts index c9eb598..ac04860 100644 --- a/src/scenarios/server/elicitation-enums.ts +++ b/src/scenarios/server/elicitation-enums.ts @@ -2,13 +2,21 @@ * SEP-1330: Elicitation enum schema improvements test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer } from './client-helper'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import type { RunContext } from '../../connection'; +import { connectToServer } from '../../connection/sdk-client'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationEnumsScenario implements ClientScenario { name = 'elicitation-sep1330-enums'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test elicitation with enum schema improvements (SEP-1330). **Server Implementation Requirements:** @@ -34,11 +42,12 @@ Implement a tool named \`test_elicitation_sep1330_enums\` (no arguments) that re } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); let capturedRequest: any = null; connection.client.setRequestHandler( diff --git a/src/scenarios/server/http-standard-headers.test.ts b/src/scenarios/server/http-standard-headers.test.ts new file mode 100644 index 0000000..056d3b3 --- /dev/null +++ b/src/scenarios/server/http-standard-headers.test.ts @@ -0,0 +1,255 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { testContext } from '../../connection/testing'; +import { + HttpHeaderValidationScenario, + HttpCustomHeaderServerValidationScenario, + CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS +} from './http-standard-headers'; +import type { ConformanceCheck } from '../../types'; + +/** + * Pins the untestable-failure policy (issue #248) for the SEP-2243 server + * scenarios: a server that lacks the fixtures these scenarios need must read + * red with a "Not testable:" cause, never as a green SKIPPED run. + */ + +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +function mockFetchTarget( + handler: (reqBody: any, reqHeaders: Record) => any +) { + global.fetch = (async (_url: any, init: any) => { + const body = JSON.parse(init.body); + const headers = init.headers || {}; + const responseConfig = (await handler(body, headers)) ?? { + status: 404, + body: { + jsonrpc: '2.0', + id: body.id, + error: { code: -32601, message: 'Not found' } + } + }; + const text = JSON.stringify(responseConfig.body); + return { + status: responseConfig.status ?? 200, + headers: { get: () => 'application/json' }, + json: async () => responseConfig.body, + text: async () => text + } as unknown as Response; + }) as typeof fetch; + return 'http://mock-sep2243-server.local'; +} + +const findAll = (checks: ConformanceCheck[], id: string) => + checks.filter((c) => c.id === id); + +describe('http-custom-header-server-validation — missing fixture policy', () => { + test('emits untestable FAILUREs for every declared check when no x-mcp-header tool exists', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'tools/list') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [ + { + name: 'plain_tool', + inputSchema: { + type: 'object', + properties: { q: { type: 'string' } } + } + } + ] + } + } + }; + } + }); + + const scenario = new HttpCustomHeaderServerValidationScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const gate = findAll(checks, 'sep-2243-server-no-xmcp-tool')[0]; + expect(gate?.status).toBe('FAILURE'); + expect(gate?.errorMessage).toContain('Not testable:'); + + for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) { + const declared = findAll(checks, id)[0]; + expect(declared?.status, id).toBe('FAILURE'); + expect(declared?.errorMessage, id).toContain('Not testable:'); + expect(declared?.details, id).toMatchObject({ untestable: true }); + } + + // The run must not contain a single SKIPPED row: the whole point is + // that this server cannot collect a vacuous green. + expect(checks.every((c) => c.status !== 'SKIPPED')).toBe(true); + }); + + test('emits untestable FAILUREs when the annotated tool has no string parameter', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'tools/list') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [ + { + name: 'numeric_only', + inputSchema: { + type: 'object', + properties: { + level: { type: 'number', 'x-mcp-header': 'Level' } + } + } + } + ] + } + } + }; + } + }); + + const scenario = new HttpCustomHeaderServerValidationScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const gate = findAll(checks, 'sep-2243-server-no-string-param')[0]; + expect(gate?.status).toBe('FAILURE'); + expect(gate?.errorMessage).toContain('Not testable:'); + for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) { + expect(findAll(checks, id)[0]?.status, id).toBe('FAILURE'); + } + }); +}); + +describe('http-header-validation — zero-tools Mcp-Name cases', () => { + // This scenario sends its live cases over raw node http (not fetch), so it + // is driven against a real local server rather than a fetch mock. + async function startBareServer( + handler: (body: any) => { status: number; body: object } + ): Promise<{ url: string; close: () => Promise }> { + const http = await import('http'); + const server = http.createServer((req, res) => { + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let body: any = {}; + try { + body = JSON.parse(raw); + } catch { + // Treat unparseable bodies as empty requests. + } + const out = handler(body); + res.writeHead(out.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(out.body)); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://localhost:${port}/mcp`, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + }; + } + + test('emits untestable FAILUREs for the Mcp-Name cases when the server lists no tools', async () => { + const srv = await startBareServer((body) => { + if (body.method === 'tools/list') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: body.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [] + } + } + }; + } + return { + status: 400, + body: { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32020, message: 'Header mismatch' } + } + }; + }); + + const scenario = new HttpHeaderValidationScenario(); + let checks: ConformanceCheck[]; + try { + checks = await scenario.run(testContext(srv.url)); + } finally { + await srv.close(); + } + + // Previously these three cases were silently omitted; now each surfaces + // as an untestable FAILURE naming the missing prerequisite. + const whitespace = findAll( + checks, + 'sep-2243-server-accepts-whitespace-header-value' + ); + expect(whitespace.length).toBeGreaterThan(0); + expect(whitespace[0].status).toBe('FAILURE'); + expect(whitespace[0].errorMessage).toContain('Not testable:'); + + const nameRejects = findAll( + checks, + 'sep-2243-server-reject-invalid-headers' + ).filter((c) => c.errorMessage?.startsWith('Not testable:')); + expect(nameRejects.map((c) => c.name).sort()).toEqual([ + 'ServerRejectsMismatchedNameHeader', + 'ServerRejectsMissingNameHeader' + ]); + }); + + test('emits untestable FAILUREs for the Mcp-Name cases when tools/list discovery fails', async () => { + const srv = await startBareServer((body) => ({ + status: 500, + body: { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32603, message: 'boom' } + } + })); + + const scenario = new HttpHeaderValidationScenario(); + let checks: ConformanceCheck[]; + try { + checks = await scenario.run(testContext(srv.url)); + } finally { + await srv.close(); + } + + const setup = findAll(checks, 'sep-2243-server-standard-setup')[0]; + expect(setup?.status).toBe('FAILURE'); + const whitespace = findAll( + checks, + 'sep-2243-server-accepts-whitespace-header-value' + )[0]; + expect(whitespace?.status).toBe('FAILURE'); + expect(whitespace?.errorMessage).toContain('Not testable:'); + }); +}); diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts new file mode 100644 index 0000000..d5ca032 --- /dev/null +++ b/src/scenarios/server/http-standard-headers.ts @@ -0,0 +1,1081 @@ +/** + * HTTP Standard Headers server validation test scenarios (SEP-2243) + * + * Tests that servers properly validate the standard MCP request headers: + * - Reject requests where Mcp-Method header doesn't match the body + * - Reject requests where Mcp-Name header doesn't match the body + * - Accept case variations of header names (case-insensitive) + * - Reject case variations of header values (case-sensitive) + * - Handle whitespace trimming per HTTP spec + * - Validate Base64-encoded custom header values + * - Return 400 Bad Request with error code -32020 (HeaderMismatch) + * + * This is a ClientScenario (connects to a server under test and validates + * its behavior). + */ + +import http from 'http'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { + withRequestMeta, + sendStatelessRequest, + type RunContext +} from '../../connection'; +import { HEADER_MISMATCH } from '../../spec-types/draft'; +import { untestableCheck } from '../untestable'; + +const SPEC_REFERENCE = { + id: 'SEP-2243-Server-Validation', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-validation' +}; + +const SPEC_REFERENCE_CASE = { + id: 'SEP-2243-Case-Sensitivity', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#case-sensitivity' +}; + +// OWS handling is an RFC 9110 §5.5 MUST ("a field parsing implementation MUST +// exclude such whitespace prior to evaluating the field value"), not a +// SEP-2243 requirement. Kept as a check because a server stack that fails it +// has a real HTTP-layer bug that will manifest as header-mismatch rejections. +const SPEC_REFERENCE_RFC9110_OWS = { + id: 'RFC-9110-5.5-Field-Values', + url: 'https://www.rfc-editor.org/rfc/rfc9110#section-5.5' +}; + +const SPEC_REFERENCE_BASE64 = { + id: 'SEP-2243-Value-Encoding', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding' +}; + +const SPEC_REFERENCE_CUSTOM = { + id: 'SEP-2243-Custom-Headers', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-behavior-for-custom-headers' +}; + +const HEADER_MISMATCH_ERROR_CODE = HEADER_MISMATCH; + +// Coarse, requirement-level check IDs (SEP-2243) for STANDARD-header +// rejections. Every standard-header rejection case emits this same pair of IDs; +// the per-case name/description carry the detail of which case was exercised, +// matching the repo's "same id, vary status/message" convention. +const REJECT_STATUS_CHECK_ID = 'sep-2243-server-reject-invalid-headers'; +const REJECT_ERROR_CODE_CHECK_ID = 'sep-2243-server-reject-error-code'; + +// CUSTOM-header (Mcp-Param-*) rejections map to the param-validation +// requirements instead. The -32020 error-code half of every custom-header +// rejection is the "reject with 400 + JSON-RPC error code -32020 if any +// validation fails" requirement. +const PARAM_REJECT_ERROR_CODE_CHECK_ID = + 'sep-2243-server-reject-param-mismatch'; + +/** + * Every requirement-level check ID HttpCustomHeaderServerValidationScenario + * can emit (SEP-2243). When the server under test exposes no x-mcp-header + * tool the scenario cannot exercise these, but it still emits one SKIPPED row + * per ID so the traceability manifest sees that a scenario exists for each + * requirement (the manifest joins on emitted IDs regardless of status). + */ +export const CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS = [ + 'sep-2243-server-decode-base64', + 'sep-2243-server-validate-param-match', + 'sep-2243-server-reject-invalid-param-chars', + 'sep-2243-server-reject-param-mismatch' +] as const; + +/** + * The three Mcp-Name validation cases need a callable tool. They are emitted + * from two places: as live test cases when the server lists a tool, and as + * untestable failures (issue #248) when it does not — keep the identities in + * one table so the two paths cannot drift. + */ +const MCP_NAME_CASES = [ + { + id: REJECT_STATUS_CHECK_ID, + name: 'ServerRejectsMismatchedNameHeader', + description: + 'Server rejects tools/call where Mcp-Name does not match body params.name', + specRef: SPEC_REFERENCE + }, + { + id: 'sep-2243-server-accepts-whitespace-header-value', + name: 'ServerAcceptsWhitespaceHeaderValue', + description: + 'Server MUST accept leading/trailing whitespace in Mcp-Name value (RFC 9110 §5.5: field parsing MUST exclude OWS before evaluating)', + specRef: SPEC_REFERENCE_RFC9110_OWS + }, + { + id: REJECT_STATUS_CHECK_ID, + name: 'ServerRejectsMissingNameHeader', + description: + 'Server MUST reject tools/call with missing Mcp-Name header when body has params.name', + specRef: SPEC_REFERENCE + } +] as const; + +/** Emit the Mcp-Name cases as untestable failures naming the cause. */ +function untestableMcpNameCases(checks: ConformanceCheck[], reason: string) { + for (const c of MCP_NAME_CASES) { + checks.push( + untestableCheck(c.id, c.name, c.description, reason, [c.specRef]) + ); + } +} + +/** + * Helper to send a raw HTTP POST request with custom headers. + * Uses Node.js http.request to preserve exact header casing and values, + * avoiding normalization that fetch()/Headers may apply. + */ +async function sendRawRequest( + serverUrl: string, + body: object, + headers: Record = {} +): Promise<{ status: number; body: any; headers: http.IncomingHttpHeaders }> { + const url = new URL(serverUrl); + const bodyStr = JSON.stringify(body); + + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Content-Length': Buffer.byteLength(bodyStr), + ...headers + } + }, + (res) => { + res.setEncoding('utf8'); + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + let responseBody: any; + const contentType = res.headers['content-type']; + if (contentType?.includes('application/json')) { + try { + responseBody = JSON.parse(data); + } catch { + responseBody = data; + } + } else { + responseBody = data; + } + resolve({ + status: res.statusCode || 0, + body: responseBody, + headers: res.headers + }); + }); + } + ); + + req.on('error', reject); + req.write(bodyStr); + req.end(); + }); +} + +/** + * Builds two checks for a rejection case: one for the HTTP 400 status, one for + * the -32020 JSON-RPC error code. Per SEP-2243 §Server Validation, 400 is MUST + * but -32020 is SHOULD for *standard* headers (and MUST for *custom* headers, + * §Server Behavior for Custom Headers) — so a server returning 400 with a + * different error code is compliant for standard headers and must not FAIL. + * + * The two emitted check IDs are supplied explicitly by the caller: standard- + * header callers pass the coarse REJECT_STATUS_CHECK_ID/REJECT_ERROR_CODE_CHECK_ID + * pair, while custom-header/Base64 callers pass the param-validation + * requirement that case exercises plus PARAM_REJECT_ERROR_CODE_CHECK_ID. The + * per-case `name`/`description` distinguish which rejection case was + * exercised. + */ +function createRejectionChecks( + statusId: string, + errorCodeId: string, + name: string, + description: string, + response: { status: number; body: any }, + specRef: { id: string; url: string }, + details: Record, + opts: { errorCodeSeverity: 'FAILURE' | 'WARNING' } +): ConformanceCheck[] { + const fullDetails = { + ...details, + responseStatus: response.status, + responseBody: response.body + }; + const ts = new Date().toISOString(); + + const statusOk = response.status === 400; + const codeOk = response.body?.error?.code === HEADER_MISMATCH_ERROR_CODE; + + return [ + { + id: statusId, + name, + description, + status: statusOk ? 'SUCCESS' : 'FAILURE', + timestamp: ts, + errorMessage: statusOk + ? undefined + : `Expected HTTP 400, got ${response.status}. Server MUST reject with 400 Bad Request.`, + specReferences: [specRef], + details: fullDetails + }, + { + id: errorCodeId, + name: `${name}ErrorCode`, + description: `${description} — uses JSON-RPC error code -32020 (HeaderMismatch)`, + status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity, + timestamp: ts, + errorMessage: codeOk + ? undefined + : `Expected JSON-RPC error code ${HEADER_MISMATCH_ERROR_CODE} (HeaderMismatch), got ${response.body?.error?.code ?? '(missing)'}.`, + specReferences: [specRef], + details: fullDetails + } + ]; +} + +function createAcceptanceCheck( + id: string, + name: string, + description: string, + response: { status: number; body: any }, + specRef: { id: string; url: string }, + details: Record +): ConformanceCheck { + const errors: string[] = []; + if (response.status >= 400) { + errors.push( + `Expected successful response, got HTTP ${response.status}. Server MUST accept this request.` + ); + } + // A server can return HTTP 200 with a JSON-RPC error in the body. Without + // this assertion that case would pass as "accepted". + if ( + response.body && + typeof response.body === 'object' && + 'error' in response.body + ) { + errors.push( + `Expected successful response, but body contains JSON-RPC error ${JSON.stringify(response.body.error)}.` + ); + } + return { + id, + name, + description, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [specRef], + details: { + ...details, + responseStatus: response.status, + responseBody: response.body + } + }; +} + +export class HttpHeaderValidationScenario implements ClientScenario { + name = 'http-header-validation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test server validation of standard MCP request headers (SEP-2243). + +**Server Implementation Requirements:** + +**Endpoint**: Streamable HTTP + +**Requirements**: +- Server MUST reject requests where Mcp-Method header doesn't match the body method +- Server MUST reject requests where Mcp-Name header doesn't match the body params.name/uri +- Server MUST accept header names case-insensitively +- Server MUST reject case-mismatched header values (method values are case-sensitive) +- Server MUST accept extra whitespace around header values (per HTTP spec) +- Server MUST return HTTP 400 Bad Request for validation failures +- Server MUST return JSON-RPC error with code -32020 (HeaderMismatch)`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Discover the server's tools with a fully-conformant stateless request + // (SEP-2575) — that wire protocol has no initialize handshake or sessions. + const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list'); + if (!toolsResponse.body?.result) { + // The server under test could not even answer a conformant tools/list: + // report a single explicit setup failure instead of misleading + // per-case results against a broken server. + const rpcError = toolsResponse.body?.error; + checks.push({ + id: 'sep-2243-server-standard-setup', + name: 'HttpHeaderValidationSetup', + description: 'Setup for header validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + `tools/list discovery failed: HTTP ${toolsResponse.status}` + + (rpcError + ? `, JSON-RPC error ${rpcError.code}: ${rpcError.message}` + : ', no result in response body'), + specReferences: [SPEC_REFERENCE], + details: { httpStatus: toolsResponse.status, error: rpcError } + }); + untestableMcpNameCases( + checks, + 'tools/list discovery failed, so the Mcp-Name header-validation cases could not be exercised' + ); + return checks; + } + const toolsResult = toolsResponse.body.result as { + tools?: Array<{ name: string; inputSchema?: unknown }>; + }; + + const baseHeaders: Record = { + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }; + + let idCounter = 100; + const nextId = () => idCounter++; + + // --- Header/Body Mismatch Tests --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + REJECT_STATUS_CHECK_ID, + 'ServerRejectsMismatchedMethodHeader', + 'Server rejects requests where Mcp-Method header does not match body method', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + { 'Mcp-Method': 'prompts/list' }, + SPEC_REFERENCE, + { requestBodyMethod: 'tools/list', mcpMethodHeader: 'prompts/list' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + REJECT_STATUS_CHECK_ID, + 'ServerRejectsMissingMethodHeader', + 'Server rejects requests with missing Mcp-Method header', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + {}, + SPEC_REFERENCE, + { requestBodyMethod: 'tools/list', mcpMethodHeader: '(missing)' } + ); + + if (toolsResult.tools && toolsResult.tools.length > 0) { + const toolName = toolsResult.tools[0].name; + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + REJECT_STATUS_CHECK_ID, + 'ServerRejectsMismatchedNameHeader', + 'Server rejects tools/call where Mcp-Name does not match body params.name', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { 'Mcp-Method': 'tools/call', 'Mcp-Name': 'wrong_tool_name' }, + SPEC_REFERENCE, + { requestBodyName: toolName, mcpNameHeader: 'wrong_tool_name' } + ); + + // --- Whitespace Test --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-server-accepts-whitespace-header-value', + 'ServerAcceptsWhitespaceHeaderValue', + 'Server MUST accept leading/trailing whitespace in Mcp-Name value (RFC 9110 §5.5: field parsing MUST exclude OWS before evaluating)', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { + 'Mcp-Method': 'tools/call', + 'Mcp-Name': ` ${toolName} ` + }, + SPEC_REFERENCE_RFC9110_OWS, + { + headerValue: ` ${toolName} `, + bodyValue: toolName, + reason: 'HTTP spec requires trimming OWS around field values' + } + ); + + // --- Missing Standard Header with Value in Body (Case 47) --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + REJECT_STATUS_CHECK_ID, + 'ServerRejectsMissingNameHeader', + 'Server MUST reject tools/call with missing Mcp-Name header when body has params.name', + { + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { name: toolName, arguments: {} } + }, + { 'Mcp-Method': 'tools/call' }, + SPEC_REFERENCE, + { + requestBodyName: toolName, + mcpNameHeader: '(missing)', + reason: + 'Standard header omitted but value present in body → MUST reject' + } + ); + } else { + // No tools to exercise the Mcp-Name dimension: these MUST cases were + // previously omitted without a trace. Emit untestable failures so + // the gap is visible (issue #248). + untestableMcpNameCases( + checks, + 'server lists no tools, so the Mcp-Name header-validation cases could not be exercised' + ); + } + + // --- Case Sensitivity Tests --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-header-name-case-insensitive', + 'ServerAcceptsLowercaseHeaderName', + 'Server MUST accept lowercase header name (mcp-method)', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + { 'mcp-method': 'tools/list' }, + SPEC_REFERENCE_CASE, + { headerNameUsed: 'mcp-method' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-header-name-case-insensitive', + 'ServerAcceptsUppercaseHeaderName', + 'Server MUST accept uppercase header name (MCP-METHOD)', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + { 'MCP-METHOD': 'tools/list' }, + SPEC_REFERENCE_CASE, + { headerNameUsed: 'MCP-METHOD' } + ); + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + REJECT_STATUS_CHECK_ID, + 'ServerRejectsCaseMismatchValue', + 'Server MUST reject uppercase method value (TOOLS/LIST) since values are case-sensitive', + { jsonrpc: '2.0', id: 0, method: 'tools/list' }, + { 'Mcp-Method': 'TOOLS/LIST' }, + SPEC_REFERENCE_CASE, + { headerValue: 'TOOLS/LIST', bodyValue: 'tools/list' } + ); + } catch (error) { + checks.push({ + id: 'sep-2243-server-standard-setup', + name: 'HttpHeaderValidationSetup', + description: 'Setup for header validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to set up tests: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE] + }); + } + + return checks; + } + + private async testCase( + checks: ConformanceCheck[], + serverUrl: string, + baseHeaders: Record, + nextId: () => number, + expectation: 'accept' | 'reject', + checkId: string, + checkName: string, + description: string, + body: any, + extraHeaders: Record, + specRef: { id: string; url: string }, + details: Record + ): Promise { + try { + // Issue #311: every raw request carries the SEP-2575 _meta fields — the + // header-validation cases only mangle headers, never the body metadata. + const requestBody = { + ...body, + id: body.id === 0 ? nextId() : body.id, + params: withRequestMeta(body.params) + }; + const response = await sendRawRequest(serverUrl, requestBody, { + ...baseHeaders, + ...extraHeaders + }); + if (expectation === 'reject') { + // Standard-header rejection: 400 is MUST, -32020 is SHOULD. All + // standard-header rejection cases collapse onto the coarse requirement + // ids; checkId/checkName still distinguish the case via name/details. + checks.push( + ...createRejectionChecks( + REJECT_STATUS_CHECK_ID, + REJECT_ERROR_CODE_CHECK_ID, + checkName, + description, + response, + specRef, + details, + { errorCodeSeverity: 'WARNING' } + ) + ); + } else { + checks.push( + createAcceptanceCheck( + checkId, + checkName, + description, + response, + specRef, + details + ) + ); + } + } catch (error) { + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [specRef] + }); + } + } +} + +export class HttpCustomHeaderServerValidationScenario implements ClientScenario { + name = 'http-custom-header-server-validation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test server validation of custom Mcp-Param headers and Base64 encoding (SEP-2243). + +**Server Implementation Requirements:** + +**Endpoint**: Streamable HTTP with at least one tool using \`x-mcp-header\` + +**Requirements**: +- Server MUST validate Base64-encoded header values +- Server MUST reject requests with invalid Base64 padding or characters +- Server MUST treat values without =?base64?...?= wrapper as literal +- Server MUST reject requests where custom header is omitted but value is in body`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Discover the server's tools with a fully-conformant stateless request + // (SEP-2575) — that wire protocol has no initialize handshake or sessions. + const toolsResponse = await sendStatelessRequest(serverUrl, 'tools/list'); + if (!toolsResponse.body?.result) { + // The server under test could not even answer a conformant tools/list: + // report a single explicit setup failure (and backfill the declared + // checks, mirroring the catch path) instead of pretending the + // requirements are not applicable to a broken server. + const rpcError = toolsResponse.body?.error; + checks.push({ + id: 'sep-2243-server-custom-setup', + name: 'HttpCustomHeaderServerValidationSetup', + description: 'Setup for custom header server validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + `tools/list discovery failed: HTTP ${toolsResponse.status}` + + (rpcError + ? `, JSON-RPC error ${rpcError.code}: ${rpcError.message}` + : ', no result in response body'), + specReferences: [SPEC_REFERENCE_CUSTOM], + details: { httpStatus: toolsResponse.status, error: rpcError } + }); + this.failDeclaredChecks(checks); + return checks; + } + const toolsResult = toolsResponse.body.result as { + tools?: Array<{ name: string; inputSchema?: unknown }>; + }; + + // Find a tool with x-mcp-header annotations + const xMcpTool = toolsResult.tools?.find((tool) => { + const schema = tool.inputSchema as any; + if (!schema?.properties) return false; + return Object.values(schema.properties).some( + (prop: any) => prop['x-mcp-header'] !== undefined + ); + }); + + if (!xMcpTool) { + checks.push( + untestableCheck( + 'sep-2243-server-no-xmcp-tool', + 'HttpCustomHeaderServerNoTool', + 'Server has no tools with x-mcp-header annotations to test', + 'server exposes no tool with x-mcp-header annotations, so none of the custom-header validation requirements could be exercised', + [SPEC_REFERENCE_CUSTOM] + ) + ); + this.untestableDeclaredChecks( + checks, + 'server exposes no tool with x-mcp-header annotations' + ); + return checks; + } + + const baseHeaders: Record = { + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }; + + // Find the first x-mcp-header annotated STRING property + // that is callable with minimal arguments to avoid schema validation failures + const schema = xMcpTool.inputSchema as any; + const annotatedEntry = Object.entries(schema.properties).find( + ([, def]: [string, any]) => + def['x-mcp-header'] !== undefined && (def as any).type === 'string' + ); + if (!annotatedEntry) { + checks.push( + untestableCheck( + 'sep-2243-server-no-string-param', + 'HttpCustomHeaderServerNoStringParam', + 'Server has no string-typed x-mcp-header parameter to test', + 'server exposes no string-typed x-mcp-header parameter, so the custom-header validation requirements could not be exercised', + [SPEC_REFERENCE_CUSTOM] + ) + ); + this.untestableDeclaredChecks( + checks, + 'server exposes no string-typed x-mcp-header parameter' + ); + return checks; + } + const [paramName, paramDef] = annotatedEntry as [string, any]; + const headerSuffix = paramDef['x-mcp-header']; + + // Build default arguments for all required params to avoid schema validation errors. + // These go in the JSON body, so number/boolean must be the real types — + // sending '0' or 'false' as strings makes the server reject on JSON-schema + // grounds and the header-validation checks below would false-pass on that 400. + const requiredParams: string[] = schema.required || []; + const defaultArgs: Record = {}; + const defaultHeaders: Record = {}; + for (const rp of requiredParams) { + if (rp !== paramName) { + const rpDef = schema.properties[rp]; + const rpType = rpDef?.type || 'string'; + if (rpType === 'number' || rpType === 'integer') { + defaultArgs[rp] = 0; + } else if (rpType === 'boolean') { + defaultArgs[rp] = false; + } else { + defaultArgs[rp] = 'test-default'; + } + // If this required param also has x-mcp-header, include its header too + if (rpDef?.['x-mcp-header']) { + defaultHeaders[`Mcp-Param-${rpDef['x-mcp-header']}`] = String( + defaultArgs[rp] + ); + } + } + } + + let idCounter = 200; + const nextId = () => idCounter++; + + // --- Base64 Decoding Tests --- + + const validBase64Value = Buffer.from('Hello').toString('base64'); + + // Valid Base64 - server decodes and validates + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-server-decode-base64', + 'ServerAcceptsValidBase64', + 'Server decodes valid Base64 header value and validates against body', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + `=?base64?${validBase64Value}?=`, + defaultArgs, + defaultHeaders + ); + + // Invalid Base64 padding — FAILURE per the SEP-2243 conformance-test-case + // table, which is the approved source of truth for these cases. The spec + // body says only "MUST decode them accordingly", but the table specifies + // strict rejection. SDKs whose stdlib decoders are lenient (Node + // Buffer.from, browser atob) will need to validate before decoding; if + // that proves burdensome we'll revisit. + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'sep-2243-server-reject-invalid-param-chars', + 'ServerRejectsInvalidBase64Padding', + 'Server MUST reject Mcp-Param header with invalid Base64 padding (per SEP-2243 test-case table)', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + '=?base64?SGVsbG8?=', + defaultArgs, + defaultHeaders + ); + + // Invalid Base64 characters — FAILURE for the same reason as padding. + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'sep-2243-server-reject-invalid-param-chars', + 'ServerRejectsInvalidBase64Chars', + 'Server MUST reject Mcp-Param header with non-alphabet Base64 characters (per SEP-2243 test-case table)', + xMcpTool.name, + paramName, + 'Hello', + headerSuffix, + '=?base64?SGVs!!!bG8=?=', + defaultArgs, + defaultHeaders + ); + + // Missing prefix - server treats as literal value + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-server-validate-param-match', + 'ServerLiteralMissingBase64Prefix', + 'Server treats value without =?base64? prefix as literal (not Base64)', + xMcpTool.name, + paramName, + validBase64Value, + headerSuffix, + validBase64Value, + defaultArgs, + defaultHeaders + ); + + // Missing suffix - server treats as literal value + await this.testBase64Case( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-server-validate-param-match', + 'ServerLiteralMissingBase64Suffix', + 'Server treats value without ?= suffix as literal (not Base64)', + xMcpTool.name, + paramName, + `=?base64?${validBase64Value}`, + headerSuffix, + `=?base64?${validBase64Value}`, + defaultArgs, + defaultHeaders + ); + + // --- Missing Custom Header with Value in Body --- + + await this.testMissingCustomHeader( + checks, + serverUrl, + baseHeaders, + nextId, + xMcpTool.name, + paramName, + headerSuffix, + defaultArgs, + defaultHeaders + ); + } catch (error) { + checks.push({ + id: 'sep-2243-server-custom-setup', + name: 'HttpCustomHeaderServerValidationSetup', + description: 'Setup for custom header server validation tests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + + // Declared-but-unemitted -> FAILURE. Reached only when setup threw partway + // through (the gate-out paths emit untestable FAILURE rows and the happy path emits + // every declared ID). + this.failDeclaredChecks(checks); + + return checks; + } + + /** + * Backfill every declared-but-unemitted check ID as FAILURE when setup + * failed before the cases could run, keeping the emitted ID set stable. + */ + private failDeclaredChecks(checks: ConformanceCheck[]): void { + for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) { + if (checks.some((c) => c.id === id)) continue; + checks.push({ + id, + name: 'NotObserved', + description: `Declared check ${id} was never emitted`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Check was not observed: custom-header validation setup failed before this case ran.', + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + } + + /** + * Emit one untestable FAILURE row per declared requirement check when the + * scenario cannot run against this server (no x-mcp-header tool). These + * are MUST requirements, so a missing fixture must read red, not green + * (issue #248); the IDs still reach checks.json so the traceability + * manifest records that a scenario exists for each requirement. + */ + private untestableDeclaredChecks( + checks: ConformanceCheck[], + reason: string + ): void { + for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) { + if (checks.some((c) => c.id === id)) continue; + checks.push( + untestableCheck( + id, + 'NotTestable', + `Declared check ${id} is not testable against this server`, + reason, + [SPEC_REFERENCE_CUSTOM] + ) + ); + } + } + + private async testBase64Case( + checks: ConformanceCheck[], + serverUrl: string, + baseHeaders: Record, + nextId: () => number, + expectation: 'accept' | 'reject', + checkId: string, + checkName: string, + description: string, + toolName: string, + paramName: string, + bodyValue: string, + headerSuffix: string, + headerValue: string, + defaultArgs: Record, + defaultHeaders: Record + ): Promise { + try { + const response = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: nextId(), + method: 'tools/call', + // Issue #311: the body always carries the SEP-2575 _meta fields — + // these cases only vary the Mcp-Param header value. + params: withRequestMeta({ + name: toolName, + arguments: { ...defaultArgs, [paramName]: bodyValue } + }) + }, + { + ...baseHeaders, + ...defaultHeaders, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': toolName, + [`Mcp-Param-${headerSuffix}`]: headerValue + } + ); + + const details = { + toolName, + paramName, + bodyValue, + headerSuffix, + headerValue + }; + + if (expectation === 'accept') { + checks.push( + createAcceptanceCheck( + checkId, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details + ) + ); + } else { + // Custom-header rejection: both 400 and -32020 are MUST per + // §Server Behavior for Custom Headers. The status half maps to the + // param-validation requirement this case exercises (checkId); the + // error-code half is the "400 + -32020 if any validation fails" + // requirement. + checks.push( + ...createRejectionChecks( + checkId, + PARAM_REJECT_ERROR_CODE_CHECK_ID, + checkName, + description, + response, + SPEC_REFERENCE_BASE64, + details, + { errorCodeSeverity: 'FAILURE' } + ) + ); + } + } catch (error) { + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_BASE64] + }); + } + } + + private async testMissingCustomHeader( + checks: ConformanceCheck[], + serverUrl: string, + baseHeaders: Record, + nextId: () => number, + toolName: string, + paramName: string, + headerSuffix: string, + defaultArgs: Record, + defaultHeaders: Record + ): Promise { + try { + // Send tools/call with value in body but NO Mcp-Param header + const response = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: nextId(), + method: 'tools/call', + // Issue #311: the body always carries the SEP-2575 _meta fields — + // this case only omits the Mcp-Param header. + params: withRequestMeta({ + name: toolName, + arguments: { ...defaultArgs, [paramName]: 'test-value' } + }) + }, + { + ...baseHeaders, + ...defaultHeaders, + 'Mcp-Method': 'tools/call', + 'Mcp-Name': toolName + // Deliberately omit Mcp-Param-{headerSuffix} header + } + ); + + // Custom-header rejection: both 400 and -32020 are MUST. A header + // omitted while the body carries a value is a header/body mismatch, so + // the status half maps to the validate-param-match requirement. + checks.push( + ...createRejectionChecks( + 'sep-2243-server-validate-param-match', + PARAM_REJECT_ERROR_CODE_CHECK_ID, + 'ServerRejectsMissingCustomHeader', + 'Server MUST reject request where custom header is omitted but value is present in body', + response, + SPEC_REFERENCE_CUSTOM, + { + toolName, + paramName, + bodyValue: 'test-value', + expectedHeader: `Mcp-Param-${headerSuffix}`, + mcpParamHeader: '(missing)' + }, + { errorCodeSeverity: 'FAILURE' } + ) + ); + } catch (error) { + checks.push({ + id: 'sep-2243-server-validate-param-match', + name: 'ServerRejectsMissingCustomHeader', + description: + 'Server MUST reject request where custom header is omitted but value is present in body', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + } +} diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts new file mode 100644 index 0000000..a845a8f --- /dev/null +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -0,0 +1,141 @@ +/** + * Helpers for SEP-2322 conformance tests. + * + * Provides InputRequiredResult-specific type guards, mock response builders, + * and a stateless JSON-RPC transport helper. + */ + +// ─── JSON-RPC Types ────────────────────────────────────────────────────────── + +export type { JsonRpcResponse } from '../../connection'; + +// ─── Stateless RPC Helper ──────────────────────────────────────────────────── + +import { sendStatelessRequest, JsonRpcResponse } from '../../connection'; + +/** + * Send a stateless JSON-RPC request (SEP-2575 pattern). + * The shared stateless helper injects the cross-cutting requirements: _meta + * (protocolVersion, clientInfo, clientCapabilities) and the standard + * MCP-Protocol-Version / Mcp-Method / Mcp-Name headers (SEP-2243). + */ +export async function sendRpc( + serverUrl: string, + method: string, + params?: Record, + options?: { skipValidation?: boolean } +): Promise { + const response = await sendStatelessRequest(serverUrl, method, params, { + skipValidation: options?.skipValidation + }); + if (!response.body) { + throw new Error( + `Expected a JSON-RPC response for ${method}, got HTTP ${response.status} (${response.contentType ?? 'no content-type'})` + ); + } + return response.body as JsonRpcResponse; +} + +// ─── InputRequiredResult Types ─────────────────────────────────────────────── + +export interface InputRequiredResultData { + resultType?: 'input_required'; + inputRequests?: Record; + requestState?: string; + _meta?: Record; + [key: string]: unknown; +} + +export interface InputRequestObject { + method: string; + params?: Record; +} + +// ─── Type Guards ───────────────────────────────────────────────────────────── + +/** + * Check if a JSON-RPC result is an InputRequiredResult. + */ +export function isInputRequiredResult( + result: Record | undefined +): result is InputRequiredResultData { + if (!result) return false; + if (result.resultType === 'input_required') return true; + return false; +} + +/** + * Check if a JSON-RPC result is a complete result (not input_required). + * complete is the default so if resultType is missing we assume it's complete. + */ +export function isCompleteResult( + result: Record | undefined +): boolean { + if (!result) return false; + if (result.resultType === 'input_required') return false; + return true; +} + +/** + * Extract inputRequests from an InputRequiredResult. + */ +export function getInputRequests( + result: InputRequiredResultData +): Record | undefined { + return result.inputRequests; +} + +// ─── Mock Response Builders ────────────────────────────────────────────────── + +/** + * Build a mock elicitation response (ElicitResult). + */ +export function mockElicitResponse( + content: Record +): Record { + return { + action: 'accept', + content + }; +} + +/** + * Build a mock sampling response (CreateMessageResult). + */ +export function mockSamplingResponse(text: string): Record { + return { + role: 'assistant', + content: { + type: 'text', + text + }, + model: 'test-model', + stopReason: 'endTurn' + }; +} + +/** + * Build a mock list roots response (ListRootsResult). + */ +export function mockListRootsResponse(): Record { + return { + roots: [ + { + uri: 'file:///test/root', + name: 'Test Root' + } + ] + }; +} + +// ─── Spec References ───────────────────────────────────────────────────────── + +/** + * SEP reference for InputRequiredResult / MRTR tests. + */ +export const MRTR_SPEC_REFERENCES = [ + { + id: 'SEP-2322', + url: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr' + } +]; diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts new file mode 100644 index 0000000..64ad4ba --- /dev/null +++ b/src/scenarios/server/input-required-result.ts @@ -0,0 +1,1672 @@ +/** + * SEP-2322: InputRequiredResult - Ephemeral Workflow Tests + * + * Tests the ephemeral (stateless) workflow where servers respond with + * InputRequiredResult containing inputRequests and/or requestState, and + * clients retry with inputResponses and echoed requestState. + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + SpecVersion +} from '../../types'; +import type { RunContext } from '../../connection'; +import { + sendRpc, + isInputRequiredResult, + isCompleteResult, + mockElicitResponse, + mockSamplingResponse, + mockListRootsResponse, + MRTR_SPEC_REFERENCES +} from './input-required-result-helpers'; +import { notTestable } from '../untestable'; + +// ─── A1: Basic Elicitation ──────────────────────────────────────────────────── + +export class InputRequiredResultBasicElicitationScenario implements ClientScenario { + name = 'input-required-result-basic-elicitation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_elicitation\` (no arguments required). + +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "user_name": { + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + } + } + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"user_name"\`, return a complete result: + +\`\`\`json +{ + "content": [{ "type": "text", "text": "Hello, !" }] +} +\`\`\``; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Initial call — expect InputRequiredResult + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult but got a complete result. ' + + 'Server should return resultType: "input_required" with inputRequests.' + ); + } else { + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else if (!r1Result.inputRequests['user_name']) { + r1Errors.push('inputRequests missing expected key "user_name"'); + } else { + const req = r1Result.inputRequests['user_name']; + if (req.method !== 'elicitation/create') { + r1Errors.push( + `Expected method "elicitation/create", got "${req.method}"` + ); + } + } + } + + checks.push({ + id: 'sep-2322-elicitation-incomplete', + name: 'InputRequiredResultElicitationIncomplete', + description: + 'Server returns InputRequiredResult with elicitation inputRequest', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses — expect complete result + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: mockElicitResponse({ name: 'Alice' }) + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with inputResponses' + ); + } else { + const content = r2Result.content as + | Array<{ type: string; text?: string }> + | undefined; + if (!content || !Array.isArray(content) || content.length === 0) { + r2Errors.push('Complete result missing content array'); + } + } + + checks.push({ + id: 'sep-2322-elicitation-complete', + name: 'InputRequiredResultElicitationComplete', + description: + 'Server returns complete result after retry with inputResponses', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-elicitation-incomplete', + name: 'InputRequiredResultElicitationIncomplete', + description: + 'Server returns InputRequiredResult with elicitation inputRequest', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A2: Basic Sampling ────────────────────────────────────────────────────── + +export class InputRequiredResultBasicSamplingScenario implements ClientScenario { + name = 'input-required-result-basic-sampling'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_sampling\` (no arguments required). + +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "capital_question": { + "method": "sampling/createMessage", + "params": { + "messages": [{ + "role": "user", + "content": { "type": "text", "text": "What is the capital of France?" } + }], + "maxTokens": 100 + } + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"capital_question"\`, return a complete result with the sampling response text.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Initial call + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_sampling', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult with sampling inputRequest' + ); + } else { + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else { + const key = Object.keys(r1Result.inputRequests)[0]; + if (!key) { + r1Errors.push('inputRequests map is empty'); + } else { + const req = r1Result.inputRequests[key]; + if (req.method !== 'sampling/createMessage') { + r1Errors.push( + `Expected method "sampling/createMessage", got "${req.method}"` + ); + } + } + } + } + + checks.push({ + id: 'sep-2322-sampling-incomplete', + name: 'InputRequiredResultSamplingIncomplete', + description: + 'Server returns InputRequiredResult with sampling inputRequest', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_sampling', + arguments: {}, + inputResponses: { + [inputKey]: mockSamplingResponse('The capital of France is Paris.') + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with sampling response' + ); + } + + checks.push({ + id: 'sep-2322-sampling-complete', + name: 'InputRequiredResultSamplingComplete', + description: + 'Server returns complete result after retry with sampling response', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-sampling-incomplete', + name: 'InputRequiredResultSamplingIncomplete', + description: + 'Server returns InputRequiredResult with sampling inputRequest', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A3: Basic ListRoots ───────────────────────────────────────────────────── + +export class InputRequiredResultBasicListRootsScenario implements ClientScenario { + name = 'input-required-result-basic-list-roots'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_list_roots\` (no arguments required). + +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "client_roots": { + "method": "roots/list", + "params": {} + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"client_roots"\` (a ListRootsResult with a \`roots\` array), return a complete result that references the provided roots.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Initial call + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_list_roots', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult with roots/list inputRequest' + ); + } else { + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else { + const key = Object.keys(r1Result.inputRequests)[0]; + if (!key) { + r1Errors.push('inputRequests map is empty'); + } else { + const req = r1Result.inputRequests[key]; + if (req.method !== 'roots/list') { + r1Errors.push( + `Expected method "roots/list", got "${req.method}"` + ); + } + } + } + } + + checks.push({ + id: 'sep-2322-list-roots-incomplete', + name: 'InputRequiredResultListRootsIncomplete', + description: + 'Server returns InputRequiredResult with roots/list inputRequest', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_list_roots', + arguments: {}, + inputResponses: { + [inputKey]: mockListRootsResponse() + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with roots response' + ); + } + + checks.push({ + id: 'sep-2322-list-roots-complete', + name: 'InputRequiredResultListRootsComplete', + description: + 'Server returns complete result after retry with roots response', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-list-roots-incomplete', + name: 'InputRequiredResultListRootsIncomplete', + description: + 'Server returns InputRequiredResult with roots/list inputRequest', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A4: Request State ────────────────────────────────────────────────────── + +export class InputRequiredResultRequestStateScenario implements ClientScenario { + name = 'input-required-result-request-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that requestState is correctly round-tripped in ephemeral InputRequiredResult flow (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_request_state\` (no arguments required). + +**Behavior (Round 1):** Return an \`InputRequiredResult\` with both \`inputRequests\` and \`requestState\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "confirm": { + "method": "elicitation/create", + "params": { + "message": "Please confirm", + "requestedSchema": { + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + "required": ["ok"] + } + } + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` AND the echoed \`requestState\`, validate the state and return a complete result. The text content MUST include the word "state-ok" to confirm the server received and validated the requestState.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult'); + } else { + if (!r1Result.requestState) { + r1Errors.push('InputRequiredResult missing requestState'); + } + if (typeof r1Result.requestState !== 'string') { + r1Errors.push('requestState must be a string'); + } + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } + } + + checks.push({ + id: 'sep-2322-request-state-incomplete', + name: 'InputRequiredResultRequestStateIncomplete', + description: + 'Server returns InputRequiredResult with both inputRequests and requestState', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + requestState + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', + arguments: {}, + inputResponses: { + [inputKey]: mockElicitResponse({ ok: true }) + }, + requestState: r1Result.requestState + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with requestState' + ); + } + + checks.push({ + id: 'sep-2322-request-state-complete', + name: 'InputRequiredResultRequestStateComplete', + description: + 'Server validates echoed requestState and returns complete result', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-request-state-incomplete', + name: 'InputRequiredResultRequestStateIncomplete', + description: + 'Server returns InputRequiredResult with both inputRequests and requestState', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A5: Multiple Input Requests ───────────────────────────────────────────── + +export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { + name = 'input-required-result-multiple-input-requests'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_multiple_inputs\` (no arguments required). + +**Behavior (Round 1):** Return an \`InputRequiredResult\` with multiple \`inputRequests\` — elicitation, sampling, and roots/list — plus \`requestState\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "user_name": { + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + }, + "greeting": { + "method": "sampling/createMessage", + "params": { + "messages": [{ "role": "user", "content": { "type": "text", "text": "Generate a greeting" } }], + "maxTokens": 50 + } + }, + "client_roots": { + "method": "roots/list", + "params": {} + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing ALL keys and the echoed \`requestState\`, return a complete result.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multiple_inputs', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult'); + } else if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else { + if (!r1Result.requestState) { + r1Errors.push('InputRequiredResult missing requestState'); + } + + const keys = Object.keys(r1Result.inputRequests); + if (keys.length < 3) { + r1Errors.push( + `Expected at least 3 inputRequests, got ${keys.length}` + ); + } + + // Check that required method types are present + const methods = new Set( + keys.map((k) => r1Result.inputRequests![k].method) + ); + if (!methods.has('elicitation/create')) { + r1Errors.push('Expected an elicitation/create inputRequest'); + } + if (!methods.has('sampling/createMessage')) { + r1Errors.push('Expected a sampling/createMessage inputRequest'); + } + if (!methods.has('roots/list')) { + r1Errors.push('Expected a roots/list inputRequest'); + } + if (methods.size < 3) { + r1Errors.push( + 'Expected inputRequests with different method types (elicitation + sampling + roots/list)' + ); + } + } + + checks.push({ + id: 'sep-2322-multiple-inputs-incomplete', + name: 'InputRequiredResultMultipleInputsIncomplete', + description: + 'Server returns InputRequiredResult with multiple inputRequests of different types', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Respond to all input requests + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputResponses: Record = {}; + for (const [key, req] of Object.entries(r1Result.inputRequests!)) { + if (req.method === 'elicitation/create') { + inputResponses[key] = mockElicitResponse({ name: 'Alice' }); + } else if (req.method === 'sampling/createMessage') { + inputResponses[key] = mockSamplingResponse('Hello there!'); + } else if (req.method === 'roots/list') { + inputResponses[key] = mockListRootsResponse(); + } + } + + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multiple_inputs', + arguments: {}, + inputResponses, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after providing all inputResponses' + ); + } + + checks.push({ + id: 'sep-2322-multiple-inputs-complete', + name: 'InputRequiredResultMultipleInputsComplete', + description: + 'Server returns complete result after all inputResponses are provided', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-multiple-inputs-incomplete', + name: 'InputRequiredResultMultipleInputsIncomplete', + description: + 'Server returns InputRequiredResult with multiple inputRequests of different types', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A6: Multi-Round ───────────────────────────────────────────────────────── + +export class InputRequiredResultMultiRoundScenario implements ClientScenario { + name = 'input-required-result-multi-round'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test multi-round ephemeral InputRequiredResult flow with evolving requestState (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_multi_round\` (no arguments required). + +**Behavior (Round 1):** Return an \`InputRequiredResult\` with an elicitation request and \`requestState\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "step1": { + "method": "elicitation/create", + "params": { + "message": "Step 1: What is your name?", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` for step1 + requestState, return ANOTHER \`InputRequiredResult\` with a new elicitation and updated requestState: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "step2": { + "method": "elicitation/create", + "params": { + "message": "Step 2: What is your favorite color?", + "requestedSchema": { + "type": "object", + "properties": { "color": { "type": "string" } }, + "required": ["color"] + } + } + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 3):** When called with \`inputResponses\` for step2 + updated requestState, return a complete result.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', + arguments: {} + }); + + const r1Result = r1.result; + let round1Complete = false; + + if ( + !r1.error && + r1Result && + isInputRequiredResult(r1Result) && + r1Result.inputRequests && + r1Result.requestState + ) { + round1Complete = true; + } + + checks.push({ + id: 'sep-2322-multi-round-r1', + name: 'InputRequiredResultMultiRoundR1', + description: + 'Round 1: Server returns InputRequiredResult with requestState', + status: round1Complete ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: round1Complete + ? undefined + : 'Expected InputRequiredResult with inputRequests and requestState', + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + if (!round1Complete || !isInputRequiredResult(r1Result)) return checks; + + // Round 2: Retry — expect another InputRequiredResult + const r1InputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', + arguments: {}, + inputResponses: { + [r1InputKey]: mockElicitResponse({ name: 'Alice' }) + }, + requestState: r1Result.requestState + }); + + const r2Result = r2.result; + let round2Complete = false; + + if ( + !r2.error && + r2Result && + isInputRequiredResult(r2Result) && + r2Result.inputRequests && + r2Result.requestState + ) { + // requestState should have changed + if (r2Result.requestState !== r1Result.requestState) { + round2Complete = true; + } + } + + checks.push({ + id: 'sep-2322-multi-round-r2', + name: 'InputRequiredResultMultiRoundR2', + description: + 'Round 2: Server returns another InputRequiredResult with updated requestState', + status: round2Complete ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: round2Complete + ? undefined + : 'Expected new InputRequiredResult with different requestState', + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + + if (!round2Complete || !isInputRequiredResult(r2Result)) return checks; + + // Round 3: Final retry — expect complete result + const r2InputKey = Object.keys(r2Result.inputRequests!)[0]; + const r3 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', + arguments: {}, + inputResponses: { + [r2InputKey]: mockElicitResponse({ color: 'blue' }) + }, + requestState: r2Result.requestState + }); + + const r3Result = r3.result; + const round3Complete = + !r3.error && r3Result != null && isCompleteResult(r3Result); + + checks.push({ + id: 'sep-2322-multi-round-r3', + name: 'InputRequiredResultMultiRoundR3', + description: 'Round 3: Server returns complete result', + status: round3Complete ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: round3Complete + ? undefined + : 'Expected complete result after final retry', + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r3Result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-multi-round-r1', + name: 'InputRequiredResultMultiRoundR1', + description: + 'Round 1: Server returns InputRequiredResult with requestState', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A7: Missing Input Response ────────────────────────────────────────────── + +export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { + name = 'input-required-result-missing-input-response'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). + +**Server Implementation Requirements:** + +Use the same tool as A1: \`test_input_required_result_elicitation\`. + +**Behavior:** When the client retries with \`inputResponses\` that are missing required keys or contain wrong keys, the server SHOULD respond with a new \`InputRequiredResult\` re-requesting the missing information (NOT a JSON-RPC error).`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Send wrong inputResponses (wrong key) + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + wrong_key: mockElicitResponse({ data: 'wrong' }) + } + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + // A JSON-RPC error is acceptable but the SEP prefers re-requesting + r1Errors.push( + 'Server returned JSON-RPC error instead of re-requesting via InputRequiredResult. ' + + 'SEP-2322 recommends servers re-request missing information.' + ); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult re-requesting missing information, ' + + 'but got a complete result' + ); + } + + checks.push({ + id: 'sep-2322-missing-response-rerequests', + name: 'InputRequiredResultMissingResponseRerequests', + description: + 'Server re-requests missing inputResponses via new InputRequiredResult', + status: r1Errors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-missing-response-rerequests', + name: 'InputRequiredResultMissingResponseRerequests', + description: + 'Server re-requests missing inputResponses via new InputRequiredResult', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A9: Non-Tool Request (prompts/get) ────────────────────────────────────── + +export class InputRequiredResultNonToolRequestScenario implements ClientScenario { + name = 'input-required-result-non-tool-request'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test InputRequiredResult on a non-tool request (prompts/get) to verify InputRequiredResult is universal (SEP-2322). + +**Server Implementation Requirements:** + +Implement a prompt named \`test_input_required_result_prompt\` that requires elicitation input. + +**Behavior (Round 1):** When \`prompts/get\` is called for \`test_input_required_result_prompt\` without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "user_context": { + "method": "elicitation/create", + "params": { + "message": "What context should the prompt use?", + "requestedSchema": { + "type": "object", + "properties": { "context": { "type": "string" } }, + "required": ["context"] + } + } + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\`, return a complete \`GetPromptResult\`.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt' + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult from prompts/get'); + } else if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } + + checks.push({ + id: 'sep-2322-non-tool-incomplete', + name: 'InputRequiredResultNonToolIncomplete', + description: + 'prompts/get returns InputRequiredResult with inputRequests', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt', + inputResponses: { + [inputKey]: mockElicitResponse({ context: 'test context' }) + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push('Expected complete GetPromptResult after retry'); + } else if (!r2Result.messages) { + r2Errors.push( + 'Complete result missing messages (expected GetPromptResult)' + ); + } + + checks.push({ + id: 'sep-2322-non-tool-complete', + name: 'InputRequiredResultNonToolComplete', + description: + 'prompts/get returns complete GetPromptResult after retry with inputResponses', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-non-tool-incomplete', + name: 'InputRequiredResultNonToolIncomplete', + description: + 'prompts/get returns InputRequiredResult with inputRequests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A10: ResultType Included ──────────────────────────────────────────────── + +export class InputRequiredResultResultTypeScenario implements ClientScenario { + name = 'input-required-result-result-type'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server explicitly includes resultType field in InputRequiredResult responses (SEP-2322). + +**Server Implementation Requirements:** + +Uses the same tool as A1: \`test_input_required_result_elicitation\`. + +This scenario verifies that the resultType field is explicitly present in the response (not just inferred).`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {} + }); + + const r1Result = r1.result; + const errors: string[] = []; + + if (r1.error) { + errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + errors.push('No result in response'); + } else if (!('resultType' in r1Result)) { + errors.push( + 'resultType field is missing from response. Servers MUST include resultType to indicate the type of the result.' + ); + } else if (r1Result.resultType !== 'input_required') { + errors.push( + `Expected resultType "input_required", got "${r1Result.resultType}"` + ); + } + + checks.push({ + id: 'sep-2322-result-type-included', + name: 'ResultTypeIncluded', + description: + 'Server includes resultType field in InputRequiredResult response', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-result-type-included', + name: 'ResultTypeIncluded', + description: + 'Server includes resultType field in InputRequiredResult response', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A11: Unsupported Methods ──────────────────────────────────────────────── + +export class InputRequiredResultUnsupportedMethodsScenario implements ClientScenario { + name = 'input-required-result-unsupported-methods'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server does NOT return InputRequiredResult on unsupported methods (SEP-2322). + +Servers MUST NOT send InputRequiredResult responses on any client requests other than the supported ones (prompts/get, resources/read, tools/call, tasks/result).`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + const errors: string[] = []; + + const unsupportedMethods = ['tools/list', 'prompts/list']; + + try { + for (const method of unsupportedMethods) { + const resp = await sendRpc(serverUrl, method, {}); + if ( + resp.result && + (resp.result as Record).resultType === + 'input_required' + ) { + errors.push( + `${method} returned InputRequiredResult, but it is not a supported method for MRTR` + ); + } + } + + checks.push({ + id: 'sep-2322-not-on-unsupported-requests', + name: 'NotOnUnsupportedRequests', + description: + 'Server does not return InputRequiredResult on unsupported methods', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES + }); + } catch (error) { + checks.push({ + id: 'sep-2322-not-on-unsupported-requests', + name: 'NotOnUnsupportedRequests', + description: + 'Server does not return InputRequiredResult on unsupported methods', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A12: Tampered State Rejection ─────────────────────────────────────────── + +export class InputRequiredResultTamperedStateScenario implements ClientScenario { + name = 'input-required-result-tampered-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server rejects tampered requestState (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_tampered_state\` (no arguments required). + +**Behavior (Round 1):** When called without inputResponses, return an InputRequiredResult with +integrity-protected requestState (e.g. HMAC-signed). + +**Behavior (Round 2 - tampered):** When called with a modified/tampered requestState, return a +JSON-RPC error (code -32602 or similar) indicating integrity check failure.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Get valid InputRequiredResult with signed requestState + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_tampered_state', + arguments: {} + }); + + const r1Result = r1.result; + if (r1.error || !r1Result || !isInputRequiredResult(r1Result)) { + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Prerequisite failed: could not get initial InputRequiredResult with requestState', + specReferences: MRTR_SPEC_REFERENCES + }); + return checks; + } + + if (!r1Result.requestState) { + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Server did not include requestState in InputRequiredResult', + specReferences: MRTR_SPEC_REFERENCES + }); + return checks; + } + + // Round 2: Tamper with the requestState and retry + const tamperedState = r1Result.requestState + '-TAMPERED'; + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_tampered_state', + arguments: {}, + inputResponses: { + [inputKey]: mockElicitResponse({ ok: true }) + }, + requestState: tamperedState + }); + + const errors: string[] = []; + + if (!r2.error) { + // The only acceptable response to tampered state is a JSON-RPC error. + // Returning a complete result OR re-prompting (InputRequiredResult) both + // indicate the server did not reject the tampered state. + if (r2.result && isCompleteResult(r2.result)) { + errors.push( + 'Server accepted tampered requestState and returned complete result. ' + + 'Servers MUST reject state that fails integrity verification.' + ); + } else { + errors.push( + 'Server did not return a JSON-RPC error for tampered requestState. ' + + 'Servers MUST reject state that fails integrity verification.' + ); + } + } + + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { tamperedResponse: r2.result ?? r2.error } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A13: Respect Client Capabilities ──────────────────────────────────────── + +export class InputRequiredResultCapabilityCheckScenario implements ClientScenario { + name = 'input-required-result-capability-check'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server only sends inputRequests for capabilities the client declared (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_capabilities\` (no arguments required). + +**Behavior:** Read client capabilities from \`_meta['io.modelcontextprotocol/clientCapabilities']\`. +Only include inputRequests for methods the client supports. For example, if the client declares +\`sampling: {}\` but NOT \`elicitation\`, only include \`sampling/createMessage\` inputRequests.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Send request with only sampling capability (no elicitation) + const resp = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_capabilities', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { + sampling: {} + // deliberately omitting elicitation + } + } + }); + + const result = resp.result; + const errors: string[] = []; + let untestable = false; + + if (resp.error) { + errors.push(`JSON-RPC error: ${resp.error.message}`); + } else if (!result) { + errors.push('No result in response'); + } else if (isInputRequiredResult(result)) { + const inputRequests = Object.entries(result.inputRequests ?? {}); + // `inputRequests` is optional (a result carrying only `requestState` is + // still valid), so naming none does not violate the MUST NOT this check + // scores — but it leaves nothing to scan, and the loop below would then + // record no error at all, scoring SUCCESS without having verified it. + if (inputRequests.length === 0) { + untestable = true; + errors.push( + notTestable( + 'server returned no inputRequests, so the capability restriction was never exercised' + ) + ); + } + // Check that no elicitation requests are included (client didn't declare it) + for (const [key, req] of inputRequests) { + if (req.method === 'elicitation/create') { + errors.push( + `Server included elicitation/create inputRequest (key: "${key}") ` + + 'but client did not declare elicitation capability' + ); + } + } + } else if (isCompleteResult(result)) { + errors.push( + 'Server returned complete result; expected InputRequiredResult with sampling-only inputRequests' + ); + } + + checks.push({ + id: 'sep-2322-respect-client-capabilities', + name: 'RespectClientCapabilities', + description: + 'Server only includes inputRequests for declared client capabilities', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: untestable ? { result, untestable: true } : { result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-respect-client-capabilities', + name: 'RespectClientCapabilities', + description: + 'Server only includes inputRequests for declared client capabilities', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A14: Ignore Unexpected Params ─────────────────────────────────────────── + +export class InputRequiredResultIgnoreExtraParamsScenario implements ClientScenario { + name = 'input-required-result-ignore-extra-params'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server ignores unexpected extra parameters in InputResponses (SEP-2322). + +**Server Implementation Requirements:** + +Uses the same tool as A1: \`test_input_required_result_elicitation\`. + +This scenario sends correct inputResponses PLUS extra unrecognized keys. The server SHOULD ignore +the extra keys and complete normally.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Send retry with correct inputResponses + extra unknown keys + const resp = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: mockElicitResponse({ name: 'Alice' }), + unknown_extra_key: { action: 'accept', content: { foo: 'bar' } }, + another_unexpected: { action: 'accept', content: { baz: 123 } } + } + }); + + const result = resp.result; + const errors: string[] = []; + + if (resp.error) { + errors.push( + `Server returned JSON-RPC error when extra params were included: ${resp.error.message}. ` + + 'Servers SHOULD ignore unrecognized information.' + ); + } else if (!result) { + errors.push('No result in response'); + } else if (!isCompleteResult(result)) { + errors.push( + 'Server did not return complete result when valid inputResponses were provided alongside extra keys. ' + + 'Servers SHOULD ignore information they do not recognize.' + ); + } + + checks.push({ + id: 'sep-2322-ignore-unexpected-params', + name: 'IgnoreUnexpectedParams', + description: 'Server ignores extra unrecognized keys in inputResponses', + status: errors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-ignore-unexpected-params', + name: 'IgnoreUnexpectedParams', + description: 'Server ignores extra unrecognized keys in inputResponses', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A15: Validate InputResponses ──────────────────────────────────────────── + +export class InputRequiredResultValidateInputScenario implements ClientScenario { + name = 'input-required-result-validate-input'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server validates InputResponses and returns appropriate errors (SEP-2322). + +**Server Implementation Requirements:** + +Uses the same tool as A1: \`test_input_required_result_elicitation\`. + +This scenario sends completely invalid inputResponses structures. The server SHOULD validate them +and return a JSON-RPC error or a new InputRequiredResult.`; + + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; + const checks: ConformanceCheck[] = []; + + try { + // Send inputResponses with invalid structure (number instead of object + // for the response). Deliberately schema-invalid, so wire-schema + // validation is skipped for this call. + const resp = await sendRpc( + serverUrl, + 'tools/call', + { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: 12345 as unknown as Record + } + }, + { skipValidation: true } + ); + + const validateErrors: string[] = []; + + // Server should either error or re-request — NOT return a complete result + // with the invalid data silently accepted + if (!resp.error && resp.result && isCompleteResult(resp.result)) { + validateErrors.push( + 'Server accepted invalid inputResponses (number instead of object) and returned complete result. ' + + 'Servers SHOULD validate InputResponses data.' + ); + } + + checks.push({ + id: 'sep-2322-validate-input-responses', + name: 'ValidateInputResponses', + description: 'Server validates InputResponses structure', + status: validateErrors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: + validateErrors.length > 0 ? validateErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: resp.result, error: resp.error } + }); + + // Also test: send completely malformed inputResponses (null). + // Deliberately schema-invalid; skip wire-schema validation. + const resp2 = await sendRpc( + serverUrl, + 'tools/call', + { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: null as unknown as Record + }, + { skipValidation: true } + ); + + const protocolErrors: string[] = []; + + if (!resp2.error && resp2.result && isCompleteResult(resp2.result)) { + protocolErrors.push( + 'Server accepted null inputResponses and returned complete result. ' + + 'Protocol errors SHOULD return a JSON-RPC error response.' + ); + } + + checks.push({ + id: 'sep-2322-error-on-protocol-error', + name: 'ErrorOnProtocolError', + description: + 'Server returns JSON-RPC error for protocol-level input errors', + status: protocolErrors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: + protocolErrors.length > 0 ? protocolErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: resp2.result, error: resp2.error } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-validate-input-responses', + name: 'ValidateInputResponses', + description: 'Server validates InputResponses structure', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts index be20f2a..d1487a5 100644 --- a/src/scenarios/server/json-schema-2020-12.ts +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -1,54 +1,114 @@ /** - * JSON Schema 2020-12 conformance test scenario (SEP-1613) + * JSON Schema 2020-12 conformance test scenario (SEP-1613, SEP-2106) * * Validates that MCP servers correctly preserve JSON Schema 2020-12 keywords * in tool definitions, ensuring implementations don't strip $schema, $defs, - * or additionalProperties fields. + * or additionalProperties fields (SEP-1613). + * + * SEP-2106 broadened inputSchema to permit the full JSON Schema 2020-12 + * vocabulary alongside the required root `type: "object"`. This scenario also + * verifies that composition (allOf/anyOf), conditional (if/then/else), and + * reference ($anchor) keywords survive tools/list rather than being stripped. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { + CheckStatus, + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; +import type { RunContext } from '../../connection'; +import type { ListToolsResult } from '../../spec-types/2025-11-25'; -const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool'; -const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; +export const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool'; +export const EXPECTED_SCHEMA_DIALECT = + 'https://json-schema.org/draft/2020-12/schema'; + +/** + * Canonical focal `inputSchema` used by both the server-side + * `json-schema-2020-12` scenario (as the schema servers under test are + * expected to emit) and the client-side `json-schema-2020-12-preservation` + * scenario (as the schema the mock server actually emits on the wire). + * + * Keeping a single source of truth ensures the documented requirements and + * the runtime test fixture stay in sync. + */ +export const JSON_SCHEMA_2020_12_FIXTURE = { + $schema: EXPECTED_SCHEMA_DIALECT, + type: 'object', + $defs: { + address: { + $anchor: 'addressDef', + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' }, + contactMethod: { type: 'string', enum: ['phone', 'email'] }, + phone: { type: 'string' }, + email: { type: 'string' } + }, + allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }], + if: { + properties: { contactMethod: { const: 'phone' } }, + required: ['contactMethod'] + }, + then: { required: ['phone'] }, + else: { required: ['email'] }, + additionalProperties: false +} as const; + +/** + * Soft version gate for the SEP-2106 keyword-preservation checks. + * + * Preserving the broader JSON Schema 2020-12 vocabulary is good behavior at + * any protocol version, so a server that preserves the keywords gets SUCCESS + * regardless. Stripping them is only a conformance FAILURE when the run + * targets a protocol version that includes SEP-2106 (the draft); when + * targeting an earlier dated version the requirement does not apply, so the + * check is SKIPPED rather than failed. + */ +export function sep2106KeywordCheckStatus( + preserved: boolean, + targetProtocolVersion: string +): CheckStatus { + if (preserved) { + return 'SUCCESS'; + } + return targetProtocolVersion === DRAFT_PROTOCOL_VERSION + ? 'FAILURE' + : 'SKIPPED'; +} export class JsonSchema2020_12Scenario implements ClientScenario { name = 'json-schema-2020-12'; - specVersions: SpecVersion[] = ['2025-11-25']; - description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613). + readonly source = { introducedIn: '2025-11-25' } as const; + description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613, SEP-2106). **Server Implementation Requirements:** -Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema 2020-12 features: +Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema 2020-12 features, including the broader vocabulary permitted by SEP-2106 (an \`$anchor\` inside \`$defs\`, composition keywords \`allOf\`/\`anyOf\`, and conditional keywords \`if\`/\`then\`/\`else\`): \`\`\`json -{ - "name": "${EXPECTED_TOOL_NAME}", - "description": "Tool with JSON Schema 2020-12 features", - "inputSchema": { - "$schema": "${EXPECTED_SCHEMA_DIALECT}", - "type": "object", - "$defs": { - "address": { - "type": "object", - "properties": { - "street": { "type": "string" }, - "city": { "type": "string" } - } - } - }, - "properties": { - "name": { "type": "string" }, - "address": { "$ref": "#/$defs/address" } - }, - "additionalProperties": false - } -} +${JSON.stringify( + { + name: EXPECTED_TOOL_NAME, + description: 'Tool with JSON Schema 2020-12 features', + inputSchema: JSON_SCHEMA_2020_12_FIXTURE + }, + null, + 2 +)} \`\`\` -**Verification**: The test verifies that \`$schema\`, \`$defs\`, and \`additionalProperties\` are preserved in the tool listing response.`; +**Verification**: The test verifies that \`$schema\`, \`$defs\`, and \`additionalProperties\` are preserved (SEP-1613), and that the composition (\`allOf\`/\`anyOf\`), conditional (\`if\`/\`then\`/\`else\`), and \`$anchor\` keywords are preserved (SEP-2106), in the tool listing response.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; const specReferences = [ { @@ -56,10 +116,19 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema url: 'https://github.com/modelcontextprotocol/specification/pull/655' } ]; + const sep2106References = [ + { + id: 'SEP-2106', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2106' + } + ]; try { - const connection = await connectToServer(serverUrl); - const result = await connection.client.listTools(); + const conn = await ctx.connect(); + // Spec version this run is targeting; used to soft-gate the SEP-2106 + // checks below. + const targetVersion = ctx.specVersion; + const result = await conn.request('tools/list'); // Find the test tool const tool = result.tools?.find((t) => t.name === EXPECTED_TOOL_NAME); @@ -82,7 +151,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema }); if (!tool) { - await connection.close(); + await conn.close(); return checks; } @@ -164,7 +233,108 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema } }); - await connection.close(); + // SEP-2106: the full JSON Schema 2020-12 vocabulary is permitted in + // inputSchema and must survive tools/list rather than being stripped to + // properties/required. These checks are soft-gated on the protocol + // version this run targets (see sep2106KeywordCheckStatus): stripping + // the keywords is only a FAILURE when targeting the draft version, and + // SKIPPED otherwise. + const skippedSuffix = ` (run targets protocol version ${targetVersion}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`; + + // Check 5: composition keywords (allOf / anyOf) preserved + const allOf = inputSchema['allOf']; + const hasAllOf = Array.isArray(allOf) && allOf.length > 0; + const hasNestedAnyOf = + Array.isArray(allOf) && + allOf.some( + (sub) => + typeof sub === 'object' && + sub !== null && + Array.isArray((sub as Record)['anyOf']) + ); + const compositionPreserved = hasAllOf && hasNestedAnyOf; + const compositionStatus = sep2106KeywordCheckStatus( + compositionPreserved, + targetVersion + ); + + checks.push({ + id: 'sep-2106-composition-keywords-preserved', + name: 'JsonSchema2020_12CompositionKeywords', + description: + 'inputSchema composition keywords (allOf/anyOf) preserved (SEP-2106)', + status: compositionStatus, + timestamp: new Date().toISOString(), + errorMessage: compositionPreserved + ? undefined + : (!hasAllOf + ? 'allOf keyword missing from inputSchema - composition keyword was likely stripped' + : 'nested anyOf missing from allOf - composition keyword was likely stripped') + + (compositionStatus === 'SKIPPED' ? skippedSuffix : ''), + specReferences: sep2106References, + details: { + hasAllOf, + hasNestedAnyOf, + targetProtocolVersion: targetVersion + } + }); + + // Check 6: conditional keywords (if / then / else) preserved + const hasIf = 'if' in inputSchema; + const hasThen = 'then' in inputSchema; + const hasElse = 'else' in inputSchema; + const conditionalPreserved = hasIf && hasThen && hasElse; + const conditionalStatus = sep2106KeywordCheckStatus( + conditionalPreserved, + targetVersion + ); + + checks.push({ + id: 'sep-2106-conditional-keywords-preserved', + name: 'JsonSchema2020_12ConditionalKeywords', + description: + 'inputSchema conditional keywords (if/then/else) preserved (SEP-2106)', + status: conditionalStatus, + timestamp: new Date().toISOString(), + errorMessage: conditionalPreserved + ? undefined + : `Conditional keywords missing (if=${hasIf}, then=${hasThen}, else=${hasElse}) - likely stripped` + + (conditionalStatus === 'SKIPPED' ? skippedSuffix : ''), + specReferences: sep2106References, + details: { + hasIf, + hasThen, + hasElse, + targetProtocolVersion: targetVersion + } + }); + + // Check 7: reference keyword ($anchor) preserved within $defs.address + const addressDef = defsValue?.['address'] as + | Record + | undefined; + const hasAnchor = !!addressDef && '$anchor' in addressDef; + const anchorStatus = sep2106KeywordCheckStatus(hasAnchor, targetVersion); + + checks.push({ + id: 'sep-2106-anchor-keyword-preserved', + name: 'JsonSchema2020_12AnchorKeyword', + description: + 'inputSchema reference keyword ($anchor) preserved in $defs (SEP-2106)', + status: anchorStatus, + timestamp: new Date().toISOString(), + errorMessage: hasAnchor + ? undefined + : '$anchor missing from $defs.address - reference keyword was likely stripped' + + (anchorStatus === 'SKIPPED' ? skippedSuffix : ''), + specReferences: sep2106References, + details: { + hasAnchor, + anchorValue: addressDef?.['$anchor'] + } + }); + + await conn.close(); } catch (error) { checks.push({ id: 'json-schema-2020-12-error', diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts new file mode 100644 index 0000000..8183516 --- /dev/null +++ b/src/scenarios/server/lifecycle.test.ts @@ -0,0 +1,187 @@ +import { testContext } from '../../connection/testing'; +import { ServerInitializeScenario } from './lifecycle'; +import { connectToServer } from '../../connection/sdk-client'; + +vi.mock('../../connection/sdk-client', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + connectToServer: vi.fn() + }; +}); + +describe('ServerInitializeScenario', () => { + const serverUrl = 'http://localhost:3000/mcp'; + const closeMock = vi.fn().mockResolvedValue(undefined); + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + vi.mocked(connectToServer).mockResolvedValue({ + client: {} as any, + close: closeMock + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns INFO when the server does not provide an MCP-Session-Id header', async () => { + fetchMock.mockResolvedValue(new Response(null)); + + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); + + expect(connectToServer).toHaveBeenCalledWith( + serverUrl, + {}, + testContext(serverUrl).specVersion + ); + expect(closeMock).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + method: 'POST' + }) + ); + + expect(checks).toHaveLength(2); + expect(checks[0]?.id).toBe('server-initialize'); + expect(checks[0]?.status).toBe('SUCCESS'); + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'INFO', + details: { + message: + 'Server did not provide an MCP-Session-Id header (session ID is optional)' + } + }); + }); + + it('returns SUCCESS when the server provides a visible ASCII session ID', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + headers: { + 'mcp-session-id': 'session-123_ABC' + } + }) + ); + + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); + + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'SUCCESS', + details: { + sessionId: 'session-123_ABC' + } + }); + }); + + it('returns FAILURE when the server provides a non-ASCII session ID', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + headers: { + 'mcp-session-id': 'session-123-é' + } + }) + ); + + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); + + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'FAILURE', + errorMessage: + 'Session ID contains characters outside visible ASCII range (0x21-0x7E)', + details: { + sessionId: 'session-123-é' + } + }); + }); + + it('sends an HTTP DELETE with the issued session ID to terminate the raw session', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + headers: { + 'mcp-session-id': 'session-123_ABC' + } + }) + ); + + await new ServerInitializeScenario().run(testContext(serverUrl)); + + expect(fetchMock).toHaveBeenCalledWith( + serverUrl, + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'mcp-session-id': 'session-123_ABC' + }) + }) + ); + }); + + it('does not send DELETE when the server did not assign a session ID', async () => { + fetchMock.mockResolvedValue(new Response(null)); + + await new ServerInitializeScenario().run(testContext(serverUrl)); + + const deleteCalls = fetchMock.mock.calls.filter( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ); + expect(deleteCalls).toHaveLength(0); + }); + + it('bounds the session-id probe so a server that never answers cannot hang it', async () => { + fetchMock.mockResolvedValue(new Response(null)); + + await new ServerInitializeScenario().run(testContext(serverUrl)); + + const probe = fetchMock.mock.calls.find( + ([, init]) => (init as RequestInit | undefined)?.method === 'POST' + ); + expect(probe?.[1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it('cancels the probe response body so an SSE answer does not leak the connection', async () => { + // `Accept` on the probe includes `text/event-stream`, so a server is free + // to answer with a stream that stays open. Only the header is read, so the + // body has to be released explicitly. + const cancel = vi.fn().mockResolvedValue(undefined); + const body = new ReadableStream({ + start() { + // Never enqueue, never close: an SSE stream awaiting its first event. + }, + cancel + }); + + fetchMock.mockResolvedValueOnce( + new Response(body, { + headers: { + 'content-type': 'text/event-stream', + 'mcp-session-id': 'session-123_ABC' + } + }) + ); + fetchMock.mockResolvedValue(new Response(null)); + + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); + + expect(cancel).toHaveBeenCalled(); + expect(checks[1]).toMatchObject({ + id: 'server-session-id-visible-ascii', + status: 'SUCCESS' + }); + }); +}); diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 392a932..85101b2 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -2,12 +2,37 @@ * Lifecycle test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer } from './client-helper'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import type { RunContext } from '../../connection'; +import { + connectToServer, + terminateSessionRaw +} from '../../connection/sdk-client'; + +const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/; + +// The session-id probe only needs the response header. Bound the request so a +// server that accepts the connection and never answers fails this check +// instead of hanging the scenario before teardown ever runs. +const SESSION_ID_PROBE_TIMEOUT_MS = 5000; + +const SESSION_SPEC_REFERENCES = [ + { + id: 'MCP-Session-Management', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management' + } +]; export class ServerInitializeScenario implements ClientScenario { name = 'server-initialize'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test basic server initialization handshake. **Server Implementation Requirements:** @@ -18,14 +43,17 @@ export class ServerInitializeScenario implements ClientScenario { - Accept \`initialize\` request with client info and capabilities - Return valid initialize response with server info, protocol version, and capabilities - Accept \`initialized\` notification from client after handshake +- If a session ID is assigned, it MUST only contain visible ASCII characters (0x21 to 0x7E) -This test verifies the server can complete the two-phase initialization handshake successfully.`; +This test verifies the server can complete the two-phase initialization handshake successfully, +and validates session ID format if one is assigned.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); // The connection process already does initialization // Check that we have a connected client @@ -65,6 +93,103 @@ This test verifies the server can complete the two-phase initialization handshak } ] }); + return checks; + } + + // Check: Session ID visible ASCII validation + // Use a raw fetch to inspect the MCP-Session-Id response header, + // since the SDK client transport does not expose it. + let rawSessionId: string | null = null; + try { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': ctx.specVersion + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: ctx.specVersion, + capabilities: {}, + clientInfo: { + name: 'conformance-session-id-test', + version: '1.0.0' + } + } + }), + signal: AbortSignal.timeout(SESSION_ID_PROBE_TIMEOUT_MS) + }); + + const sessionId = response.headers.get('mcp-session-id'); + rawSessionId = sessionId; + + // Only the header is needed. `Accept` includes `text/event-stream`, so a + // server answering with an open SSE stream would otherwise leave this + // body unconsumed and leak the connection for the rest of the run. + await response.body?.cancel(); + + if (!sessionId) { + checks.push({ + id: 'server-session-id-visible-ascii', + name: 'ServerSessionIdVisibleAscii', + description: + 'Server-provided session ID uses only visible ASCII characters', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: SESSION_SPEC_REFERENCES, + details: { + message: + 'Server did not provide an MCP-Session-Id header (session ID is optional)' + } + }); + } else if (VISIBLE_ASCII_REGEX.test(sessionId)) { + checks.push({ + id: 'server-session-id-visible-ascii', + name: 'ServerSessionIdVisibleAscii', + description: + 'Server-provided session ID uses only visible ASCII characters', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: SESSION_SPEC_REFERENCES, + details: { + sessionId + } + }); + } else { + checks.push({ + id: 'server-session-id-visible-ascii', + name: 'ServerSessionIdVisibleAscii', + description: + 'Server-provided session ID uses only visible ASCII characters', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Session ID contains characters outside visible ASCII range (0x21-0x7E)', + specReferences: SESSION_SPEC_REFERENCES, + details: { + sessionId + } + }); + } + } catch (error) { + checks.push({ + id: 'server-session-id-visible-ascii', + name: 'ServerSessionIdVisibleAscii', + description: + 'Server-provided session ID uses only visible ASCII characters', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to send initialize request for session ID check: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SESSION_SPEC_REFERENCES + }); + } finally { + if (rawSessionId) { + await terminateSessionRaw(serverUrl, rawSessionId, ctx.specVersion); + } } return checks; diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts new file mode 100644 index 0000000..871ad73 --- /dev/null +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -0,0 +1,160 @@ +import { testContext } from '../../connection/testing'; +/** + * SEP-2322 MRTR negative tests. + * + * Positive tests run via the CLI runner against the everything-server + * (which implements MRTR in its stateless path). These negative tests run + * against a deliberately broken server to verify checks emit FAILURE. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'net'; +import path from 'path'; +import { + InputRequiredResultResultTypeScenario, + InputRequiredResultUnsupportedMethodsScenario, + InputRequiredResultTamperedStateScenario, + InputRequiredResultCapabilityCheckScenario +} from './input-required-result'; +import { + formatWireViolation, + takeWireViolations +} from '../../validation/wire-schema'; + +// The broken fixture violates the draft schema by design; drain the wire-schema recorder +// so the suite-wide guard doesn't re-flag it. Only the *implementation* may be invalid — +// a harness-origin violation is a real harness bug and must still fail. +afterEach(() => { + const { violations } = takeWireViolations(); + const harnessViolations = violations.filter((v) => v.origin === 'harness'); + if (harnessViolations.length > 0) { + throw new Error( + 'Harness-origin wire-schema violations in an MRTR negative test ' + + '(only the broken fixture may be invalid here):\n ' + + harnessViolations.map(formatWireViolation).join('\n ') + ); + } +}); + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function startServer(scriptPath: string, port: number): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString() }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +describe('SEP-2322 MRTR negative tests', () => { + let serverProcess: ChildProcess | null = null; + let SERVER_URL: string; + + beforeAll(async () => { + const port = await getFreePort(); + SERVER_URL = `http://localhost:${port}/mcp`; + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2322-mrtr-broken-server.ts' + ), + port + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE for sep-2322-result-type-included against server that omits resultType', async () => { + const scenario = new InputRequiredResultResultTypeScenario(); + const checks = await scenario.run(testContext(SERVER_URL)); + + const resultTypeCheck = checks.find( + (c) => c.id === 'sep-2322-result-type-included' + ); + expect(resultTypeCheck).toBeDefined(); + expect(resultTypeCheck?.status).toBe('FAILURE'); + }, 10000); + + it('emits FAILURE for sep-2322-not-on-unsupported-requests against server returning InputRequiredResult on tools/list', async () => { + const scenario = new InputRequiredResultUnsupportedMethodsScenario(); + const checks = await scenario.run(testContext(SERVER_URL)); + + const unsupportedCheck = checks.find( + (c) => c.id === 'sep-2322-not-on-unsupported-requests' + ); + expect(unsupportedCheck).toBeDefined(); + expect(unsupportedCheck?.status).toBe('FAILURE'); + }, 10000); + + it('emits FAILURE for sep-2322-reject-tampered-state against server that accepts tampered state', async () => { + const scenario = new InputRequiredResultTamperedStateScenario(); + const checks = await scenario.run(testContext(SERVER_URL)); + + const tamperedCheck = checks.find( + (c) => c.id === 'sep-2322-reject-tampered-state' + ); + expect(tamperedCheck).toBeDefined(); + expect(tamperedCheck?.status).toBe('FAILURE'); + }, 10000); + + it('reports sep-2322-respect-client-capabilities as untestable against a server whose input_required result requests nothing', async () => { + const scenario = new InputRequiredResultCapabilityCheckScenario(); + const checks = await scenario.run(testContext(SERVER_URL)); + + const capabilityCheck = checks.find( + (c) => c.id === 'sep-2322-respect-client-capabilities' + ); + expect(capabilityCheck).toBeDefined(); + expect(capabilityCheck?.status).toBe('FAILURE'); + // The requirement was not violated, it could not be exercised (#248). + expect(capabilityCheck?.errorMessage).toContain('Not testable:'); + expect(capabilityCheck?.details?.untestable).toBe(true); + }, 10000); +}); diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts new file mode 100644 index 0000000..746cf9c --- /dev/null +++ b/src/scenarios/server/negative.test.ts @@ -0,0 +1,522 @@ +import { testContext } from '../../connection/testing'; +import { spawn, ChildProcess } from 'child_process'; +import { createServer, type IncomingMessage, type Server } from 'http'; +import type { AddressInfo } from 'net'; +import path from 'path'; +import { DNSRebindingProtectionScenario } from './dns-rebinding'; +import { ResourcesNotFoundErrorScenario } from './resources'; +import { CachingScenario } from './caching'; +import { ToolsListScenario } from './tools'; +import { + JsonSchema2020_12Scenario, + sep2106KeywordCheckStatus +} from './json-schema-2020-12'; +import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; +import { takeWireViolations } from '../../validation/wire-schema'; + +function startServer(scriptPath: string, port: number): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString() }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +async function readJsonBody( + req: IncomingMessage +): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< + string, + unknown + >; +} + +function listenOnRandomPort(server: Server): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.listen(0, () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Server did not listen on a TCP port')); + return; + } + resolve(`http://localhost:${(address as AddressInfo).port}/mcp`); + }); + }); +} + +function closeHttpServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +describe('Server scenario negative tests', () => { + describe('dns-rebinding-protection', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3004; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/no-dns-rebinding-protection.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE against a server without rebinding protection', async () => { + const scenario = new DNSRebindingProtectionScenario(); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`) + ); + + const rebindingCheck = checks.find( + (c) => c.id === 'localhost-host-rebinding-rejected' + ); + expect(rebindingCheck?.status).toBe('FAILURE'); + }, 10000); + + it('sends initialized notification after a successful dated initialize', async () => { + const requests: Array<{ + method?: string; + host?: string; + origin?: string; + sessionId?: string; + }> = []; + const sessionId = 'session-338'; + const server = createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/mcp') { + res.writeHead(404).end(); + return; + } + + const body = await readJsonBody(req); + const method = + typeof body.method === 'string' ? body.method : undefined; + requests.push({ + method, + host: req.headers.host, + origin: + typeof req.headers.origin === 'string' + ? req.headers.origin + : undefined, + sessionId: + typeof req.headers['mcp-session-id'] === 'string' + ? req.headers['mcp-session-id'] + : undefined + }); + + if ( + req.headers.host === 'evil.example.com' || + req.headers.origin === 'http://evil.example.com' + ) { + res + .writeHead(403, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ error: 'forbidden' })); + return; + } + + if (method === 'initialize') { + res + .writeHead(200, { + 'Content-Type': 'application/json', + 'Mcp-Session-Id': sessionId + }) + .end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: LATEST_SPEC_VERSION, + capabilities: {}, + serverInfo: { + name: 'dns-rebinding-lifecycle-test', + version: '1.0.0' + } + } + }) + ); + return; + } + + if (method === 'notifications/initialized') { + res.writeHead( + req.headers['mcp-session-id'] === sessionId ? 202 : 400 + ); + res.end(); + return; + } + + res.writeHead(500).end(); + }); + + const serverUrl = await listenOnRandomPort(server); + try { + const checks = await new DNSRebindingProtectionScenario().run( + testContext(serverUrl, LATEST_SPEC_VERSION) + ); + + expect( + checks.find((c) => c.id === 'localhost-host-rebinding-rejected') + ?.status + ).toBe('SUCCESS'); + expect( + checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status + ).toBe('SUCCESS'); + + const validHost = new URL(serverUrl).host; + expect(requests).toEqual([ + expect.objectContaining({ + method: 'initialize', + host: 'evil.example.com', + origin: 'http://evil.example.com' + }), + expect.objectContaining({ + method: 'initialize', + host: validHost, + origin: `http://${validHost}` + }), + expect.objectContaining({ + method: 'notifications/initialized', + host: validHost, + origin: `http://${validHost}`, + sessionId + }) + ]); + } finally { + await closeHttpServer(server); + } + }, 10000); + + it('does not send initialized notification for the draft discover probe', async () => { + const methods: Array = []; + const server = createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/mcp') { + res.writeHead(404).end(); + return; + } + + const body = await readJsonBody(req); + const method = + typeof body.method === 'string' ? body.method : undefined; + methods.push(method); + + if ( + req.headers.host === 'evil.example.com' || + req.headers.origin === 'http://evil.example.com' + ) { + res + .writeHead(403, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ error: 'forbidden' })); + return; + } + + if (method === 'server/discover') { + res.writeHead(200, { 'Content-Type': 'application/json' }).end( + JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: {} + }) + ); + return; + } + + res.writeHead(500).end(); + }); + + const serverUrl = await listenOnRandomPort(server); + try { + const checks = await new DNSRebindingProtectionScenario().run( + testContext(serverUrl, DRAFT_PROTOCOL_VERSION) + ); + + expect( + checks.find((c) => c.id === 'localhost-host-rebinding-rejected') + ?.status + ).toBe('SUCCESS'); + expect( + checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status + ).toBe('SUCCESS'); + expect(methods).toEqual(['server/discover', 'server/discover']); + } finally { + await closeHttpServer(server); + } + }, 10000); + }); + + describe('sep-2164-resource-not-found', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3005; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2164-empty-contents.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE for no-empty-contents and WARNING for error-code against a server returning empty contents', async () => { + const scenario = new ResourcesNotFoundErrorScenario(); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION) + ); + + const noEmpty = checks.find((c) => c.id === 'sep-2164-no-empty-contents'); + expect(noEmpty?.status).toBe('FAILURE'); + + const errorCode = checks.find((c) => c.id === 'sep-2164-error-code'); + expect(errorCode?.status).toBe('WARNING'); + }, 10000); + }); + + describe('sep-2549-caching-hints', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3006; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2549-no-caching-hints.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE for presence checks against a server without caching hints', async () => { + const scenario = new CachingScenario(); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION) + ); + + // Should have at least 7 checks (5 presence + 2 aggregate) + expect(checks.length).toBeGreaterThanOrEqual(7); + + const presenceCheckIds = [ + 'sep-2549-tools-list-caching-hints', + 'sep-2549-prompts-list-caching-hints', + 'sep-2549-resources-list-caching-hints', + 'sep-2549-resources-templates-list-caching-hints', + 'sep-2549-resources-read-caching-hints' + ]; + + for (const checkId of presenceCheckIds) { + const check = checks.find((c) => c.id === checkId); + expect(check).toBeDefined(); + expect(check?.status).toBe('FAILURE'); + } + + // This fixture's whole point is omitting the caching hints the draft + // schema requires, so its responses are wire-schema-invalid by design. + expect(takeWireViolations().violations.length).toBeGreaterThan(0); + }, 15000); + }); + + describe('json-schema-2020-12 (SEP-2106)', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3007; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2106-stripped-schema.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('flags SEP-2106 keyword-preservation checks against a server that strips the 2020-12 vocabulary', async () => { + const scenario = new JsonSchema2020_12Scenario(); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`) + ); + + // The tool is still advertised, so it must be found... + const found = checks.find( + (c) => c.id === 'json-schema-2020-12-tool-found' + ); + expect(found?.status).toBe('SUCCESS'); + + // ...but the stripped 2020-12 keywords must be flagged. testContext() + // defaults to LATEST_SPEC_VERSION (2025-11-25), so the soft version gate + // reports SKIPPED rather than FAILURE; see sep2106KeywordCheckStatus. + const composition = checks.find( + (c) => c.id === 'sep-2106-composition-keywords-preserved' + ); + expect(composition?.status).toBe('SKIPPED'); + + const conditional = checks.find( + (c) => c.id === 'sep-2106-conditional-keywords-preserved' + ); + expect(conditional?.status).toBe('SKIPPED'); + + const anchor = checks.find( + (c) => c.id === 'sep-2106-anchor-keyword-preserved' + ); + expect(anchor?.status).toBe('SKIPPED'); + }, 10000); + }); + + describe('tools-list-deterministic-order', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3008; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/tools-list-rotated-order.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits WARNING for deterministic-order while tools-list still passes against a server that rotates its tool list', async () => { + const scenario = new ToolsListScenario(); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION) + ); + + const list = checks.find((c) => c.id === 'tools-list'); + expect(list?.status).toBe('SUCCESS'); + + const order = checks.find( + (c) => c.id === 'tools-list-deterministic-order' + ); + expect(order?.status).toBe('WARNING'); + expect(order?.errorMessage).toMatch(/different order/); + expect(order?.details).toMatchObject({ toolCount: 4, probes: 3 }); + expect(order?.details?.untestable).toBeUndefined(); + }, 10000); + }); + + describe('tools-name-format', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3009; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/invalid-tool-names.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits WARNING for tools-name-format against a server advertising invalid tool names', async () => { + const scenario = new ToolsListScenario(); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`, '2025-11-25') + ); + + const formatCheck = checks.find((c) => c.id === 'tools-name-format'); + expect(formatCheck?.status).toBe('WARNING'); + expect(formatCheck?.errorMessage).toContain('bad tool name'); + expect(formatCheck?.details).toMatchObject({ + results: expect.objectContaining({ + 'bad tool name': expect.stringMatching(/invalid:/) + }) + }); + }, 10000); + }); + + describe('sep2106KeywordCheckStatus (soft version gate)', () => { + it('passes preserved keywords at any target version', () => { + expect(sep2106KeywordCheckStatus(true, DRAFT_PROTOCOL_VERSION)).toBe( + 'SUCCESS' + ); + expect(sep2106KeywordCheckStatus(true, LATEST_SPEC_VERSION)).toBe( + 'SUCCESS' + ); + }); + + it('fails stripped keywords only when targeting the draft version', () => { + expect(sep2106KeywordCheckStatus(false, DRAFT_PROTOCOL_VERSION)).toBe( + 'FAILURE' + ); + expect(sep2106KeywordCheckStatus(false, LATEST_SPEC_VERSION)).toBe( + 'SKIPPED' + ); + }); + }); +}); diff --git a/src/scenarios/server/prompts.ts b/src/scenarios/server/prompts.ts index f62faac..c5a5f38 100644 --- a/src/scenarios/server/prompts.ts +++ b/src/scenarios/server/prompts.ts @@ -2,12 +2,16 @@ * Prompts test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer } from './client-helper'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import type { RunContext } from '../../connection'; +import type { + ListPromptsResult, + GetPromptResult +} from '../../spec-types/2025-06-18'; export class PromptsListScenario implements ClientScenario { name = 'prompts-list'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test listing available prompts. **Server Implementation Requirements:** @@ -21,13 +25,13 @@ export class PromptsListScenario implements ClientScenario { - \`description\` (string) - \`arguments\` (array, optional) - list of required arguments`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.listPrompts(); + const result = await conn.request('prompts/list'); // Validate response structure const errors: string[] = []; @@ -64,7 +68,7 @@ export class PromptsListScenario implements ClientScenario { } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'prompts-list', @@ -88,7 +92,7 @@ export class PromptsListScenario implements ClientScenario { export class PromptsGetSimpleScenario implements ClientScenario { name = 'prompts-get-simple'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test getting a simple prompt without arguments. **Server Implementation Requirements:** @@ -109,13 +113,13 @@ Implement a prompt named \`test_simple_prompt\` with no arguments that returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.getPrompt({ + const result = await conn.request('prompts/get', { name: 'test_simple_prompt' }); @@ -149,7 +153,7 @@ Implement a prompt named \`test_simple_prompt\` with no arguments that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'prompts-get-simple', @@ -173,7 +177,7 @@ Implement a prompt named \`test_simple_prompt\` with no arguments that returns: export class PromptsGetWithArgsScenario implements ClientScenario { name = 'prompts-get-with-args'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test parameterized prompt. **Server Implementation Requirements:** @@ -198,13 +202,13 @@ Returns (with args \`{arg1: "hello", arg2: "world"}\`): } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.getPrompt({ + const result = await conn.request('prompts/get', { name: 'test_prompt_with_arguments', arguments: { arg1: 'testValue1', @@ -245,7 +249,7 @@ Returns (with args \`{arg1: "hello", arg2: "world"}\`): } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'prompts-get-with-args', @@ -269,7 +273,7 @@ Returns (with args \`{arg1: "hello", arg2: "world"}\`): export class PromptsGetEmbeddedResourceScenario implements ClientScenario { name = 'prompts-get-embedded-resource'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test prompt with embedded resource content. **Server Implementation Requirements:** @@ -304,13 +308,13 @@ Returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.getPrompt({ + const result = await conn.request('prompts/get', { name: 'test_prompt_with_embedded_resource', arguments: { resourceUri: 'test://example-resource' @@ -351,7 +355,7 @@ Returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'prompts-get-embedded-resource', @@ -375,7 +379,7 @@ Returns: export class PromptsGetWithImageScenario implements ClientScenario { name = 'prompts-get-with-image'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test prompt with image content. **Server Implementation Requirements:** @@ -404,13 +408,13 @@ Implement a prompt named \`test_prompt_with_image\` with no arguments that retur } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.getPrompt({ + const result = await conn.request('prompts/get', { name: 'test_prompt_with_image' }); @@ -448,7 +452,7 @@ Implement a prompt named \`test_prompt_with_image\` with no arguments that retur } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'prompts-get-with-image', diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index ec3c5fc..382b0db 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -2,16 +2,23 @@ * Resources test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer } from './client-helper'; import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { JsonRpcError, type RunContext } from '../../connection'; +import type { + ListResourcesResult, + ReadResourceResult, TextResourceContents, - BlobResourceContents -} from '@modelcontextprotocol/sdk/types.js'; + BlobResourceContents, + EmptyResult +} from '../../spec-types/2025-06-18'; export class ResourcesListScenario implements ClientScenario { name = 'resources-list'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test listing available resources. **Server Implementation Requirements:** @@ -26,13 +33,13 @@ export class ResourcesListScenario implements ClientScenario { - \`description\` (string) - \`mimeType\` (string, optional)`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.listResources(); + const result = await conn.request('resources/list'); // Validate response structure const errors: string[] = []; @@ -68,7 +75,7 @@ export class ResourcesListScenario implements ClientScenario { } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-list', @@ -92,7 +99,7 @@ export class ResourcesListScenario implements ClientScenario { export class ResourcesReadTextScenario implements ClientScenario { name = 'resources-read-text'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test reading text resource. **Server Implementation Requirements:** @@ -111,13 +118,13 @@ Implement resource \`test://static-text\` that returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.readResource({ + const result = await conn.request('resources/read', { uri: 'test://static-text' }); @@ -155,7 +162,7 @@ Implement resource \`test://static-text\` that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-read-text', @@ -179,7 +186,7 @@ Implement resource \`test://static-text\` that returns: export class ResourcesReadBinaryScenario implements ClientScenario { name = 'resources-read-binary'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test reading binary resource. **Server Implementation Requirements:** @@ -198,13 +205,13 @@ Implement resource \`test://static-binary\` that returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.readResource({ + const result = await conn.request('resources/read', { uri: 'test://static-binary' }); @@ -240,7 +247,7 @@ Implement resource \`test://static-binary\` that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-read-binary', @@ -264,7 +271,7 @@ Implement resource \`test://static-binary\` that returns: export class ResourcesTemplateReadScenario implements ClientScenario { name = 'resources-templates-read'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test reading resource from template. **Server Implementation Requirements:** @@ -287,13 +294,13 @@ Returns (for \`uri: "test://template/123/data"\`): } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.readResource({ + const result = await conn.request('resources/read', { uri: 'test://template/123/data' }); @@ -342,7 +349,7 @@ Returns (for \`uri: "test://template/123/data"\`): } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-templates-read', @@ -366,7 +373,10 @@ Returns (for \`uri: "test://template/123/data"\`): export class ResourcesSubscribeScenario implements ClientScenario { name = 'resources-subscribe'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test subscribing to resource updates. **Server Implementation Requirements:** @@ -389,13 +399,13 @@ Example request: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - await connection.client.subscribeResource({ + await conn.request('resources/subscribe', { uri: 'test://watched-resource' }); @@ -413,7 +423,7 @@ Example request: ] }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-subscribe', @@ -435,9 +445,168 @@ Example request: } } +export class ResourcesNotFoundErrorScenario implements ClientScenario { + name = 'sep-2164-resource-not-found'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test error handling for non-existent resources (SEP-2164). + +**Server Implementation Requirements:** + +**Endpoint**: \`resources/read\` + +When a client requests a URI that does not correspond to any resource, the server: + +- **MUST NOT** return a result with an empty \`contents\` array +- **SHOULD** return a JSON-RPC error with code \`-32602\` (Invalid Params) +- **SHOULD** include the requested \`uri\` in the error \`data\` field + +Example error response: + +\`\`\`json +{ + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32602, + "message": "Resource not found", + "data": { + "uri": "test://nonexistent-resource-for-conformance-testing" + } + } +} +\`\`\` + +This scenario does not require the server to register any specific resource — it tests behavior when reading a URI the server does not recognize.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + const nonexistentUri = + 'test://nonexistent-resource-for-conformance-testing'; + const specReferences = [ + { + id: 'SEP-2164', + url: 'https://modelcontextprotocol.io/specification/draft/server/resources#error-handling' + } + ]; + + // SEP-2164 is a draft-spec requirement; under --spec-version draft the + // RunContext-provided connect() resolves to the stateless impl, so the + // request goes out with the draft protocol version and _meta/headers. + let conn; + try { + conn = await ctx.connect(); + } catch (error) { + checks.push({ + id: 'sep-2164-error-code', + name: 'ResourcesNotFoundErrorCode', + description: + 'Server returns -32602 (Invalid Params) for non-existent resource', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences + }); + return checks; + } + + let caughtError: unknown; + let result: { contents: unknown[] } | undefined; + try { + result = await conn.request('resources/read', { + uri: nonexistentUri + }); + } catch (error) { + caughtError = error; + } + + // Check 1: MUST NOT return an empty contents array + const returnedEmptyContents = + result !== undefined && (result.contents?.length ?? 0) === 0; + checks.push({ + id: 'sep-2164-no-empty-contents', + name: 'ResourcesNotFoundNoEmptyContents', + description: + 'Server does not return an empty contents array for a non-existent resource', + status: returnedEmptyContents ? 'FAILURE' : 'SUCCESS', + timestamp: new Date().toISOString(), + errorMessage: returnedEmptyContents + ? 'Server returned a result with an empty contents array. Servers MUST NOT return an empty contents array for non-existent resources.' + : undefined, + specReferences, + details: { + requestedUri: nonexistentUri, + receivedResult: result !== undefined, + contentsLength: result?.contents?.length + } + }); + + // Check 2: SHOULD return JSON-RPC error with code -32602 + const rpcError = + caughtError instanceof JsonRpcError ? caughtError : undefined; + const errorCode = rpcError?.code; + let errorCodeMessage: string | undefined; + if (result !== undefined) { + errorCodeMessage = `Server returned a result instead of an error (contents length: ${result.contents?.length ?? 'undefined'}). Servers SHOULD return a JSON-RPC error for non-existent resources.`; + } else if (rpcError === undefined) { + errorCodeMessage = `Expected a JSON-RPC error, got: ${caughtError instanceof Error ? caughtError.message : String(caughtError)}`; + } else if (errorCode !== -32602) { + errorCodeMessage = + `Expected error code -32602 (Invalid Params), got ${errorCode}. ` + + (errorCode === -32002 + ? 'Code -32002 was used in earlier spec versions but SEP-2164 standardizes on -32602.' + : ''); + } + + checks.push({ + id: 'sep-2164-error-code', + name: 'ResourcesNotFoundErrorCode', + description: + 'Server returns -32602 (Invalid Params) for non-existent resource (SHOULD)', + status: errorCodeMessage === undefined ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: errorCodeMessage, + specReferences, + details: { + requestedUri: nonexistentUri, + receivedErrorCode: errorCode, + receivedResult: result !== undefined + } + }); + + // Check 3: SHOULD include uri in error data field + const errorData = rpcError?.data as { uri?: string } | undefined; + const dataUriMatches = errorData?.uri === nonexistentUri; + + checks.push({ + id: 'sep-2164-data-uri', + name: 'ResourcesNotFoundDataUri', + description: + 'Server includes the requested URI in the error data field (SHOULD)', + status: rpcError ? (dataUriMatches ? 'SUCCESS' : 'WARNING') : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: rpcError + ? dataUriMatches + ? undefined + : `Error data.uri is ${JSON.stringify(errorData?.uri)}, expected "${nonexistentUri}". This is a SHOULD requirement.` + : 'No JSON-RPC error received; cannot evaluate data field.', + specReferences, + details: { + requestedUri: nonexistentUri, + receivedDataUri: errorData?.uri + } + }); + + await conn.close(); + return checks; + } +} + export class ResourcesUnsubscribeScenario implements ClientScenario { name = 'resources-unsubscribe'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test unsubscribing from resource. **Server Implementation Requirements:** @@ -450,19 +619,19 @@ export class ResourcesUnsubscribeScenario implements ClientScenario { - Stop sending update notifications for that URI - Return empty object \`{}\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); // First subscribe - await connection.client.subscribeResource({ + await conn.request('resources/subscribe', { uri: 'test://watched-resource' }); // Then unsubscribe - await connection.client.unsubscribeResource({ + await conn.request('resources/unsubscribe', { uri: 'test://watched-resource' }); @@ -480,7 +649,7 @@ export class ResourcesUnsubscribeScenario implements ClientScenario { ] }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-unsubscribe', diff --git a/src/scenarios/server/session-lifecycle.test.ts b/src/scenarios/server/session-lifecycle.test.ts new file mode 100644 index 0000000..2f90da3 --- /dev/null +++ b/src/scenarios/server/session-lifecycle.test.ts @@ -0,0 +1,314 @@ +import { SessionLifecycleScenario } from './session-lifecycle'; +import { testContext } from '../../connection/testing'; + +describe('SessionLifecycleScenario', () => { + const serverUrl = 'http://localhost:3000/mcp'; + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('emits INFO and skips the lifecycle checks when the server is stateless', async () => { + fetchMock.mockResolvedValueOnce(new Response(null)); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'server-session-lifecycle-skipped', + status: 'INFO' + }); + }); + + it('reports FAILURE when initialize itself fails', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ + id: 'server-session-lifecycle-error', + status: 'FAILURE', + details: { statusCode: 500 } + }); + }); + + it('reports FAILURE when the initialized notification is rejected', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-abc' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 400 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(checks).toHaveLength(3); + expect(checks[0]).toMatchObject({ + id: 'server-session-initialized-accepted', + status: 'FAILURE', + details: { statusCode: 400 } + }); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SKIPPED' + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('reports SUCCESS for all checks on the happy path (DELETE 200, then POST 404)', async () => { + fetchMock + // initialize + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-abc' } + }) + ) + // notifications/initialized + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // DELETE + .mockResolvedValueOnce(new Response(null, { status: 200 })) + // POST after termination + .mockResolvedValueOnce(new Response(null, { status: 404 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + const deleteCall = fetchMock.mock.calls[2]; + expect(deleteCall?.[0]).toBe(serverUrl); + expect((deleteCall?.[1] as RequestInit).method).toBe('DELETE'); + expect((deleteCall?.[1] as RequestInit).headers).toMatchObject({ + 'mcp-session-id': 'session-abc' + }); + + const postAfterDelete = fetchMock.mock.calls[3]; + expect((postAfterDelete?.[1] as RequestInit).method).toBe('POST'); + expect((postAfterDelete?.[1] as RequestInit).headers).toMatchObject({ + 'mcp-session-id': 'session-abc' + }); + + expect(checks).toHaveLength(3); + expect(checks[0]).toMatchObject({ + id: 'server-session-initialized-accepted', + status: 'SUCCESS', + details: { statusCode: 202 } + }); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS', + details: { statusCode: 200 } + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SUCCESS', + details: { statusCode: 404 } + }); + }); + + it('sends the negotiated protocol version on follow-up requests', async () => { + fetchMock + // initialize: server negotiates down to 2025-03-26 + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2025-03-26', + capabilities: {}, + serverInfo: { name: 'test', version: '1.0.0' } + } + }), + { + headers: { + 'content-type': 'application/json', + 'mcp-session-id': 'session-old' + } + } + ) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl, '2025-06-18') + ); + + // initialize itself must not carry MCP-Protocol-Version. + const initHeaders = (fetchMock.mock.calls[0]?.[1] as RequestInit) + .headers as Record; + expect(initHeaders['MCP-Protocol-Version']).toBeUndefined(); + + // All follow-up requests carry the negotiated version, not a hardcoded one. + for (const callIndex of [1, 2, 3]) { + const headers = (fetchMock.mock.calls[callIndex]?.[1] as RequestInit) + .headers as Record; + expect(headers['MCP-Protocol-Version']).toBe('2025-03-26'); + } + + expect(checks).toHaveLength(3); + expect(checks.every((c) => c.status === 'SUCCESS')).toBe(true); + }); + + it('marks the lifecycle checks as SKIPPED when the server returns 405 on DELETE', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-no-delete' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 405 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + // Should NOT POST again after a 405 — the 404 check is meaningless then. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(checks).toHaveLength(3); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SKIPPED', + details: { statusCode: 405 } + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('treats 404 on DELETE as already terminated when the session stops responding', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-expired' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // DELETE says the session is already gone + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS', + details: { statusCode: 404 } + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SUCCESS' + }); + }); + + it('reports WARNING when DELETE returns 404 but the session still responds', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-no-delete-route' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + // DELETE 404s (no DELETE route)... + .mockResolvedValueOnce(new Response(null, { status: 404 })) + // ...but the session is still alive. + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(checks).toHaveLength(3); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'WARNING', + details: { statusCode: 404 } + }); + expect(checks[1].errorMessage).toContain('still responds'); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('reports WARNING (not FAILURE) on an unexpected DELETE status and skips the 404 check', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-odd-delete' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 500 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(checks).toHaveLength(3); + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'WARNING', + details: { statusCode: 500 } + }); + expect(checks[1].errorMessage).toContain('Expected 2xx, 404, or 405'); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'SKIPPED' + }); + }); + + it('reports FAILURE on the terminated-returns-404 check when the server returns 200 after DELETE', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(null, { + headers: { 'mcp-session-id': 'session-buggy' } + }) + ) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + const checks = await new SessionLifecycleScenario().run( + testContext(serverUrl) + ); + + expect(checks[1]).toMatchObject({ + id: 'server-session-delete-accepted', + status: 'SUCCESS' + }); + expect(checks[2]).toMatchObject({ + id: 'server-session-terminated-returns-404', + status: 'FAILURE', + details: { statusCode: 200 } + }); + }); +}); diff --git a/src/scenarios/server/session-lifecycle.ts b/src/scenarios/server/session-lifecycle.ts new file mode 100644 index 0000000..b5facbd --- /dev/null +++ b/src/scenarios/server/session-lifecycle.ts @@ -0,0 +1,415 @@ +/** + * Session lifecycle conformance test scenario for MCP servers. + * + * Verifies the two server-side guarantees the Streamable HTTP transport spec + * places on session termination: + * + * 1. The server accepts an HTTP DELETE that carries the issued session ID. + * 2. After such a DELETE, a subsequent request bearing the terminated + * session ID is rejected with HTTP 404 Not Found. + * + * See https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { readSseJsonRpcResponse, type RunContext } from '../../connection'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-Session-Management', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management' + } +]; + +const INITIALIZE_REQUEST_ID = 1; + +/** + * How long to wait for the initialize result body before giving up on + * extracting the negotiated protocol version. A server that answers the + * initialize POST with a long-lived SSE stream and never emits the response + * must not hang the run; on timeout the caller falls back to the requested + * spec version. + */ +const NEGOTIATED_VERSION_TIMEOUT_MS = 5_000; + +const TOOLS_LIST_BODY = { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {} +}; + +interface CheckDef { + id: string; + name: string; + description: string; +} + +const LIFECYCLE_ERROR: CheckDef = { + id: 'server-session-lifecycle-error', + name: 'SessionLifecycleError', + description: 'Session lifecycle test execution' +}; + +const INITIALIZED_ACCEPTED: CheckDef = { + id: 'server-session-initialized-accepted', + name: 'SessionInitializedAccepted', + description: + 'Server accepts the initialized notification on the issued session ID with a 2xx response' +}; + +const DELETE_ACCEPTED: CheckDef = { + id: 'server-session-delete-accepted', + name: 'SessionDeleteAccepted', + description: 'Server accepts HTTP DELETE on the issued session ID' +}; + +const TERMINATED_RETURNS_404: CheckDef = { + id: 'server-session-terminated-returns-404', + name: 'SessionTerminatedReturns404', + description: + 'Server returns HTTP 404 for requests bearing a terminated session ID' +}; + +function check( + def: CheckDef, + status: ConformanceCheck['status'], + extras: Pick, 'errorMessage' | 'details'> = {} +): ConformanceCheck { + return { + ...def, + status, + timestamp: new Date().toISOString(), + specReferences: SPEC_REFERENCES, + ...extras + }; +} + +/** + * Extract the protocol version the server negotiated in its initialize + * result. Returns undefined when the body cannot be parsed or does not + * arrive within the timeout; the caller falls back to the requested spec + * version. + */ +async function negotiatedProtocolVersion( + initResponse: Response +): Promise { + const parse = async (): Promise => { + const contentType = initResponse.headers.get('content-type') ?? ''; + let body: unknown; + if (contentType.includes('text/event-stream')) { + body = (await readSseJsonRpcResponse(initResponse, INITIALIZE_REQUEST_ID)) + .body; + } else { + body = await initResponse.json(); + } + const result = (body as { result?: { protocolVersion?: unknown } } | null) + ?.result; + return typeof result?.protocolVersion === 'string' + ? result.protocolVersion + : undefined; + }; + + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + parse(), + new Promise((resolve) => { + timer = setTimeout( + () => resolve(undefined), + NEGOTIATED_VERSION_TIMEOUT_MS + ); + }) + ]); + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +export class SessionLifecycleScenario implements ClientScenario { + name = 'server-session-lifecycle'; + // Sessions were removed entirely from the draft spec (SEP-2575), so the + // lifecycle checks only apply to the dated stateful versions. + readonly source = { + introducedIn: '2025-03-26', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; + description = `Verify the server honours the streamable-HTTP session +termination contract. + +**Server Implementation Requirements:** + +- Accept an HTTP DELETE to the MCP endpoint that carries the issued + \`Mcp-Session-Id\` header (or respond 405 if the server does not support + explicit termination). The spec does not pin a success status, so any + other response is reported as a warning rather than a failure. +- After such a DELETE, return HTTP 404 Not Found for subsequent requests + bearing the terminated session ID. + +Servers without session management (stateless) are reported as SKIPPED.`; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + + // Every fetch in this scenario shares one AbortController: cancel() on + // a body whose reader is still locked rejects, so aborting the fetch is + // the only reliable way to drop a never-ending SSE stream (initialize + // or any follow-up response) once the scenario finishes. + const abort = new AbortController(); + + try { + // initialize MUST NOT carry MCP-Protocol-Version (the version is being + // negotiated by the initialize handshake itself). + const initResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: INITIALIZE_REQUEST_ID, + method: 'initialize', + params: { + protocolVersion: specVersion, + capabilities: {}, + clientInfo: { + name: 'conformance-session-lifecycle-test', + version: '1.0.0' + } + } + }), + signal: abort.signal + }); + + if (!initResponse.ok) { + checks.push( + check(LIFECYCLE_ERROR, 'FAILURE', { + errorMessage: `initialize failed with HTTP ${initResponse.status}; cannot exercise session lifecycle`, + details: { statusCode: initResponse.status } + }) + ); + return checks; + } + + const sessionId = initResponse.headers.get('mcp-session-id'); + + if (!sessionId) { + checks.push( + check( + { + id: 'server-session-lifecycle-skipped', + name: 'SessionLifecycleSkipped', + description: + 'Server is stateless (no MCP-Session-Id) — lifecycle checks not applicable' + }, + 'INFO', + { + details: { + message: + 'Server did not return an MCP-Session-Id header; session lifecycle does not apply.' + } + } + ) + ); + return checks; + } + + // Follow-up requests carry the version the server actually negotiated + // (2025-06-18+ says the client SHOULD send the negotiated version, and + // servers MAY reject versions they don't support with 400), falling + // back to the requested spec version. Hardcoding a newer version here + // would false-fail servers that negotiated an older one. + const protocolVersion = + (await negotiatedProtocolVersion(initResponse)) ?? specVersion; + + // Complete the handshake so the server treats the session as live. + const initializedResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }), + signal: abort.signal + }); + + if (!initializedResponse.ok) { + // The session never became live, so the termination checks would be + // meaningless. + checks.push( + check(INITIALIZED_ACCEPTED, 'FAILURE', { + errorMessage: `Expected 2xx for notifications/initialized, got ${initializedResponse.status}`, + details: { statusCode: initializedResponse.status } + }), + skipped( + DELETE_ACCEPTED, + 'Skipped because the initialized notification was rejected; the session never became live.' + ), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the initialized notification was rejected; the session never became live.' + ) + ); + return checks; + } + + checks.push( + check(INITIALIZED_ACCEPTED, 'SUCCESS', { + details: { statusCode: initializedResponse.status } + }) + ); + + // Step 1: DELETE the session. + const deleteResponse = await fetch(serverUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'MCP-Protocol-Version': protocolVersion + }, + signal: abort.signal + }); + + const deleteAccepted = + deleteResponse.status >= 200 && deleteResponse.status < 300; + const deleteNotSupported = deleteResponse.status === 405; + // A 404 could mean the server already considers the session terminated + // (e.g. it expired between initialize and DELETE) — or that the server + // simply has no DELETE route. The follow-up request below tells the + // two apart. + const deleteReturned404 = deleteResponse.status === 404; + + if (deleteNotSupported) { + // If the server refused DELETE, the terminated-returns-404 check has + // nothing to assert against. + checks.push( + check(DELETE_ACCEPTED, 'SKIPPED', { + details: { + statusCode: 405, + message: + 'Server returned 405 Method Not Allowed; spec permits servers to refuse explicit DELETE.' + } + }), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the server does not support explicit session termination (405 on DELETE).' + ) + ); + return checks; + } + + if (!deleteAccepted && !deleteReturned404) { + // The spec only says the client SHOULD send DELETE and the server + // MAY respond 405; it never pins a success status, so an unexpected + // status is a warning rather than a failure. Without a confirmed + // termination the terminated-returns-404 check would be misleading. + const warningMessage = `Expected 2xx, 404, or 405 for DELETE, got ${deleteResponse.status}. The spec does not pin a success status for session termination, so this is reported as a warning.`; + checks.push( + check(DELETE_ACCEPTED, 'WARNING', { + errorMessage: warningMessage, + details: { + statusCode: deleteResponse.status, + message: warningMessage + } + }), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the preceding DELETE did not clearly terminate the session; cannot verify 404 behaviour.' + ) + ); + return checks; + } + + // Step 2: Re-send a request with the terminated session ID and expect 404. + const afterTerminationResponse = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + 'mcp-session-id': sessionId + }, + body: JSON.stringify(TOOLS_LIST_BODY), + signal: abort.signal + }); + const terminated404 = afterTerminationResponse.status === 404; + + if (deleteReturned404 && !terminated404) { + // The 404 on DELETE was not a termination: the session still + // answers. Most likely the server has no DELETE route (it should + // return 405 in that case). + const warningMessage = `DELETE returned 404 but the session still responds (HTTP ${afterTerminationResponse.status} on a follow-up request). A server without explicit termination support should return 405.`; + checks.push( + check(DELETE_ACCEPTED, 'WARNING', { + errorMessage: warningMessage, + details: { statusCode: 404, message: warningMessage } + }), + skipped( + TERMINATED_RETURNS_404, + 'Skipped because the preceding DELETE did not terminate the session; cannot verify 404 behaviour.' + ) + ); + return checks; + } + + checks.push( + check(DELETE_ACCEPTED, 'SUCCESS', { + details: deleteReturned404 + ? { + statusCode: 404, + message: + 'Server returned 404 for DELETE and the session no longer responds; treating it as already terminated.' + } + : { statusCode: deleteResponse.status } + }) + ); + + if (terminated404) { + checks.push( + check(TERMINATED_RETURNS_404, 'SUCCESS', { + details: { statusCode: 404 } + }) + ); + } else { + checks.push( + check(TERMINATED_RETURNS_404, 'FAILURE', { + errorMessage: `Expected 404 on terminated session ID, got ${afterTerminationResponse.status}`, + details: { statusCode: afterTerminationResponse.status } + }) + ); + } + } catch (error) { + checks.push( + check(LIFECYCLE_ERROR, 'FAILURE', { + errorMessage: `Failed to exercise session lifecycle: ${ + error instanceof Error ? error.message : String(error) + }` + }) + ); + } finally { + // Drop whatever is left of any response (e.g. a still-open SSE + // stream after the negotiation timeout, or an unread follow-up body) + // so it cannot keep the connection alive after the scenario ends. A + // no-op for bodies that were fully consumed. + abort.abort(); + } + + return checks; + } +} + +function skipped(def: CheckDef, message: string): ConformanceCheck { + return check(def, 'SKIPPED', { details: { message } }); +} diff --git a/src/scenarios/server/skills/directory.ts b/src/scenarios/server/skills/directory.ts new file mode 100644 index 0000000..91dae45 --- /dev/null +++ b/src/scenarios/server/skills/directory.ts @@ -0,0 +1,393 @@ +/** + * SEP-2640 Skills extension — the `resources/directory/read` surface (added in + * spec commit 2e04c48d, 2026-06-09). + * + * One scenario, six checks (per AGENTS.md "fewer scenarios, more checks"). + * Each check's verbatim spec quote lives next to its check ID in + * src/seps/sep-2640.yaml. + * + * Capability gating reads the declared capability from `server/discover` + * (mirrors `tasks/capability.ts`): the checks run only when the server declares + * `io.modelcontextprotocol/skills.directoryRead: true`. An undeclared optional + * capability is a SKIP (not a failure); a declared-but-broken one fails. + * + * Discovery is dynamic and brand-neutral: the directory to exercise is derived + * from `skills/list` or `resources/list`, hardcoding no fixture URI, so + * the scenario passes against any conformant SEP-2640 server. When no directory + * (or no subdirectory) can be discovered, that check reports the missing + * prerequisite via untestableCheck (issue #248), never a silent green. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import { Connection, JsonRpcError, type RunContext } from '../../../connection'; +import { untestableCheck } from '../../untestable'; +import { + SKILLS_EXTENSION_ID, + SKILL_MANIFEST_FILENAME, + SEP_2640_REF, + JSONRPC_METHOD_NOT_FOUND, + JSONRPC_INVALID_PARAMS, + type SkillResource, + skillsCapability, + directoryReadDeclared, + skillsCheck, + listAllResources, + skillsListAll, + directoryReadAll, + skillNameFromManifestUri +} from './helpers'; + +const DIRECTORY_MIME = 'inode/directory'; + +const CAPABILITY_ID = 'sep-2640-capability-directory-read-flag'; +const METHOD_ID = 'sep-2640-directory-read-method-registered'; +const SHAPE_ID = 'sep-2640-directory-read-result-resources-shape'; +const SUBDIR_ID = 'sep-2640-directory-read-subdir-mimetype'; +const INVALID_PARAMS_ID = 'sep-2640-directory-read-invalid-params'; +const PAGINATION_ID = 'sep-2640-directory-read-pagination'; + +const ALL_IDS = [ + CAPABILITY_ID, + METHOD_ID, + SHAPE_ID, + SUBDIR_ID, + INVALID_PARAMS_ID, + PAGINATION_ID +]; + +interface DirectoryReadResult { + resources?: SkillResource[]; + nextCursor?: string; +} + +/** A directory to exercise plus, when known, a non-directory resource under it. */ +interface DirectoryTarget { + dirUri: string; + /** A known file (non-directory) resource, used for the -32602 negative path. */ + fileUri?: string; +} + +/** The skill root directory URI for a SKILL.md URI (strip the trailing file). */ +function skillRootFromManifestUri(uri: string): string | undefined { + if (skillNameFromManifestUri(uri) === undefined) return undefined; + return uri.slice(0, uri.length - `/${SKILL_MANIFEST_FILENAME}`.length); +} + +/** + * Discover a directory resource to exercise, brand-neutrally: prefer a skill + * root derived from a skill-md SKILL.md (index first, then resources/list), + * then any `inode/directory` resource in resources/list. + */ +async function discoverDirectory( + conn: Connection +): Promise { + // 1. A skills/list entry — its SKILL.md URI gives us both a directory (the + // skill root) and a known file (the SKILL.md itself). An unenumerable + // catalog returns nothing here, so discovery falls through. + const listed = await skillsListAll(conn); + if (!('error' in listed)) { + const entry = listed.entries.find( + (e) => + typeof e.uri === 'string' && + skillRootFromManifestUri(e.uri) !== undefined + ); + const uri = entry?.uri as string | undefined; + if (uri) { + return { dirUri: skillRootFromManifestUri(uri)!, fileUri: uri }; + } + } + + const resources = await listAllResources(conn); + + // 2. A SKILL.md in resources/list — derive the skill root the same way. + const manifest = resources.find( + (r) => skillRootFromManifestUri(r.uri) !== undefined + ); + if (manifest) { + return { + dirUri: skillRootFromManifestUri(manifest.uri)!, + fileUri: manifest.uri + }; + } + + // 3. Any directory resource, using a non-directory sibling for the -32602 + // path when one is listed. + const dir = resources.find((r) => r.mimeType === DIRECTORY_MIME); + if (dir) { + const file = resources.find((r) => r.mimeType !== DIRECTORY_MIME); + return { dirUri: dir.uri, fileUri: file?.uri }; + } + + return undefined; +} + +export class SkillsDirectoryReadScenario implements ClientScenario { + name = 'sep-2640-skills-directory'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + description = `SEP-2640 Skills extension: resources/directory/read surface (added in spec commit 2e04c48d, 2026-06-09). + +**Endpoint**: \`resources/directory/read\` (gated by \`io.modelcontextprotocol/skills.directoryRead: true\`) + +**Requirements covered** (each check carries a verbatim spec excerpt in src/seps/sep-2640.yaml): + +- \`sep-2640-capability-directory-read-flag\` — server declared directoryRead (read from server/discover) +- \`sep-2640-directory-read-method-registered\` — a declaring server supports the method on a served directory (MUST) +- \`sep-2640-directory-read-result-resources-shape\` — result has resources[] of direct children (MUST) +- \`sep-2640-directory-read-subdir-mimetype\` — subdirectory children carry \`inode/directory\` (MUST) +- \`sep-2640-directory-read-invalid-params\` — a non-directory URI returns \`-32602\` (MUST) +- \`sep-2640-directory-read-pagination\` — \`nextCursor\` round-trips per resources/list (single-page is conformant) + +**Gating & discovery**: the checks SKIP when the skills extension or its \`directoryRead\` flag is undeclared. The directory to exercise is discovered dynamically from \`skills/list\` / \`resources/list\` — no fixture URI is hardcoded.`; + + async run(ctx: RunContext): Promise { + const conn = await ctx.connect(); + try { + // === Capability gating via server/discover (not error-inference) === + const skills = await skillsCapability(conn); + if (!skills) { + const reason = + 'Server did not declare the io.modelcontextprotocol/skills extension; directoryRead checks not applicable.'; + return ALL_IDS.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + if (!directoryReadDeclared(skills)) { + const reason = + 'Server declared the skills extension but not directoryRead: true; the resources/directory/read checks are optional and not applicable.'; + return ALL_IDS.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + + const checks: ConformanceCheck[] = []; + + // Check 1: capability declared (observed directly from server/discover). + checks.push( + skillsCheck( + CAPABILITY_ID, + 'Server declared io.modelcontextprotocol/skills.directoryRead: true under capabilities.extensions.', + 'SUCCESS', + { details: { directoryRead: true } } + ) + ); + + // === Discover a directory to exercise (brand-neutral) === + const target = await discoverDirectory(conn); + if (!target) { + const reason = + 'no directory resource discoverable via skills/list or resources/list to exercise resources/directory/read'; + const rest: Array<[string, string]> = [ + [ + METHOD_ID, + 'A declaring server MUST support the method on a served directory.' + ], + [ + SHAPE_ID, + 'Result carries resources[] of the directory’s direct children.' + ], + [SUBDIR_ID, 'Subdirectory children carry mimeType inode/directory.'], + [ + INVALID_PARAMS_ID, + 'A non-directory URI yields -32602 Invalid params.' + ], + [ + PAGINATION_ID, + 'nextCursor round-trips per the resources/list contract.' + ] + ]; + for (const [id, desc] of rest) { + checks.push( + untestableCheck(id, id, desc, reason, [SEP_2640_REF], 'FAILURE') + ); + } + return checks; + } + + // === Happy path: list the discovered directory === + let happy: DirectoryReadResult | undefined; + let happyErr: unknown; + try { + happy = await conn.request( + 'resources/directory/read', + { uri: target.dirUri } + ); + // Every check below inspects the directory's children, so they need + // the whole directory rather than whichever slice fits one page. + const all = await directoryReadAll(conn, target.dirUri); + happy = { ...happy, resources: all.resources }; + } catch (e) { + happyErr = e; + } + + // Check 2: method registered (declared -> MUST be supported). + const methodNotFound = + happyErr instanceof JsonRpcError && + happyErr.code === JSONRPC_METHOD_NOT_FOUND; + checks.push( + skillsCheck( + METHOD_ID, + 'A server that declares directoryRead MUST support resources/directory/read on a served skill directory.', + happy !== undefined ? 'SUCCESS' : 'FAILURE', + happy !== undefined + ? { details: { uri: target.dirUri } } + : { + errorMessage: methodNotFound + ? `resources/directory/read returned -32601 for ${target.dirUri} despite the server declaring directoryRead: true` + : `resources/directory/read on ${target.dirUri} failed: ${ + happyErr instanceof Error + ? happyErr.message + : String(happyErr) + }` + } + ) + ); + + // Check 3: result shape — resources[] of Resource objects. + const shapeErrs: string[] = []; + if (!Array.isArray(happy?.resources)) { + shapeErrs.push('result.resources is not an array'); + } else { + happy.resources.forEach((r, i) => { + if (typeof r.uri !== 'string') { + shapeErrs.push(`resources[${i}].uri is not a string`); + } + }); + } + checks.push( + skillsCheck( + SHAPE_ID, + 'The result contains resources[] listing the directory’s direct children, each with at least a uri.', + happy === undefined + ? 'FAILURE' + : shapeErrs.length === 0 + ? 'SUCCESS' + : 'FAILURE', + happy === undefined + ? { errorMessage: 'directory read did not return a result' } + : shapeErrs.length === 0 + ? { details: { childCount: happy.resources?.length ?? 0 } } + : { errorMessage: shapeErrs.join('; ') } + ) + ); + + // Check 4: subdirectory mime marker. A directory whose fixture exposes no + // child subdirectory cannot exercise this — report it untestable, not a + // pass and not a failure of the server. + const subdirChild = Array.isArray(happy?.resources) + ? happy.resources.find((r) => r.mimeType === DIRECTORY_MIME) + : undefined; + if (subdirChild) { + checks.push( + skillsCheck( + SUBDIR_ID, + 'A subdirectory child is listed as a directory resource (mimeType inode/directory) so clients can descend.', + 'SUCCESS', + { details: { subdirectoryUri: subdirChild.uri } } + ) + ); + } else { + checks.push( + untestableCheck( + SUBDIR_ID, + SUBDIR_ID, + 'A subdirectory child is listed with mimeType inode/directory.', + `no child with mimeType ${DIRECTORY_MIME} under ${target.dirUri}; the served directory exposes no subdirectory to exercise this check`, + [SEP_2640_REF], + 'FAILURE' + ) + ); + } + + // Check 5: non-directory URI -> -32602. Needs a known non-directory + // resource; prefer the discovered fileUri, else a non-directory child. + const nonDirUri = + target.fileUri ?? + (Array.isArray(happy?.resources) + ? happy.resources.find( + (r) => typeof r.uri === 'string' && r.mimeType !== DIRECTORY_MIME + )?.uri + : undefined); + if (nonDirUri === undefined) { + checks.push( + untestableCheck( + INVALID_PARAMS_ID, + INVALID_PARAMS_ID, + 'A non-directory URI yields -32602 Invalid params.', + 'no non-directory resource discoverable to probe the -32602 path', + [SEP_2640_REF], + 'FAILURE' + ) + ); + } else { + let invalidOk = false; + let invalidDetail = ''; + try { + await conn.request('resources/directory/read', { + uri: nonDirUri + }); + invalidDetail = `expected -32602 for non-directory URI ${nonDirUri}, got a successful result`; + } catch (e) { + if (e instanceof JsonRpcError && e.code === JSONRPC_INVALID_PARAMS) { + invalidOk = true; + } else if (e instanceof JsonRpcError) { + invalidDetail = `expected -32602 for ${nonDirUri}, got ${e.code}: ${e.message}`; + } else { + invalidDetail = `expected -32602, got non-JsonRpcError: ${ + e instanceof Error ? e.message : String(e) + }`; + } + } + checks.push( + skillsCheck( + INVALID_PARAMS_ID, + 'resources/directory/read on a non-directory URI MUST return -32602 (Invalid params).', + invalidOk ? 'SUCCESS' : 'FAILURE', + invalidOk + ? { details: { nonDirectoryUri: nonDirUri } } + : { errorMessage: invalidDetail } + ) + ); + } + + // Check 6: pagination contract (single-page is conformant). + let paginationOk = false; + let paginationDetail = ''; + const firstCursor = happy?.nextCursor; + if (happy === undefined) { + paginationDetail = 'no directory result to evaluate pagination'; + } else if (!firstCursor) { + paginationOk = true; + paginationDetail = 'single-page response (no nextCursor)'; + } else { + try { + const second = await conn.request( + 'resources/directory/read', + { uri: target.dirUri, cursor: firstCursor } + ); + paginationOk = Array.isArray(second.resources); + paginationDetail = paginationOk + ? `nextCursor round-tripped: ${firstCursor}` + : 'follow-up call returned non-array resources'; + } catch (e) { + paginationDetail = `follow-up call with cursor failed: ${ + e instanceof Error ? e.message : String(e) + }`; + } + } + checks.push( + skillsCheck( + PAGINATION_ID, + 'nextCursor round-trips per the resources/list contract (single-page responses are conformant).', + paginationOk ? 'SUCCESS' : 'FAILURE', + paginationOk + ? { details: { paginationDetail } } + : { errorMessage: paginationDetail } + ) + ); + + return checks; + } finally { + await conn.close(); + } + } +} diff --git a/src/scenarios/server/skills/enumeration.ts b/src/scenarios/server/skills/enumeration.ts new file mode 100644 index 0000000..68b3eeb --- /dev/null +++ b/src/scenarios/server/skills/enumeration.ts @@ -0,0 +1,1215 @@ +/** + * SEP-2640 Skills extension — the `skills/list` and `skills/get` surface. + * + * Replaces the former `skill://index.json` scenario. The 2026-08-21 revision of + * the SEP removed that well-known resource entirely (it appears nowhere in the + * current text) and replaced it with two methods that every server declaring the + * extension MUST implement. + * + * One scenario, many checks (per AGENTS.md "fewer scenarios, more checks"). + * Each check's verbatim spec quote lives next to its check ID in + * src/seps/sep-2640.yaml, keeping the YAML and this scenario in lock-step. + * + * All discovery is dynamic and brand-neutral: the scenario enumerates whatever + * the server serves and validates the entries it finds, hardcoding no + * fixture-specific skill name or URI. When the server does not declare the + * skills extension the checks are SKIPPED (an optional, undeclared capability). + * An empty listing is explicitly permitted, so entry-level checks SKIP rather + * than fail against a server with an unenumerable catalog. + */ + +import { isDeepStrictEqual } from 'util'; +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { RunContext } from '../../../connection'; +import { + SKILLS_EXTENSION_ID, + SKILLS_LIST_METHOD, + SKILLS_GET_METHOD, + SKILL_URI_SCHEME, + SKILL_MANIFEST_FILENAME, + SKILL_DIGEST_PATTERN, + RESOURCES_DYNAMIC, + MAX_RESOURCES_PER_SKILL, + MAX_TOTAL_SIZE_PER_SKILL, + FRONTMATTER_RESERVED_PREFIX, + JSONRPC_INVALID_PARAMS, + type SkillEntry, + type SkillResourceEntry, + declaredSkillsCapability, + describeValue, + resourcesCapabilityDeclared, + isSettingsObject, + skillsCheck, + skillsListAll, + skillsGet, + settingsAreInline, + skillNameFromManifestUri, + skillRootFromManifestUri, + isDynamicResources, + resourcesArray, + entryLabel, + readResourceText, + parseFrontmatter +} from './helpers'; + +const CAPABILITY_IDS = [ + 'sep-2640-capability-declaration-inline', + 'sep-2640-capability-requires-resources', + 'sep-2640-capability-commits-to-methods', + 'sep-2640-capability-empty-object' +] as const; + +const LIST_IDS = [ + 'sep-2640-skills-list-implemented', + 'sep-2640-skills-list-pagination', + 'sep-2640-skills-list-entry-atomic', + 'sep-2640-skills-list-cache-attributes' +] as const; + +const ENTRY_IDS = [ + 'sep-2640-entry-uri-required', + 'sep-2640-entry-frontmatter-required', + 'sep-2640-entry-uri-matches-frontmatter-name', + 'sep-2640-skill-uri-scheme', + 'sep-2640-entry-resources-required', + 'sep-2640-resources-complete', + 'sep-2640-resources-uri-within-skill', + 'sep-2640-resources-digest-format', + 'sep-2640-resources-size-required', + 'sep-2640-limit-resources-per-skill', + 'sep-2640-limit-total-size', + 'sep-2640-metadata-reserved-prefix', + 'sep-2640-name-naming-rules', + 'sep-2640-authority-reg-name', + 'sep-2640-names-should-be-unique' +] as const; + +const GET_IDS = [ + 'sep-2640-skills-get-implemented', + 'sep-2640-skills-get-entry-shape', + 'sep-2640-skills-get-cache-attributes', + 'sep-2640-skills-get-no-cursor', + 'sep-2640-skills-get-unknown-uri-invalid-params' +] as const; + +/** Emitted by the read-back pass, which fetches one listed SKILL.md. */ +const READBACK_IDS = [ + 'sep-2640-skillmd-required', + 'sep-2640-skillmd-frontmatter', + 'sep-2640-entry-frontmatter-identical' +] as const; + +const ALL_CHECK_IDS = [ + ...CAPABILITY_IDS, + ...LIST_IDS, + ...ENTRY_IDS, + ...GET_IDS, + ...READBACK_IDS +]; + +/** + * Agent Skills naming rules as the SEP defers to them: 1-64 characters, + * lowercase alphanumeric and hyphens, with no leading, trailing or + * consecutive hyphens. The leading/trailing rule falls out of the anchored + * alphanumeric bookends; the consecutive one needs the `(?!.*--)` lookahead. + */ +const SKILL_NAME_PATTERN = /^(?!.*--)[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +/** RFC 3986 reg-name: unreserved / pct-encoded / sub-delims, case-insensitive. */ +const REG_NAME_PATTERN = /^(?:[A-Za-z0-9\-._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})*$/; + +/** A URI that no conformant server should serve, for the -32602 probe. */ +const UNKNOWN_SKILL_URI = + 'skill://mcp-conformance-nonexistent-skill-9f3a2b/SKILL.md'; + +function joinErrs(errs: string[], limit = 5): string { + const shown = errs.slice(0, limit).join('; '); + return errs.length > limit + ? `${shown} (+${errs.length - limit} more)` + : shown; +} + +export class SkillsEnumerationScenario implements ClientScenario { + name = 'sep-2640-skills-enumeration'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + description = `SEP-2640 Skills extension: \`skills/list\` enumeration and \`skills/get\` retrieval. + +**Methods**: \`skills/list\`, \`skills/get\` (both mandatory for a server declaring \`io.modelcontextprotocol/skills\`) + +**Requirements covered** (each check carries a verbatim spec excerpt in src/seps/sep-2640.yaml): + +- \`sep-2640-capability-declaration-inline\` — extension settings sit inline under the identifier, per SEP-2133 (no \`config\` envelope) +- \`sep-2640-capability-commits-to-methods\` — declaring the extension commits the server to both methods +- \`sep-2640-skills-list-implemented\` — \`skills/list\` is implemented and returns a \`skills\` array +- \`sep-2640-skills-list-pagination\` — \`nextCursor\` is honoured as a cursor on the next request +- \`sep-2640-skills-list-entry-atomic\` — no skill entry is split across pages +- \`sep-2640-entry-uri-required\` / \`sep-2640-entry-frontmatter-required\` / \`sep-2640-entry-resources-required\` — the three required entry fields +- \`sep-2640-entry-uri-matches-frontmatter-name\` — the final skill-path segment equals \`frontmatter.name\` +- \`sep-2640-resources-complete\` — \`resources\` includes an entry matching the skill's own \`uri\`, each file once +- \`sep-2640-resources-uri-within-skill\` / \`sep-2640-resources-digest-format\` / \`sep-2640-resources-size-required\` — the \`{uri, digest, size}\` triple +- \`sep-2640-limit-resources-per-skill\` / \`sep-2640-limit-total-size\` — 512 entries, 16 MiB +- \`sep-2640-metadata-reserved-prefix\` — frontmatter \`metadata\` keys under \`io.modelcontextprotocol/\` are reserved +- \`sep-2640-skills-get-*\` — \`skills/get\` returns a list-shaped entry, carries no cursor, and answers \`-32602\` for an unknown URI + +**Discovery is dynamic**: an undeclared extension SKIPs everything; an empty or partial listing is permitted and SKIPs the entry-level checks.`; + + async run(ctx: RunContext): Promise { + const conn = await ctx.connect(); + try { + const declaration = await declaredSkillsCapability(conn); + if (!declaration.declared) { + const reason = + 'Server did not declare the io.modelcontextprotocol/skills extension; enumeration checks not applicable.'; + return ALL_CHECK_IDS.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + + // Declared, but possibly with something that is not a settings object. + // The remaining checks read no settings from a malformed declaration, so + // they proceed against an empty one rather than aborting: `skills/list` + // and `skills/get` are still owed by anything that declared at all. + const declaredValue = declaration.value; + const wellFormed = isSettingsObject(declaredValue); + const skills: Record = wellFormed ? declaredValue : {}; + + const checks: ConformanceCheck[] = []; + + // === capability-empty-object === + // "An empty object indicates support for the extension with no optional + // features." The observable half is the type: the declared value MUST be + // an object, and an empty one is a valid declaration rather than a + // malformed capability. A server declaring `true` fails here instead of + // silently skipping the suite. + checks.push( + skillsCheck( + 'sep-2640-capability-empty-object', + 'An empty object indicates support for the extension with no optional features.', + wellFormed ? 'SUCCESS' : 'FAILURE', + wellFormed + ? { + details: { + declaredKeys: Object.keys(skills), + empty: Object.keys(skills).length === 0 + } + } + : { + errorMessage: `capabilities.extensions["${SKILLS_EXTENSION_ID}"] is ${describeValue(declaredValue)}, not a settings object. An empty object is the way to declare support with no optional features.`, + details: { observed: declaredValue } + } + ) + ); + + // === capability-declaration-inline === + // SEP-2133 (Final) maps an extension identifier straight to its settings + // object. An envelope hides settings from any spec-following client. + if (wellFormed) { + const { inline, envelopeKeys } = settingsAreInline(skills); + checks.push( + skillsCheck( + 'sep-2640-capability-declaration-inline', + 'Extension settings are a map of extension identifiers to per-extension settings objects; the settings sit directly under the identifier.', + inline ? 'SUCCESS' : 'FAILURE', + inline + ? { details: { settingKeys: Object.keys(skills) } } + : { + errorMessage: `capabilities.extensions["${SKILLS_EXTENSION_ID}"] carries envelope key(s) ${envelopeKeys.join(', ')} instead of the settings object itself. SEP-2133 (Final) defines no envelope, and SEP-2640's capability block places directoryRead inline.`, + details: { envelopeKeys, observed: skills } + } + ) + ); + } else { + const reason = + 'the declared value is not a settings object, so there are no settings to place inline or in an envelope; see sep-2640-capability-empty-object'; + checks.push( + skillsCheck( + 'sep-2640-capability-declaration-inline', + 'Extension settings are a map of extension identifiers to per-extension settings objects; the settings sit directly under the identifier.', + 'SKIPPED', + { errorMessage: reason } + ) + ); + } + + // === capability-requires-resources === + // Stated on the stable page as a consequence of the base Resources + // spec rather than a new obligation: a server serving skill files + // through resources/read already has to declare `resources`. Checkable + // here because the declaration is on the wire either way. + const hasResources = await resourcesCapabilityDeclared(conn); + checks.push( + skillsCheck( + 'sep-2640-capability-requires-resources', + 'A server declaring this extension MUST also declare the resources capability, because skill files are served through resources/read.', + hasResources ? 'SUCCESS' : 'FAILURE', + hasResources + ? { details: { resources: true } } + : { + errorMessage: + 'the server declares the skills extension but not the base `resources` capability, so a spec-following client has no basis to call resources/read for skill files.' + } + ) + ); + + // === skills/list === + const listed = await skillsListAll(conn); + if ('error' in listed) { + const reason = `${SKILLS_LIST_METHOD} failed with code ${listed.error.code}: ${listed.error.message}. A server declaring the extension MUST implement it.`; + checks.push( + skillsCheck( + 'sep-2640-capability-commits-to-methods', + 'Declaring the extension commits the server to skills/list and skills/get.', + 'FAILURE', + { errorMessage: reason } + ), + skillsCheck( + 'sep-2640-skills-list-implemented', + 'A server declaring the extension MUST implement the skills/list method.', + 'FAILURE', + { errorMessage: reason } + ) + ); + for (const id of [ + 'sep-2640-skills-list-pagination', + 'sep-2640-skills-list-entry-atomic', + ...ENTRY_IDS, + ...GET_IDS, + ...READBACK_IDS + ]) { + checks.push( + skillsCheck(id, 'skills/list is unavailable.', 'SKIPPED', { + errorMessage: reason + }) + ); + } + return checks; + } + + const { entries, pages, truncated } = listed; + + checks.push( + skillsCheck( + 'sep-2640-skills-list-implemented', + 'A server declaring the extension MUST implement the skills/list method, which returns the skills it serves. The result MAY be empty.', + 'SUCCESS', + { + details: { + pages: pages.length, + entries: entries.length, + emptyListingPermitted: entries.length === 0 + } + } + ) + ); + + // === skills-list-pagination === + // Multi-page runs prove the cursor round-trips. A single page is a clean + // pass: the contract is "when nextCursor is present, pass it back", and + // skillsListAll did exactly that to reach the end. + checks.push( + skillsCheck( + 'sep-2640-skills-list-pagination', + 'Pagination mirrors the base protocol: the request accepts an optional cursor, and when the result includes nextCursor the client passes it back.', + truncated ? 'FAILURE' : 'SUCCESS', + truncated + ? { + errorMessage: `skills/list did not terminate: the server kept returning a nextCursor (or repeated one) across ${pages.length} pages.` + } + : { details: { pages: pages.length } } + ) + ); + + // === skills-list-entry-atomic === + // "An entry is atomic — a skill's resources set is never split across + // pages." Observable as a URI appearing in more than one page. + const uriPages = new Map(); + pages.forEach((page, pageIdx) => { + for (const e of page.entries) { + if (typeof e.uri !== 'string') continue; + const seen = uriPages.get(e.uri) ?? []; + if (!seen.includes(pageIdx)) seen.push(pageIdx); + uriPages.set(e.uri, seen); + } + }); + const split = [...uriPages.entries()] + .filter(([, p]) => p.length > 1) + .map(([uri, p]) => `${uri} appears on pages ${p.join(', ')}`); + checks.push( + skillsCheck( + 'sep-2640-skills-list-entry-atomic', + "An entry is atomic — a skill's resources set is never split across pages.", + split.length === 0 ? 'SUCCESS' : 'FAILURE', + split.length === 0 + ? { details: { pages: pages.length, distinctUris: uriPages.size } } + : { errorMessage: joinErrs(split) } + ) + ); + + // === skills-list-cache-attributes === + // SEP-2549 attributes exist only from 2026-07-28. Below that they are + // not merely optional, they are undefined by the negotiated schema, so + // a server that omits them is correct and a WARNING is a false + // positive. From 2026-07-28 they are required, so a missing one is a + // FAILURE rather than something to shrug at. Reported by Sam Bloomberg + // against the Go SDK, which was correct in both directions while this + // check graded it wrong in both. + const first = pages[0]?.result ?? {}; + const hasTtl = first.ttlMs !== undefined; + const hasScope = first.cacheScope !== undefined; + const cacheAttrsApply = ctx.specVersion >= '2026-07-28'; + + if (!cacheAttrsApply) { + checks.push( + skillsCheck( + 'sep-2640-skills-list-cache-attributes', + "In protocol versions 2026-07-28 and later, the skills/list result carries the base protocol's list-caching attributes ttlMs and cacheScope (SEP-2549).", + 'SKIPPED', + { + errorMessage: `not applicable on negotiated protocol ${ctx.specVersion}: ttlMs and cacheScope are defined from 2026-07-28`, + details: { + specVersion: ctx.specVersion, + ttlMs: first.ttlMs, + cacheScope: first.cacheScope + } + } + ) + ); + } else { + const missing = [!hasTtl && 'ttlMs', !hasScope && 'cacheScope'] + .filter(Boolean) + .join(' and '); + checks.push( + skillsCheck( + 'sep-2640-skills-list-cache-attributes', + "In protocol versions 2026-07-28 and later, the skills/list result carries the base protocol's list-caching attributes ttlMs and cacheScope (SEP-2549).", + hasTtl && hasScope ? 'SUCCESS' : 'FAILURE', + hasTtl && hasScope + ? { + details: { ttlMs: first.ttlMs, cacheScope: first.cacheScope } + } + : { + errorMessage: `skills/list result omits ${missing} on protocol ${ctx.specVersion}, where SEP-2549 requires both.` + } + ) + ); + } + + if (entries.length === 0) { + const reason = + 'skills/list returned no entries; entry-level checks not applicable. A server whose catalog is large, generated on demand, or otherwise unenumerable MAY return an empty listing.'; + for (const id of [...ENTRY_IDS, ...GET_IDS, ...READBACK_IDS]) { + checks.push( + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + checks.push( + skillsCheck( + 'sep-2640-capability-commits-to-methods', + 'Declaring the extension commits the server to skills/list and skills/get.', + 'SUCCESS', + { details: { note: 'skills/list answered; listing is empty.' } } + ) + ); + return checks; + } + + checks.push(...entryChecks(entries)); + checks.push(...(await getChecks(conn, entries, ctx.specVersion))); + checks.push(...(await readbackChecks(conn, entries))); + + return checks; + } finally { + await conn.close(); + } + } +} + +/** Validate every `skills[]` entry against the §Discovery entry schema. */ +function entryChecks(entries: SkillEntry[]): ConformanceCheck[] { + const checks: ConformanceCheck[] = []; + + // === entry-uri-required === + const uriErrs = entries + .map((e, i) => + typeof e.uri === 'string' && e.uri.length > 0 + ? null + : `skills[${i}].uri is missing or not a string` + ) + .filter((x): x is string => x !== null); + checks.push( + skillsCheck( + 'sep-2640-entry-uri-required', + "Every entry carries uri, the full resource URI of the skill's SKILL.md.", + uriErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + uriErrs.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: joinErrs(uriErrs) } + ) + ); + + // === entry-frontmatter-required === + // Verbatim frontmatter, so name and description are always present. + const fmErrs: string[] = []; + entries.forEach((e, i) => { + const fm = e.frontmatter; + if (!fm || typeof fm !== 'object' || Array.isArray(fm)) { + fmErrs.push( + `${entryLabel(e, i)}: frontmatter is missing or not an object` + ); + return; + } + const obj = fm as Record; + if (typeof obj.name !== 'string' || obj.name.length === 0) { + fmErrs.push(`${entryLabel(e, i)}: frontmatter.name is missing or empty`); + } + if (typeof obj.description !== 'string' || obj.description.length === 0) { + fmErrs.push( + `${entryLabel(e, i)}: frontmatter.description is missing or empty` + ); + } + }); + checks.push( + skillsCheck( + 'sep-2640-entry-frontmatter-required', + "frontmatter is the skill's SKILL.md YAML frontmatter rendered verbatim as a JSON object; because the Agent Skills specification requires name and description, those fields are always present.", + fmErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + fmErrs.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: joinErrs(fmErrs) } + ) + ); + + // === entry-uri-matches-frontmatter-name === + const nameErrs: string[] = []; + entries.forEach((e) => { + if (typeof e.uri !== 'string') return; + const fm = e.frontmatter as Record | undefined; + const declared = fm && typeof fm.name === 'string' ? fm.name : undefined; + if (declared === undefined) return; + const fromUri = skillNameFromManifestUri(e.uri); + if (fromUri === undefined) { + nameErrs.push( + `${e.uri}: does not end in /${SKILL_MANIFEST_FILENAME}, so the skill name is not recoverable from the URI` + ); + } else if (fromUri !== declared) { + nameErrs.push( + `${e.uri}: final skill-path segment "${fromUri}" !== frontmatter.name "${declared}"` + ); + } + }); + checks.push( + skillsCheck( + 'sep-2640-entry-uri-matches-frontmatter-name', + "The final segment of the entry's uri MUST equal frontmatter.name.", + nameErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + nameErrs.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: joinErrs(nameErrs) } + ) + ); + + // === skill-uri-scheme (SHOULD) === + // "Servers SHOULD use the skill:// URI scheme", but a server MAY serve + // skills under another scheme native to its domain and no scheme is + // privileged, so a deviation is a WARNING rather than a failure. + const otherScheme = entries + .filter( + (e) => typeof e.uri === 'string' && !e.uri.startsWith(SKILL_URI_SCHEME) + ) + .map((e) => String(e.uri)); + checks.push( + skillsCheck( + 'sep-2640-skill-uri-scheme', + 'Servers SHOULD use the skill:// URI scheme for the resources of a skill.', + otherScheme.length === 0 ? 'SUCCESS' : 'WARNING', + otherScheme.length === 0 + ? { details: { entryCount: entries.length } } + : { + errorMessage: `Entries served under another scheme (explicitly permitted; no scheme is privileged): ${joinErrs(otherScheme)}` + } + ) + ); + + // === entry-resources-required === + const resErrs: string[] = []; + entries.forEach((e, i) => { + if (isDynamicResources(e)) return; + if (resourcesArray(e) !== undefined) return; + resErrs.push( + `${entryLabel(e, i)}: resources is ${JSON.stringify(e.resources)}, neither an array nor "${RESOURCES_DYNAMIC}"` + ); + }); + checks.push( + skillsCheck( + 'sep-2640-entry-resources-required', + 'resources is REQUIRED on every skill entry and takes one of two forms: an array of {uri, digest, size} triples, or the string "dynamic". An entry with no resources at all, or with any other value, is invalid.', + resErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + resErrs.length === 0 + ? { + details: { + entryCount: entries.length, + dynamicEntries: entries.filter(isDynamicResources).length + } + } + : { errorMessage: joinErrs(resErrs) } + ) + ); + + // Entries carrying an array are the only ones the remaining checks apply to. + const arrayEntries = entries + .map((e, i) => ({ e, i, arr: resourcesArray(e) })) + .filter( + (x): x is { e: SkillEntry; i: number; arr: SkillResourceEntry[] } => + x.arr !== undefined + ); + + const dynamicOnlyReason = + 'Every entry declares "resources": "dynamic", which publishes no file manifest; the resources-array checks are not applicable.'; + + if (arrayEntries.length === 0) { + for (const id of [ + 'sep-2640-resources-complete', + 'sep-2640-resources-uri-within-skill', + 'sep-2640-resources-digest-format', + 'sep-2640-resources-size-required', + 'sep-2640-limit-resources-per-skill', + 'sep-2640-limit-total-size' + ]) { + checks.push( + skillsCheck(id, dynamicOnlyReason, 'SKIPPED', { + errorMessage: dynamicOnlyReason + }) + ); + } + } else { + // === resources-complete === + // Observable half: an entry matching the skill's own uri, and no file + // listed twice. Full completeness (every file of the skill) cannot be + // confirmed from the wire without a second source of truth. + const completeErrs: string[] = []; + for (const { e, i, arr } of arrayEntries) { + const own = typeof e.uri === 'string' ? e.uri : undefined; + if (own && !arr.some((r) => r.uri === own)) { + completeErrs.push( + `${entryLabel(e, i)}: resources has no entry matching the skill's own uri (the SKILL.md digest and size)` + ); + } + const seen = new Set(); + const dupes = new Set(); + for (const r of arr) { + if (typeof r.uri !== 'string') continue; + if (seen.has(r.uri)) dupes.add(r.uri); + seen.add(r.uri); + } + if (dupes.size > 0) { + completeErrs.push( + `${entryLabel(e, i)}: resources lists ${[...dupes].join(', ')} more than once` + ); + } + } + checks.push( + skillsCheck( + 'sep-2640-resources-complete', + 'When present, resources MUST be complete: it lists every file of the skill, each exactly once, including an entry matching the skill top-level uri — that entry carries the digest and size of SKILL.md itself.', + completeErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + completeErrs.length === 0 + ? { details: { entriesChecked: arrayEntries.length } } + : { errorMessage: joinErrs(completeErrs) } + ) + ); + + // === resources-uri-within-skill === + const containErrs: string[] = []; + for (const { e, i, arr } of arrayEntries) { + const own = typeof e.uri === 'string' ? e.uri : undefined; + const root = own ? skillRootFromManifestUri(own) : undefined; + if (!root) continue; + for (const r of arr) { + if (typeof r.uri !== 'string') { + containErrs.push(`${entryLabel(e, i)}: a resources entry has no uri`); + continue; + } + if (r.uri !== own && !r.uri.startsWith(`${root}/`)) { + containErrs.push( + `${entryLabel(e, i)}: ${r.uri} is outside the skill directory ${root}` + ); + } + } + } + checks.push( + skillsCheck( + 'sep-2640-resources-uri-within-skill', + "Each uri MUST be the skill's SKILL.md or a file within the skill's directory.", + containErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + containErrs.length === 0 + ? { details: { entriesChecked: arrayEntries.length } } + : { errorMessage: joinErrs(containErrs) } + ) + ); + + // === resources-digest-format === + const digestErrs: string[] = []; + for (const { e, i, arr } of arrayEntries) { + for (const r of arr) { + if ( + typeof r.digest !== 'string' || + !SKILL_DIGEST_PATTERN.test(r.digest) + ) { + digestErrs.push( + `${entryLabel(e, i)}: ${String(r.uri)} digest=${JSON.stringify(r.digest)} is not sha256:{64 lowercase hex}` + ); + } + } + } + checks.push( + skillsCheck( + 'sep-2640-resources-digest-format', + "Digests are SHA-256 hashes of an artifact's raw bytes, formatted as sha256:{hex} where {hex} is 64 lowercase hexadecimal characters.", + digestErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + digestErrs.length === 0 + ? { details: { entriesChecked: arrayEntries.length } } + : { errorMessage: joinErrs(digestErrs) } + ) + ); + + // === resources-size-required === + const sizeErrs: string[] = []; + for (const { e, i, arr } of arrayEntries) { + for (const r of arr) { + if ( + typeof r.size !== 'number' || + !Number.isInteger(r.size) || + r.size < 0 + ) { + sizeErrs.push( + `${entryLabel(e, i)}: ${String(r.uri)} size=${JSON.stringify(r.size)} is not a non-negative integer` + ); + } + } + } + checks.push( + skillsCheck( + 'sep-2640-resources-size-required', + "Each entry MUST carry size: the length in bytes of the file's raw content — the same bytes the digest covers.", + sizeErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + sizeErrs.length === 0 + ? { details: { entriesChecked: arrayEntries.length } } + : { errorMessage: joinErrs(sizeErrs) } + ) + ); + + // === limit-resources-per-skill (SHOULD NOT exceed) === + const overCount = arrayEntries + .filter(({ arr }) => arr.length > MAX_RESOURCES_PER_SKILL) + .map(({ e, i, arr }) => `${entryLabel(e, i)}: ${arr.length} entries`); + checks.push( + skillsCheck( + 'sep-2640-limit-resources-per-skill', + 'Servers SHOULD NOT serve a skill exceeding 512 resource entries, counted over the entries of the skill resources, SKILL.md included.', + overCount.length === 0 ? 'SUCCESS' : 'WARNING', + overCount.length === 0 + ? { + details: { + maxObserved: Math.max( + ...arrayEntries.map(({ arr }) => arr.length) + ), + limit: MAX_RESOURCES_PER_SKILL + } + } + : { errorMessage: joinErrs(overCount) } + ) + ); + + // === limit-total-size (SHOULD NOT exceed) === + const sums = arrayEntries.map(({ e, i, arr }) => ({ + label: entryLabel(e, i), + total: arr.reduce( + (acc, r) => acc + (typeof r.size === 'number' ? r.size : 0), + 0 + ) + })); + const overSize = sums + .filter((s) => s.total > MAX_TOTAL_SIZE_PER_SKILL) + .map((s) => `${s.label}: ${s.total} bytes`); + checks.push( + skillsCheck( + 'sep-2640-limit-total-size', + 'Servers SHOULD NOT serve a skill whose total file size exceeds 16 MiB (16,777,216 bytes), summed over the skill resources.', + overSize.length === 0 ? 'SUCCESS' : 'WARNING', + overSize.length === 0 + ? { + details: { + maxObservedBytes: Math.max(...sums.map((s) => s.total)), + limit: MAX_TOTAL_SIZE_PER_SKILL + } + } + : { errorMessage: joinErrs(overSize) } + ) + ); + } + + // === metadata-reserved-prefix === + // This extension currently defines no keys under the reserved prefix, so a + // server publishing one is squatting on a namespace reserved for MCP. + const reservedErrs: string[] = []; + entries.forEach((e, i) => { + const fm = e.frontmatter as Record | undefined; + const meta = fm?.metadata; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return; + for (const key of Object.keys(meta as Record)) { + if (key.startsWith(FRONTMATTER_RESERVED_PREFIX)) { + reservedErrs.push( + `${entryLabel(e, i)}: frontmatter.metadata["${key}"]` + ); + } + } + }); + checks.push( + skillsCheck( + 'sep-2640-metadata-reserved-prefix', + 'Within the frontmatter metadata object, keys prefixed with io.modelcontextprotocol/ are reserved for metadata defined by MCP extensions. This extension currently defines no such keys.', + reservedErrs.length === 0 ? 'SUCCESS' : 'WARNING', + reservedErrs.length === 0 + ? { details: { entryCount: entries.length } } + : { + errorMessage: `Keys under the reserved prefix, which this extension does not currently define: ${joinErrs(reservedErrs)}` + } + ) + ); + + // === name-naming-rules === + // The name is recoverable from the URI alone, so this is checkable without + // fetching anything. + const badNames: string[] = []; + entries.forEach((e, i) => { + if (typeof e.uri !== 'string') return; + const name = skillNameFromManifestUri(e.uri); + if (name !== undefined && !SKILL_NAME_PATTERN.test(name)) { + badNames.push(`${entryLabel(e, i)}: name "${name}"`); + } + }); + checks.push( + skillsCheck( + 'sep-2640-name-naming-rules', + "The final segment, being the skill name, MUST satisfy the Agent Skills specification's naming rules (1-64 characters, lowercase alphanumeric and hyphens, no leading, trailing or consecutive hyphens).", + badNames.length === 0 ? 'SUCCESS' : 'FAILURE', + badNames.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: joinErrs(badNames) } + ) + ); + + // === authority-reg-name (SHOULD) === + const badAuthority: string[] = []; + entries.forEach((e, i) => { + if (typeof e.uri !== 'string') return; + const schemeEnd = e.uri.indexOf('://'); + if (schemeEnd < 0) return; + const segments = e.uri + .slice(schemeEnd + 3) + .split('/') + .filter((x) => x.length > 0); + const authority = segments[0]; + if (authority !== undefined && !REG_NAME_PATTERN.test(authority)) { + badAuthority.push(`${entryLabel(e, i)}: authority "${authority}"`); + } + }); + checks.push( + skillsCheck( + 'sep-2640-authority-reg-name', + 'The first segment occupies the authority component and SHOULD be a valid reg-name per RFC 3986.', + badAuthority.length === 0 ? 'SUCCESS' : 'WARNING', + badAuthority.length === 0 + ? { details: { entryCount: entries.length } } + : { errorMessage: joinErrs(badAuthority) } + ) + ); + + // === names-should-be-unique (SHOULD) === + // A collision is explicitly permitted — two skills at different paths may + // share a final segment — so this is a WARNING that tells a host operator the + // listing will need disambiguating, not a failure. + const byName = new Map(); + entries.forEach((e) => { + if (typeof e.uri !== 'string') return; + const n = skillNameFromManifestUri(e.uri); + if (n === undefined) return; + byName.set(n, [...(byName.get(n) ?? []), e.uri]); + }); + const collisions = [...byName.entries()] + .filter(([, uris]) => uris.length > 1) + .map(([n, uris]) => `"${n}" served at ${uris.join(' and ')}`); + checks.push( + skillsCheck( + 'sep-2640-names-should-be-unique', + "Within a server's listing, names SHOULD be unique, but they are not guaranteed to be.", + collisions.length === 0 ? 'SUCCESS' : 'WARNING', + collisions.length === 0 + ? { details: { distinctNames: byName.size } } + : { + errorMessage: `Names collide within one listing, so hosts MUST disambiguate them: ${joinErrs(collisions)}` + } + ) + ); + + return checks; +} + +/** + * Fetch one listed `SKILL.md` and check it against the entry that advertised + * it. This is the server-side half of the host's frontmatter-comparison MUST: + * if the entry's `frontmatter` does not match the file, no conforming host can + * load the skill. + */ +async function readbackChecks( + conn: Parameters[0], + entries: SkillEntry[] +): Promise { + const checks: ConformanceCheck[] = []; + const sample = entries.find( + (e) => + typeof e.uri === 'string' && e.uri.endsWith(`/${SKILL_MANIFEST_FILENAME}`) + ); + const uri = sample?.uri as string | undefined; + + if (!uri) { + const reason = `No listed entry has a uri ending in /${SKILL_MANIFEST_FILENAME}, so no SKILL.md can be read back.`; + for (const id of READBACK_IDS) { + checks.push(skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason })); + } + return checks; + } + + let body: Awaited>; + try { + body = await readResourceText(conn, uri); + } catch (e) { + const reason = `resources/read on ${uri} failed: ${e instanceof Error ? e.message : String(e)}`; + checks.push( + skillsCheck( + 'sep-2640-skillmd-required', + 'Every skill MUST contain a SKILL.md file at its root.', + 'FAILURE', + { errorMessage: reason } + ) + ); + for (const id of [ + 'sep-2640-skillmd-frontmatter', + 'sep-2640-entry-frontmatter-identical' + ]) { + checks.push( + skillsCheck(id, 'SKILL.md is unreadable.', 'SKIPPED', { + errorMessage: reason + }) + ); + } + return checks; + } + + if (!body) { + const reason = `resources/read on ${uri} returned no text content, so the listed SKILL.md is not retrievable.`; + checks.push( + skillsCheck( + 'sep-2640-skillmd-required', + 'Every skill MUST contain a SKILL.md file at its root.', + 'FAILURE', + { errorMessage: reason } + ) + ); + for (const id of [ + 'sep-2640-skillmd-frontmatter', + 'sep-2640-entry-frontmatter-identical' + ]) { + checks.push( + skillsCheck(id, 'No SKILL.md content to inspect.', 'SKIPPED', { + errorMessage: reason + }) + ); + } + return checks; + } + + checks.push( + skillsCheck( + 'sep-2640-skillmd-required', + 'Every skill MUST contain a SKILL.md file at its root.', + 'SUCCESS', + { details: { uri, bytes: body.text.length } } + ) + ); + + // === skillmd-frontmatter === + const fm = parseFrontmatter(body.text); + const fmErrs: string[] = []; + if (!fm) { + fmErrs.push('SKILL.md has no leading --- delimited YAML frontmatter block'); + } else { + if (typeof fm.name !== 'string' || fm.name.length === 0) { + fmErrs.push('frontmatter has no non-empty name'); + } + if (typeof fm.description !== 'string' || fm.description.length === 0) { + fmErrs.push('frontmatter has no non-empty description'); + } + } + checks.push( + skillsCheck( + 'sep-2640-skillmd-frontmatter', + 'SKILL.md MUST begin with YAML frontmatter containing at minimum the name and description fields.', + fmErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + fmErrs.length === 0 + ? { details: { uri } } + : { errorMessage: `${uri}: ${fmErrs.join('; ')}` } + ) + ); + + // === entry-frontmatter-identical === + const declared = sample?.frontmatter as Record | undefined; + if ( + !fm || + !declared || + typeof declared !== 'object' || + Array.isArray(declared) + ) { + const reason = + 'Either the file has no parseable frontmatter or the entry carries no frontmatter object, so the two cannot be compared.'; + checks.push( + skillsCheck( + 'sep-2640-entry-frontmatter-identical', + 'The frontmatter object MUST be identical in content to the frontmatter of the SKILL.md it describes.', + 'SKIPPED', + { errorMessage: reason } + ) + ); + return checks; + } + + const diffs: string[] = []; + const keys = new Set([...Object.keys(fm), ...Object.keys(declared)]); + // Content, not serialisation: an encoder that orders map keys differently + // from the YAML source (Go's encoding/json sorts them) still describes the + // same frontmatter. + for (const k of keys) { + if (isDeepStrictEqual(fm[k] ?? null, declared[k] ?? null)) continue; + diffs.push( + `${k}: file=${JSON.stringify(fm[k] ?? null)} entry=${JSON.stringify(declared[k] ?? null)}` + ); + } + checks.push( + skillsCheck( + 'sep-2640-entry-frontmatter-identical', + 'The frontmatter object MUST be identical in content to the frontmatter of the SKILL.md it describes.', + diffs.length === 0 ? 'SUCCESS' : 'FAILURE', + diffs.length === 0 + ? { details: { uri, fields: [...keys] } } + : { + errorMessage: `${uri}: entry frontmatter differs from the file's: ${joinErrs(diffs)}` + } + ) + ); + + return checks; +} + +/** Exercise `skills/get` against a real entry and against an unknown URI. */ +async function getChecks( + conn: Parameters[0], + entries: SkillEntry[], + specVersion: string +): Promise { + const checks: ConformanceCheck[] = []; + const sample = entries.find((e) => typeof e.uri === 'string'); + const sampleUri = sample?.uri as string | undefined; + + if (!sampleUri) { + const reason = + 'No listed entry carries a uri, so skills/get cannot be exercised against a known skill.'; + for (const id of GET_IDS) { + checks.push(skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason })); + } + return checks; + } + + const got = await skillsGet(conn, sampleUri); + if ('error' in got) { + const reason = `${SKILLS_GET_METHOD} failed for a skill the server itself listed (${sampleUri}) with code ${got.error.code}: ${got.error.message}.`; + checks.push( + skillsCheck( + 'sep-2640-capability-commits-to-methods', + 'Declaring the extension commits the server to skills/list and skills/get.', + 'FAILURE', + { errorMessage: reason } + ), + skillsCheck( + 'sep-2640-skills-get-implemented', + 'A server declaring the extension MUST also implement the skills/get method, which returns the entry for a single skill named by its URI.', + 'FAILURE', + { errorMessage: reason } + ) + ); + for (const id of [ + 'sep-2640-skills-get-entry-shape', + 'sep-2640-skills-get-no-cursor', + 'sep-2640-skills-get-unknown-uri-invalid-params' + ]) { + checks.push( + skillsCheck(id, 'skills/get is unavailable.', 'SKIPPED', { + errorMessage: reason + }) + ); + } + return checks; + } + + checks.push( + skillsCheck( + 'sep-2640-capability-commits-to-methods', + 'Declaring the extension itself commits the server to skills/list and skills/get.', + 'SUCCESS', + { details: { methods: [SKILLS_LIST_METHOD, SKILLS_GET_METHOD] } } + ), + skillsCheck( + 'sep-2640-skills-get-implemented', + 'A server declaring the extension MUST also implement the skills/get method, which returns the entry for a single skill named by its URI.', + 'SUCCESS', + { details: { probedUri: sampleUri } } + ) + ); + + // === skills-get-entry-shape === + const skill = got.result.skill as SkillEntry | undefined; + const shapeErrs: string[] = []; + if (!skill || typeof skill !== 'object' || Array.isArray(skill)) { + shapeErrs.push('result.skill is missing or not an object'); + } else { + if (skill.uri !== sampleUri) { + shapeErrs.push( + `result.skill.uri=${JSON.stringify(skill.uri)} does not echo the requested uri ${sampleUri}` + ); + } + if ( + !skill.frontmatter || + typeof skill.frontmatter !== 'object' || + Array.isArray(skill.frontmatter) + ) { + shapeErrs.push('result.skill.frontmatter is missing or not an object'); + } + if (!isDynamicResources(skill) && resourcesArray(skill) === undefined) { + shapeErrs.push( + `result.skill.resources=${JSON.stringify(skill.resources)} is neither an array nor "${RESOURCES_DYNAMIC}"` + ); + } + } + checks.push( + skillsCheck( + 'sep-2640-skills-get-entry-shape', + 'The skill object is a skill entry, identical in shape and meaning to an entry of skills/list — the same uri, frontmatter, and resources fields, under the same rules.', + shapeErrs.length === 0 ? 'SUCCESS' : 'FAILURE', + shapeErrs.length === 0 + ? { details: { probedUri: sampleUri } } + : { errorMessage: joinErrs(shapeErrs) } + ) + ); + + // === skills-get-no-cursor === + const hasCursor = got.result.nextCursor !== undefined; + checks.push( + skillsCheck( + 'sep-2640-skills-get-no-cursor', + 'The result carries no pagination cursor: a single entry is not a list.', + hasCursor ? 'FAILURE' : 'SUCCESS', + hasCursor + ? { + errorMessage: `skills/get returned nextCursor=${JSON.stringify(got.result.nextCursor)}; a single entry is not a list.` + } + : { details: { probedUri: sampleUri } } + ) + ); + + // === skills-get-cache-attributes === + // SEP-2640 left this open ("whether the result should also carry the base + // protocol's caching attributes ... is left open"); the stable page closed + // it in the affirmative on 2026-09-10 (ext-skills#139). Gated the same way + // as the skills/list check: below 2026-07-28 CacheableResult is undefined + // by the negotiated schema, so omission is correct rather than a defect. + const getResult = got.result as Record; + const getTtl = getResult.ttlMs !== undefined; + const getScope = getResult.cacheScope !== undefined; + const getCacheApplies = specVersion >= '2026-07-28'; + const getCacheText = + 'GetSkillResult extends CacheableResult, so ttlMs and cacheScope are REQUIRED, as they are on resources/read.'; + + if (!getCacheApplies) { + checks.push( + skillsCheck( + 'sep-2640-skills-get-cache-attributes', + getCacheText, + 'SKIPPED', + { + errorMessage: `not applicable on negotiated protocol ${specVersion}: ttlMs and cacheScope are defined from 2026-07-28`, + details: { + specVersion, + ttlMs: getResult.ttlMs, + cacheScope: getResult.cacheScope + } + } + ) + ); + } else { + const missing = [!getTtl && 'ttlMs', !getScope && 'cacheScope'] + .filter(Boolean) + .join(' and '); + checks.push( + skillsCheck( + 'sep-2640-skills-get-cache-attributes', + getCacheText, + getTtl && getScope ? 'SUCCESS' : 'FAILURE', + getTtl && getScope + ? { + details: { + ttlMs: getResult.ttlMs, + cacheScope: getResult.cacheScope + } + } + : { + errorMessage: `skills/get result omits ${missing} on protocol ${specVersion}, where the stable spec page requires both.` + } + ) + ); + } + + // === skills-get-unknown-uri-invalid-params === + const unknown = await skillsGet(conn, UNKNOWN_SKILL_URI); + if ('error' in unknown) { + const ok = unknown.error.code === JSONRPC_INVALID_PARAMS; + checks.push( + skillsCheck( + 'sep-2640-skills-get-unknown-uri-invalid-params', + 'If the URI does not identify a skill the server serves, the server MUST return error -32602 (Invalid params).', + ok ? 'SUCCESS' : 'FAILURE', + ok + ? { + details: { + probedUri: UNKNOWN_SKILL_URI, + code: unknown.error.code + } + } + : { + errorMessage: `skills/get on an unserved URI returned code ${unknown.error.code} (${unknown.error.message}); expected ${JSONRPC_INVALID_PARAMS}.` + } + ) + ); + } else { + checks.push( + skillsCheck( + 'sep-2640-skills-get-unknown-uri-invalid-params', + 'If the URI does not identify a skill the server serves, the server MUST return error -32602 (Invalid params).', + 'FAILURE', + { + errorMessage: `skills/get returned a successful result for ${UNKNOWN_SKILL_URI}, which no conformant server should serve; expected error ${JSONRPC_INVALID_PARAMS}.` + } + ) + ); + } + + return checks; +} diff --git a/src/scenarios/server/skills/helpers.ts b/src/scenarios/server/skills/helpers.ts new file mode 100644 index 0000000..c5320f2 --- /dev/null +++ b/src/scenarios/server/skills/helpers.ts @@ -0,0 +1,461 @@ +/** + * Shared helpers for the SEP-2640 (Skills extension) server-conformance + * scenarios under this directory. + * + * The scenarios treat the server-under-test as an arbitrary SEP-2640 server: + * capability is read from `server/discover` (never inferred from an error), and + * every skill is discovered dynamically through `skills/list` — no + * fixture-specific URI is hardcoded, so the checks pass against any conformant + * server, not just one implementation's fixture. + * + * Extracted against the 2026-08-21 revision of the SEP (branch + * `sep/skills-extension`), which replaced the `skill://index.json` well-known + * resource with the `skills/list` and `skills/get` methods, deferred archive + * distribution, and reshaped the skill entry to `{uri, frontmatter, resources}`. + */ + +import type { + CheckStatus, + ConformanceCheck, + SpecReference +} from '../../../types'; +import type { Connection } from '../../../connection'; +import { JsonRpcError } from '../../../connection'; +import { parse as parseYaml } from 'yaml'; + +export const SKILLS_EXTENSION_ID = 'io.modelcontextprotocol/skills'; +export const SKILL_URI_SCHEME = 'skill://'; +export const SKILL_MANIFEST_FILENAME = 'SKILL.md'; +export const SKILLS_META_PREFIX = 'io.modelcontextprotocol.skills/'; + +/** Reserved prefix for MCP-defined keys inside frontmatter `metadata`. */ +export const FRONTMATTER_RESERVED_PREFIX = 'io.modelcontextprotocol/'; + +export const SKILLS_LIST_METHOD = 'skills/list'; +export const SKILLS_GET_METHOD = 'skills/get'; +export const DIRECTORY_READ_METHOD = 'resources/directory/read'; + +/** `sha256:{hex}` with exactly 64 lowercase hex characters (SEP-2640). */ +export const SKILL_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +/** The `"dynamic"` sentinel a server sets in place of a `resources` array. */ +export const RESOURCES_DYNAMIC = 'dynamic'; + +/** Per-skill limits fixed by the SEP (§Limits). */ +export const MAX_RESOURCES_PER_SKILL = 512; +export const MAX_TOTAL_SIZE_PER_SKILL = 16 * 1024 * 1024; // 16 MiB + +export const JSONRPC_METHOD_NOT_FOUND = -32601; +export const JSONRPC_INVALID_PARAMS = -32602; + +export const SEP_2640_REF: SpecReference = { + id: 'SEP-2640', + url: 'https://modelcontextprotocol.io/seps/2640-skills-extension#specification' +}; + +/** A `resources/list` / directory-read entry (only the fields we inspect). */ +export interface SkillResource { + uri: string; + name?: string; + description?: string; + mimeType?: string; + _meta?: Record; +} + +/** One `{uri, digest, size}` triple of a skill entry's `resources` array. */ +export interface SkillResourceEntry { + uri?: unknown; + digest?: unknown; + size?: unknown; + [key: string]: unknown; +} + +/** + * One skill entry, as returned by `skills/list` (in `skills[]`) and by + * `skills/get` (as `skill`). The two are identical in shape and meaning. + */ +export interface SkillEntry { + uri?: unknown; + frontmatter?: unknown; + /** An array of `{uri, digest, size}`, or the string `"dynamic"`. */ + resources?: unknown; + [key: string]: unknown; +} + +export interface SkillsListResult { + skills?: unknown; + nextCursor?: string; + ttlMs?: unknown; + cacheScope?: unknown; + [key: string]: unknown; +} + +export interface SkillsGetResult { + skill?: unknown; + nextCursor?: unknown; + [key: string]: unknown; +} + +/** First text content of a `resources/read`, with its mimeType and `_meta`. */ +export interface ResourceText { + text: string; + mimeType?: string; + meta?: Record; +} + +/** + * Build a check carrying the SEP-2640 reference. Per AGENTS.md the same `id` + * flips `status` + `errorMessage` between SUCCESS and FAILURE rather than + * branching into distinct slugs. + */ +export function skillsCheck( + id: string, + description: string, + status: CheckStatus, + extras: Partial = {} +): ConformanceCheck { + return { + id, + name: id, + description, + status, + timestamp: new Date().toISOString(), + specReferences: [SEP_2640_REF], + ...extras + }; +} + +/** A JSON object, as opposed to an array, `null`, or a primitive. */ +export function isSettingsObject( + value: unknown +): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** How to name an observed non-object value in an error message. */ +export function describeValue(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + return `a ${typeof value}`; +} + +/** + * Whether the server declared the skills extension at all, and the raw value it + * declared it with, before any shape coercion. + * + * `skillsCapability` folds a malformed declaration (`true`, a string, an array) + * into `undefined`, which downstream reads as "extension not declared" and + * SKIPs. That is a false SKIP: the server did declare the extension, it just + * declared it with the wrong type, and the suite should say so. Callers that + * need to tell "absent" from "malformed" apart use this instead. + * + * Reads the declared capability from `server/discover` (mirrors + * `tasks/capability.ts`), never inferred from a `-32601`. + */ +export async function declaredSkillsCapability( + conn: Connection +): Promise<{ declared: boolean; value: unknown }> { + const discovered = await conn.discover(); + const caps = (discovered.capabilities as Record) ?? {}; + const extensions = caps.extensions as Record | undefined; + if (!extensions || !(SKILLS_EXTENSION_ID in extensions)) { + return { declared: false, value: undefined }; + } + return { declared: true, value: extensions[SKILLS_EXTENSION_ID] }; +} + +/** + * The skills extension object declared under `capabilities.extensions`, or + * `undefined` when the server did not declare it — or declared it with + * something that is not a settings object, which the callers of this helper + * treat the same way. An undeclared optional extension is a SKIP. + */ +export async function skillsCapability( + conn: Connection +): Promise | undefined> { + const { value } = await declaredSkillsCapability(conn); + return isSettingsObject(value) ? value : undefined; +} + +/** + * Whether the declared extension object nests its settings inline, as both + * SEP-2133 and SEP-2640 require, rather than wrapping them in an envelope. + * + * SEP-2133 (status Final) defines `extensions` as "a map of extension + * identifiers to per-extension settings objects", and SEP-2640's capability + * block matches: `{"io.modelcontextprotocol/skills": {"directoryRead": true}}`. + * Neither SEP defines an envelope, and neither has a slot for `id`, + * `specVersion` or `stability`. + * + * An earlier revision of this helper accepted a `config` envelope alongside the + * inline form, on the belief that the two SEPs disagreed. Re-reading SEP-2133 + * at Final status, they do not. The envelope is a non-conformant shape emitted + * by at least one SDK, so it is reported rather than silently accepted. + */ +export function settingsAreInline(skills: Record): { + inline: boolean; + envelopeKeys: string[]; +} { + const envelopeKeys = ['config', 'specVersion', 'stability', 'id'].filter( + (k) => k in skills + ); + return { inline: envelopeKeys.length === 0, envelopeKeys }; +} + +/** + * Whether the server declares the base `resources` capability. + * + * The stable spec page (ext-skills#139) states this as a consequence of the + * base Resources specification rather than a new obligation: a server that + * serves skill files through `resources/read` already has to declare it. + */ +export async function resourcesCapabilityDeclared( + conn: Connection +): Promise { + const discovered = await conn.discover(); + const caps = (discovered.capabilities as Record) ?? {}; + return isSettingsObject(caps.resources); +} + +/** + * Whether the skills extension declares `directoryRead: true`. + * + * Reads only the inline location the SEPs specify. A server that buries the + * flag inside an envelope fails `sep-2640-capability-declaration-inline` and is + * treated here as not having declared the optional method, which is the + * conservative reading: a client that follows the spec would not see the flag + * either, and "clients MUST NOT call resources/directory/read against a server + * that has not declared directoryRead: true". + */ +export function directoryReadDeclared( + skills: Record +): boolean { + return skills.directoryRead === true; +} + +/** Everything from `resources/list`, paginating until `nextCursor` clears. */ +export async function listAllResources( + conn: Connection +): Promise { + const out: SkillResource[] = []; + let cursor: string | undefined; + do { + const page = await conn.request<{ + resources?: SkillResource[]; + nextCursor?: string; + }>('resources/list', cursor ? { cursor } : undefined); + out.push(...(page.resources ?? [])); + cursor = page.nextCursor; + } while (cursor); + return out; +} + +/** One page of `skills/list`, kept separate so pagination can be inspected. */ +export interface SkillsListPage { + result: SkillsListResult; + entries: SkillEntry[]; +} + +/** + * Call `skills/list` once, optionally with a cursor. Returns a `JsonRpcError` + * rather than throwing so a scenario can distinguish "method not implemented" + * (a FAILURE, since the method is mandatory for a declaring server) from a + * transport fault. + */ +export async function skillsListPage( + conn: Connection, + cursor?: string +): Promise { + try { + const result = await conn.request( + SKILLS_LIST_METHOD, + cursor ? { cursor } : {} + ); + const entries = Array.isArray(result.skills) + ? (result.skills as SkillEntry[]) + : []; + return { result, entries }; + } catch (e) { + if (e instanceof JsonRpcError) return { error: e }; + throw e; + } +} + +/** + * Every entry from `skills/list`, following `nextCursor`. `pages` is retained + * so the atomic-entry and pagination checks can reason about page boundaries. + * Bounded to avoid looping forever against a server that returns a constant + * cursor. + */ +export async function skillsListAll( + conn: Connection, + maxPages = 50 +): Promise< + | { entries: SkillEntry[]; pages: SkillsListPage[]; truncated: boolean } + | { error: JsonRpcError } +> { + const pages: SkillsListPage[] = []; + const entries: SkillEntry[] = []; + let cursor: string | undefined; + const seenCursors = new Set(); + + for (let i = 0; i < maxPages; i++) { + const page = await skillsListPage(conn, cursor); + if ('error' in page) return page; + pages.push(page); + entries.push(...page.entries); + const next = page.result.nextCursor; + if (typeof next !== 'string' || next.length === 0) { + return { entries, pages, truncated: false }; + } + if (seenCursors.has(next)) { + // A repeating cursor is a server bug; stop rather than spin. + return { entries, pages, truncated: true }; + } + seenCursors.add(next); + cursor = next; + } + return { entries, pages, truncated: true }; +} + +/** Call `skills/get` for one skill URI. */ +export async function skillsGet( + conn: Connection, + uri: string +): Promise<{ result: SkillsGetResult } | { error: JsonRpcError }> { + try { + const result = await conn.request(SKILLS_GET_METHOD, { + uri + }); + return { result }; + } catch (e) { + if (e instanceof JsonRpcError) return { error: e }; + throw e; + } +} + +/** Read a resource's first text content plus its mimeType and `_meta`. */ +export async function readResourceText( + conn: Connection, + uri: string +): Promise { + const res = await conn.request<{ + contents?: Array<{ + text?: string; + mimeType?: string; + _meta?: Record; + }>; + }>('resources/read', { uri }); + const entry = (res.contents ?? []).find((c) => typeof c.text === 'string'); + if (!entry || typeof entry.text !== 'string') return undefined; + return { text: entry.text, mimeType: entry.mimeType, meta: entry._meta }; +} + +/** + * The skill name recoverable from a `SKILL.md` resource URI: the final segment + * of ``, i.e. the last path segment before the trailing + * `SKILL.md`. Returns `undefined` when the URI does not end in `/SKILL.md`. + * + * Scheme-agnostic by design: the SEP is explicit that "no scheme is + * privileged" and that the structural constraints "apply regardless of + * scheme", so a server serving skills under `github://` is judged by the same + * path rule as one using `skill://`. + * + * skill://org/team/deploy/SKILL.md -> "deploy" + * github://acme/repo/lint/SKILL.md -> "lint" + */ +export function skillNameFromManifestUri(uri: string): string | undefined { + const schemeEnd = uri.indexOf('://'); + if (schemeEnd < 0) return undefined; + const parts = uri + .slice(schemeEnd + 3) + .split('/') + .filter((p) => p.length > 0); + if (parts.length < 2) return undefined; + if (parts[parts.length - 1] !== SKILL_MANIFEST_FILENAME) return undefined; + return parts[parts.length - 2]; +} + +/** The skill's root directory URI: its `SKILL.md` URI with the file removed. */ +export function skillRootFromManifestUri(uri: string): string | undefined { + if (!uri.endsWith(`/${SKILL_MANIFEST_FILENAME}`)) return undefined; + return uri.slice(0, -`/${SKILL_MANIFEST_FILENAME}`.length); +} + +/** + * Extract and parse the YAML frontmatter block at the head of a `SKILL.md`. + * Returns `undefined` when there is no leading `---` delimited block or it does + * not parse to an object. + */ +export function parseFrontmatter( + markdown: string +): Record | undefined { + // Tolerate a leading UTF-8 BOM before the opening `---` fence. + const body = markdown.charCodeAt(0) === 0xfeff ? markdown.slice(1) : markdown; + const match = body.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/); + if (!match) return undefined; + try { + const parsed = parseYaml(match[1]) as unknown; + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +/** True when the entry's `resources` is the `"dynamic"` sentinel. */ +export function isDynamicResources(entry: SkillEntry): boolean { + return entry.resources === RESOURCES_DYNAMIC; +} + +/** + * The entry's `resources` array, or `undefined` when it is `"dynamic"`, absent, + * or any other value. Callers distinguish those cases via `isDynamicResources`. + */ +export function resourcesArray( + entry: SkillEntry +): SkillResourceEntry[] | undefined { + return Array.isArray(entry.resources) + ? (entry.resources as SkillResourceEntry[]) + : undefined; +} + +/** A short, stable label for an entry, for error messages. */ +export function entryLabel(entry: SkillEntry, i: number): string { + return typeof entry.uri === 'string' ? entry.uri : `skills[${i}]`; +} + +/** + * Every child of a directory, following `nextCursor` until it clears. + * + * SEP-2640 says directory-read pagination mirrors `resources/list`, so a + * conformant server MAY split a directory across pages. Reading only the + * first page makes "no subdirectory here" indistinguishable from "the + * subdirectory is on page two". + */ +export async function directoryReadAll( + conn: Connection, + uri: string, + maxPages = 50 +): Promise<{ resources: SkillResource[]; pages: number; truncated: boolean }> { + const resources: SkillResource[] = []; + const seen = new Set(); + let cursor: string | undefined; + + for (let i = 0; i < maxPages; i++) { + const page = await conn.request<{ + resources?: SkillResource[]; + nextCursor?: string; + }>('resources/directory/read', cursor ? { uri, cursor } : { uri }); + resources.push(...(page.resources ?? [])); + const next = page.nextCursor; + if (typeof next !== 'string' || next.length === 0) { + return { resources, pages: i + 1, truncated: false }; + } + if (seen.has(next)) return { resources, pages: i + 1, truncated: true }; + seen.add(next); + cursor = next; + } + return { resources, pages: maxPages, truncated: true }; +} diff --git a/src/scenarios/server/skills/manifest.ts b/src/scenarios/server/skills/manifest.ts new file mode 100644 index 0000000..00bf4ab --- /dev/null +++ b/src/scenarios/server/skills/manifest.ts @@ -0,0 +1,358 @@ +/** + * SEP-2640 Skills extension — the `SKILL.md` manifest resource. + * + * One scenario, many checks (per AGENTS.md "fewer scenarios, more checks"). + * Each check's verbatim spec quote lives next to its check ID in + * src/seps/sep-2640.yaml. + * + * Discovery is dynamic and brand-neutral: the scenario finds a `skill-md` + * skill's `SKILL.md` resource from `resources/list` (preferred — it carries the + * Resource `name`/`description` metadata) or falls back to the first `skill-md` + * entry in `skills/list`, hardcoding no fixture skill. Undeclared + * extension SKIPs; a declared extension with no discoverable `SKILL.md` reports + * the missing prerequisite via untestableCheck (issue #248), never a silent + * green. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import { JsonRpcError, type RunContext } from '../../../connection'; +import { untestableCheck } from '../../untestable'; +import { + SKILLS_EXTENSION_ID, + SKILLS_META_PREFIX, + SEP_2640_REF, + type SkillResource, + skillsCapability, + skillsCheck, + listAllResources, + skillsListAll, + readResourceText, + skillNameFromManifestUri, + parseFrontmatter +} from './helpers'; + +const MIMETYPE_ID = 'sep-2640-skillmd-mimetype'; +const METADATA_NAME_ID = 'sep-2640-skillmd-metadata-name'; +const METADATA_DESCRIPTION_ID = 'sep-2640-skillmd-metadata-description'; +const FINAL_SEGMENT_ID = 'sep-2640-final-segment-equals-name'; +const META_PREFIX_ID = 'sep-2640-meta-prefix'; + +const MARKDOWN_MIME = 'text/markdown'; + +/** A SKILL.md resource URI is skill:///SKILL.md. */ +function isManifestUri(uri: string): boolean { + return skillNameFromManifestUri(uri) !== undefined; +} + +/** A `_meta` key that already carries a reverse-domain namespace (`vendor.tld/…`). */ +function isNamespacedMetaKey(key: string): boolean { + return /^[a-z0-9-]+(\.[a-z0-9-]+)+\//i.test(key); +} + +export class SkillsManifestScenario implements ClientScenario { + name = 'sep-2640-skills-manifest'; + readonly source = { extensionId: SKILLS_EXTENSION_ID } as const; + description = `SEP-2640 Skills extension: the \`SKILL.md\` manifest resource. + +**Resource**: \`skill:///SKILL.md\` (read via \`resources/read\`) + +**Requirements covered** (each check carries a verbatim spec excerpt in src/seps/sep-2640.yaml): + +- \`sep-2640-skillmd-mimetype\` — the SKILL.md resource \`mimeType\` SHOULD be \`text/markdown\` +- \`sep-2640-skillmd-metadata-name\` — the resource \`name\` SHOULD be the frontmatter \`name\` +- \`sep-2640-skillmd-metadata-description\` — the resource \`description\` SHOULD be the frontmatter \`description\` +- \`sep-2640-final-segment-equals-name\` — the final \`\` segment MUST equal the frontmatter \`name\` +- \`sep-2640-meta-prefix\` — un-namespaced skill \`_meta\` keys SHOULD use the \`io.modelcontextprotocol.skills/\` prefix + +**Discovery is dynamic**: the scenario picks the first \`skill-md\` skill it finds. Undeclared extension SKIPs; a declared extension with no discoverable SKILL.md reports the missing prerequisite (not a silent skip).`; + + async run(ctx: RunContext): Promise { + const conn = await ctx.connect(); + try { + const skills = await skillsCapability(conn); + const allIds = [ + MIMETYPE_ID, + METADATA_NAME_ID, + METADATA_DESCRIPTION_ID, + FINAL_SEGMENT_ID, + META_PREFIX_ID + ]; + if (!skills) { + const reason = + 'Server did not declare the io.modelcontextprotocol/skills extension; SKILL.md checks not applicable.'; + return allIds.map((id) => + skillsCheck(id, reason, 'SKIPPED', { errorMessage: reason }) + ); + } + + // === Dynamic discovery: resources/list first (carries Resource + // metadata), then skills/list. === + const resources = await listAllResources(conn); + const manifestResource: SkillResource | undefined = resources.find((r) => + isManifestUri(r.uri) + ); + let manifestUri = manifestResource?.uri; + if (!manifestUri) { + const listed = await skillsListAll(conn); + if (!('error' in listed)) { + const entry = listed.entries.find( + (e) => typeof e.uri === 'string' && isManifestUri(e.uri) + ); + manifestUri = entry?.uri as string | undefined; + } + } + + if (!manifestUri) { + const reason = + 'no skill:///SKILL.md resource found via resources/list or skills/list'; + return [ + untestableCheck( + MIMETYPE_ID, + MIMETYPE_ID, + 'SKILL.md resource mimeType SHOULD be text/markdown.', + reason, + [SEP_2640_REF], + 'WARNING' + ), + untestableCheck( + METADATA_NAME_ID, + METADATA_NAME_ID, + 'SKILL.md resource name SHOULD match the frontmatter name.', + reason, + [SEP_2640_REF], + 'WARNING' + ), + untestableCheck( + METADATA_DESCRIPTION_ID, + METADATA_DESCRIPTION_ID, + 'SKILL.md resource description SHOULD match the frontmatter description.', + reason, + [SEP_2640_REF], + 'WARNING' + ), + untestableCheck( + FINAL_SEGMENT_ID, + FINAL_SEGMENT_ID, + 'The final segment MUST equal the frontmatter name.', + reason, + [SEP_2640_REF], + 'FAILURE' + ), + untestableCheck( + META_PREFIX_ID, + META_PREFIX_ID, + 'Skill _meta keys SHOULD use the io.modelcontextprotocol.skills/ prefix.', + reason, + [SEP_2640_REF], + 'WARNING' + ) + ]; + } + + const checks: ConformanceCheck[] = []; + + // Read the manifest content (for mimeType, frontmatter, and _meta). + let content: + | { text: string; mimeType?: string; meta?: Record } + | undefined; + let readError: string | undefined; + try { + content = await readResourceText(conn, manifestUri); + if (!content) readError = 'resources/read returned no text content'; + } catch (e) { + readError = + e instanceof JsonRpcError + ? `resources/read failed: code ${e.code}: ${e.message}` + : e instanceof Error + ? e.message + : String(e); + } + + // === skillmd-mimetype (SHOULD) === + // Prefer the read content's mimeType; fall back to the resources/list + // Resource metadata mimeType. + const mimeType = content?.mimeType ?? manifestResource?.mimeType; + if (mimeType === undefined) { + checks.push( + untestableCheck( + MIMETYPE_ID, + MIMETYPE_ID, + 'SKILL.md resource mimeType SHOULD be text/markdown.', + `no mimeType observable for ${manifestUri}${readError ? ` (${readError})` : ''}`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else { + checks.push( + skillsCheck( + MIMETYPE_ID, + 'SKILL.md resource mimeType SHOULD be text/markdown.', + mimeType === MARKDOWN_MIME ? 'SUCCESS' : 'WARNING', + mimeType === MARKDOWN_MIME + ? { details: { uri: manifestUri, mimeType } } + : { + errorMessage: `expected mimeType "${MARKDOWN_MIME}", got ${JSON.stringify(mimeType)}` + } + ) + ); + } + + // Parse the frontmatter once for the name/description/final-segment checks. + const frontmatter = content ? parseFrontmatter(content.text) : undefined; + const fmName = + typeof frontmatter?.name === 'string' ? frontmatter.name : undefined; + const fmDescription = + typeof frontmatter?.description === 'string' + ? frontmatter.description + : undefined; + + // === final-segment-equals-name (MUST) === + const uriName = skillNameFromManifestUri(manifestUri); + if (fmName === undefined || uriName === undefined) { + const missing = + fmName === undefined + ? `SKILL.md frontmatter has no string "name"${readError ? ` (${readError})` : ''}` + : `could not derive the skill name from URI ${manifestUri}`; + checks.push( + untestableCheck( + FINAL_SEGMENT_ID, + FINAL_SEGMENT_ID, + 'The final segment MUST equal the frontmatter name.', + missing, + [SEP_2640_REF], + 'FAILURE' + ) + ); + } else { + checks.push( + skillsCheck( + FINAL_SEGMENT_ID, + 'The final segment of the SKILL.md URI MUST equal the frontmatter name.', + uriName === fmName ? 'SUCCESS' : 'FAILURE', + uriName === fmName + ? { details: { uri: manifestUri, name: fmName } } + : { + errorMessage: `final path segment "${uriName}" != frontmatter name "${fmName}"` + } + ) + ); + } + + // === skillmd-metadata-name (SHOULD) — needs the Resource metadata === + if (!manifestResource) { + checks.push( + untestableCheck( + METADATA_NAME_ID, + METADATA_NAME_ID, + 'SKILL.md resource name SHOULD match the frontmatter name.', + `SKILL.md ${manifestUri} is not listed in resources/list, so its Resource name metadata is not observable`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else if (fmName === undefined) { + checks.push( + untestableCheck( + METADATA_NAME_ID, + METADATA_NAME_ID, + 'SKILL.md resource name SHOULD match the frontmatter name.', + `SKILL.md frontmatter has no string "name" to compare against${readError ? ` (${readError})` : ''}`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else { + checks.push( + skillsCheck( + METADATA_NAME_ID, + 'The SKILL.md resource name SHOULD be set from the frontmatter name.', + manifestResource.name === fmName ? 'SUCCESS' : 'WARNING', + manifestResource.name === fmName + ? { details: { name: fmName } } + : { + errorMessage: `resource name ${JSON.stringify(manifestResource.name)} != frontmatter name ${JSON.stringify(fmName)}` + } + ) + ); + } + + // === skillmd-metadata-description (SHOULD) — needs Resource metadata === + if (!manifestResource) { + checks.push( + untestableCheck( + METADATA_DESCRIPTION_ID, + METADATA_DESCRIPTION_ID, + 'SKILL.md resource description SHOULD match the frontmatter description.', + `SKILL.md ${manifestUri} is not listed in resources/list, so its Resource description metadata is not observable`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else if (fmDescription === undefined) { + checks.push( + untestableCheck( + METADATA_DESCRIPTION_ID, + METADATA_DESCRIPTION_ID, + 'SKILL.md resource description SHOULD match the frontmatter description.', + `SKILL.md frontmatter has no string "description" to compare against${readError ? ` (${readError})` : ''}`, + [SEP_2640_REF], + 'WARNING' + ) + ); + } else { + checks.push( + skillsCheck( + METADATA_DESCRIPTION_ID, + 'The SKILL.md resource description SHOULD be set from the frontmatter description.', + manifestResource.description === fmDescription + ? 'SUCCESS' + : 'WARNING', + manifestResource.description === fmDescription + ? { details: { description: fmDescription } } + : { + errorMessage: `resource description ${JSON.stringify(manifestResource.description)} != frontmatter description ${JSON.stringify(fmDescription)}` + } + ) + ); + } + + // === meta-prefix (SHOULD, conditional on _meta keys being present) === + // Union the _meta of the read content and the resources/list Resource. + const metaKeys = new Set([ + ...Object.keys(content?.meta ?? {}), + ...Object.keys(manifestResource?._meta ?? {}) + ]); + if (metaKeys.size === 0) { + checks.push( + skillsCheck( + META_PREFIX_ID, + 'When _meta keys are used for skill resources, they SHOULD use the io.modelcontextprotocol.skills/ reverse-domain prefix.', + 'SUCCESS', + { details: { note: 'skill resource exposes no _meta keys' } } + ) + ); + } else { + // Only bare (un-namespaced) keys are flagged: a key already carrying a + // reverse-domain namespace is the intended shape, whichever vendor. + const bareKeys = [...metaKeys].filter((k) => !isNamespacedMetaKey(k)); + checks.push( + skillsCheck( + META_PREFIX_ID, + 'When _meta keys are used for skill resources, they SHOULD use the io.modelcontextprotocol.skills/ reverse-domain prefix.', + bareKeys.length === 0 ? 'SUCCESS' : 'WARNING', + bareKeys.length === 0 + ? { details: { metaKeys: [...metaKeys] } } + : { + errorMessage: `un-namespaced skill _meta keys SHOULD use the ${SKILLS_META_PREFIX} prefix: ${bareKeys.join(', ')}` + } + ) + ); + } + + return checks; + } finally { + await conn.close(); + } + } +} diff --git a/src/scenarios/server/skills/negative.test.ts b/src/scenarios/server/skills/negative.test.ts new file mode 100644 index 0000000..c53298b --- /dev/null +++ b/src/scenarios/server/skills/negative.test.ts @@ -0,0 +1,301 @@ +import { describe, test, expect } from 'vitest'; +import { createHash } from 'crypto'; +import { createServer, type IncomingMessage, type Server } from 'http'; +import type { AddressInfo } from 'net'; +import { testContext } from '../../../connection/testing'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { withRequiredDraftResultFields } from '../../../mock-server'; +import { + takeWireViolations, + formatWireViolation +} from '../../../validation/wire-schema'; +import { SkillsEnumerationScenario } from './enumeration'; +import { SKILLS_EXTENSION_ID } from './helpers'; + +/** + * Negative controls for the SEP-2640 capability checks. + * + * `sep-2640-capability-empty-object` used to report a hardcoded SUCCESS, so it + * could not fail and was checking nothing (PR #330 review). It now asserts the + * observable half of "an empty object indicates support for the extension with + * no optional features": the declared value must be an object at all. + * + * The same change closes a false SKIP. A server declaring the extension as + * `true` used to be folded into "extension not declared" and skipped the whole + * suite, which read as a clean run against a server that is plainly wrong. + * + * A non-object declaration also violates the base `DiscoverResult` schema, so + * the harness's wire validation catches it independently. That is belt and + * braces, not redundancy: the scenario's job is to stop *masking* the problem + * with a SKIP, and the tests below assert both halves. The malformed cases + * emit those violations deliberately and drain them. + */ + +/** A listed entry that is well formed apart from whatever the test is probing. */ +function entryFor(name: string) { + const uri = `skill://${name}/SKILL.md`; + return { + uri, + frontmatter: { name, description: 'A negative-control fixture skill.' }, + resources: [ + { + uri, + digest: `sha256:${'0'.repeat(64)}`, + size: 1 + } + ] + }; +} + +function jsonRpcResult(id: unknown, method: string, result: object): string { + return JSON.stringify({ + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, result) + }); +} + +async function readJsonBody( + req: IncomingMessage +): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record< + string, + unknown + >; +} + +/** + * A minimal SEP-2575 stateless server whose only interesting property is the + * value it declares the skills extension with. Everything else answers just + * enough for the scenario to reach its capability checks. + */ +function startServerDeclaring( + declared: unknown, + skills: object[] = [], + files: Record = {} +): Promise<{ url: string; server: Server }> { + const server = createServer(async (req, res) => { + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + const body = await readJsonBody(req); + const method = body.method as string; + const id = body.id; + const send = (result: object) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(jsonRpcResult(id, method, result)); + }; + + if (method === 'server/discover') { + const extensions = + declared === undefined ? {} : { [SKILLS_EXTENSION_ID]: declared }; + send({ + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { resources: {}, extensions }, + serverInfo: { name: 'skills-negative', version: '1.0.0' } + }); + return; + } + if (method === 'skills/list') { + send({ skills }); + return; + } + if (method === 'resources/read') { + const uri = (body.params as { uri?: string } | undefined)?.uri; + if (uri !== undefined && uri in files) { + send({ + contents: [{ uri, mimeType: 'text/markdown', text: files[uri] }] + }); + return; + } + } + if (method === 'resources/list') { + send({ resources: [] }); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` } + }) + ); + }); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, () => { + const addr = server.address() as AddressInfo; + resolve({ url: `http://localhost:${addr.port}/mcp`, server }); + }); + }); +} + +async function runAgainst( + declared: unknown, + skills: object[] = [], + files: Record = {} +) { + const { url, server } = await startServerDeclaring(declared, skills, files); + try { + const scenario = new SkillsEnumerationScenario(); + const checks = await scenario.run(testContext(url, DRAFT_PROTOCOL_VERSION)); + return { + checks: new Map(checks.map((c) => [c.id, c])), + // Drained here so an intentionally malformed declaration does not trip + // the global vitest hook; the tests assert on the drained result. + violations: takeWireViolations().violations + }; + } finally { + await new Promise((r) => server.close(() => r())); + } +} + +async function checksFor( + declared: unknown, + skills: object[] = [], + files: Record = {} +) { + const { checks, violations } = await runAgainst(declared, skills, files); + expect(violations, 'unexpected wire-schema violations').toEqual([]); + return checks; +} + +const EMPTY_OBJECT_ID = 'sep-2640-capability-empty-object'; +const INLINE_ID = 'sep-2640-capability-declaration-inline'; + +describe('SEP-2640 capability declaration', () => { + test('an empty object is a valid declaration', async () => { + const checks = await checksFor({}); + expect(checks.get(EMPTY_OBJECT_ID)?.status).toBe('SUCCESS'); + expect(checks.get(INLINE_ID)?.status).toBe('SUCCESS'); + }); + + test('a non-empty settings object is a valid declaration', async () => { + const checks = await checksFor({ directoryRead: true }); + expect(checks.get(EMPTY_OBJECT_ID)?.status).toBe('SUCCESS'); + expect(checks.get(INLINE_ID)?.status).toBe('SUCCESS'); + }); + + test.each([ + ['true', true], + ['a string', 'yes'], + ['an array', []], + ['null', null] + ])( + 'declaring the extension as %s is a FAILURE, not a SKIP', + async (_label, value) => { + const { checks, violations } = await runAgainst(value); + expect(checks.get(EMPTY_OBJECT_ID)?.status).toBe('FAILURE'); + // No settings object means no envelope question to answer. + expect(checks.get(INLINE_ID)?.status).toBe('SKIPPED'); + // The base DiscoverResult schema rejects it too; both must fire. + expect( + violations.some((v) => + /extensions.*must be object/.test(formatWireViolation(v)) + ), + 'expected a DiscoverResult wire-schema violation' + ).toBe(true); + } + ); + + test('an envelope instead of inline settings is a FAILURE', async () => { + const checks = await checksFor({ config: { directoryRead: true } }); + expect(checks.get(EMPTY_OBJECT_ID)?.status).toBe('SUCCESS'); + expect(checks.get(INLINE_ID)?.status).toBe('FAILURE'); + }); + + test('an undeclared extension skips every check', async () => { + const checks = await checksFor(undefined); + expect(checks.get(EMPTY_OBJECT_ID)?.status).toBe('SKIPPED'); + expect(checks.get(INLINE_ID)?.status).toBe('SKIPPED'); + expect([...checks.values()].every((c) => c.status === 'SKIPPED')).toBe( + true + ); + }); +}); + +/** + * Valid and invalid `name` examples reproduced from the Agent Skills + * specification, which SEP-2640 defers to for naming + * (https://agentskills.io/specification#name-field). The consecutive-hyphen + * rule is the one the first pass of this check missed. + */ +const NAMING_ID = 'sep-2640-name-naming-rules'; + +describe('SEP-2640 skill naming rules', () => { + test.each(['pdf-processing', 'data-analysis', 'code-review', 'a', 'a1'])( + 'accepts %s', + async (name) => { + const checks = await checksFor({}, [entryFor(name)]); + expect(checks.get(NAMING_ID)?.status).toBe('SUCCESS'); + } + ); + + test.each([ + ['consecutive hyphens', 'pdf--processing'], + ['a leading hyphen', '-pdf'], + ['a trailing hyphen', 'pdf-'], + ['65 characters', 'a'.repeat(65)] + ])('rejects %s', async (_label, name) => { + const checks = await checksFor({}, [entryFor(name)]); + expect(checks.get(NAMING_ID)?.status).toBe('FAILURE'); + }); +}); + +/** + * `entry-frontmatter-identical` compares content, not serialisation. A server + * whose JSON encoder orders map keys differently from the YAML source (Go's + * encoding/json sorts them) is conformant and must pass. + */ +const IDENTICAL_ID = 'sep-2640-entry-frontmatter-identical'; + +function skillWithFile( + yamlMetadata: string, + entryMetadata: Record +) { + const uri = 'skill://demo/SKILL.md'; + const text = `---\nname: demo\ndescription: Demo skill\nmetadata:\n${yamlMetadata}---\n\nBody.\n`; + const entry = { + uri, + frontmatter: { + name: 'demo', + description: 'Demo skill', + metadata: entryMetadata + }, + resources: [ + { + uri, + digest: `sha256:${createHash('sha256').update(text).digest('hex')}`, + size: Buffer.byteLength(text) + } + ] + }; + return { entry, files: { [uri]: text } }; +} + +describe('SEP-2640 frontmatter identity', () => { + test('nested keys serialised in a different order are identical', async () => { + const { entry, files } = skillWithFile(' version: "1.0"\n author: me\n', { + author: 'me', + version: '1.0' + }); + const checks = await checksFor({}, [entry], files); + expect(checks.get(IDENTICAL_ID)?.status).toBe('SUCCESS'); + }); + + test('a differing nested value is a FAILURE', async () => { + const { entry, files } = skillWithFile(' version: "1.0"\n author: me\n', { + author: 'someone-else', + version: '1.0' + }); + const checks = await checksFor({}, [entry], files); + expect(checks.get(IDENTICAL_ID)?.status).toBe('FAILURE'); + }); +}); diff --git a/src/scenarios/server/sse-multiple-streams.test.ts b/src/scenarios/server/sse-multiple-streams.test.ts new file mode 100644 index 0000000..967f5d8 --- /dev/null +++ b/src/scenarios/server/sse-multiple-streams.test.ts @@ -0,0 +1,183 @@ +import { describe, test, expect } from 'vitest'; +import http from 'http'; +import { testContext } from '../../connection/testing'; +import { ServerSSEMultipleStreamsScenario } from './sse-multiple-streams'; +import type { ConformanceCheck } from '../../types'; + +/** + * Regression coverage for issue #412: the stateful scenario must send the + * protocol version negotiated during initialize on its raw follow-up POSTs, + * not a hard-coded historical revision. The fixture server below stores the + * version it returned from initialize and rejects (400) any later request + * whose MCP-Protocol-Version header does not match it. + */ + +const SESSION_ID = 'test-session-412'; + +interface VersionEnforcingServer { + url: string; + negotiatedVersion: () => string | undefined; + close: () => Promise; +} + +async function startVersionEnforcingServer(): Promise { + let negotiated: string | undefined; + + const server = http.createServer((req, res) => { + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let body: { + id?: number | string; + method?: string; + params?: { protocolVersion?: string }; + } = {}; + try { + body = JSON.parse(raw); + } catch { + // Treat unparseable bodies as empty requests. + } + const respond = (status: number, payload?: object) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(payload ? JSON.stringify(payload) : undefined); + }; + + if (body.method === 'initialize') { + negotiated = body.params?.protocolVersion; + res.setHeader('mcp-session-id', SESSION_ID); + respond(200, { + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: negotiated, + capabilities: {}, + serverInfo: { name: 'version-enforcing-server', version: '1.0.0' } + } + }); + return; + } + + if (req.headers['mcp-protocol-version'] !== negotiated) { + respond(400, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32000, message: 'Protocol version mismatch' } + }); + return; + } + + if (body.method === 'notifications/initialized') { + respond(202); + return; + } + if (body.method === 'tools/list') { + respond(200, { jsonrpc: '2.0', id: body.id, result: { tools: [] } }); + return; + } + respond(404, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32601, message: 'Not found' } + }); + }); + }); + + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://localhost:${port}/mcp`, + negotiatedVersion: () => negotiated, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + }; +} + +const findAll = (checks: ConformanceCheck[], id: string) => + checks.filter((c) => c.id === id); + +describe('server-sse-multiple-streams — negotiated protocol version', () => { + test('passes against a server that rejects non-negotiated versions', async () => { + const srv = await startVersionEnforcingServer(); + + const scenario = new ServerSSEMultipleStreamsScenario(); + let checks: ConformanceCheck[]; + try { + checks = await scenario.run(testContext(srv.url)); + } finally { + await srv.close(); + } + + // The scenario negotiated a current version, not the old hard-coded pin. + expect(srv.negotiatedVersion()).toBeDefined(); + expect(srv.negotiatedVersion()).not.toBe('2025-03-26'); + + const accepted = findAll(checks, 'server-accepts-multiple-post-streams')[0]; + expect(accepted?.status).toBe('SUCCESS'); + expect(accepted?.details).toMatchObject({ + numStreamsAttempted: 3, + numStreamsAccepted: 3 + }); + expect(checks.every((c) => c.status !== 'FAILURE')).toBe(true); + }); + + test('fixture server rejects a supported but non-negotiated version', async () => { + const srv = await startVersionEnforcingServer(); + + try { + const init = await fetch(srv.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' } + } + }) + }); + expect(init.status).toBe(200); + + const listWith = (version: string) => + fetch(srv.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': SESSION_ID, + 'mcp-protocol-version': version + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {} + }) + }); + + // The old hard-coded pin is a valid MCP version but not the one + // negotiated above, so this fixture must reject it. + const rejected = await listWith('2025-03-26'); + expect(rejected.status).toBe(400); + + const acceptedRes = await listWith('2025-11-25'); + expect(acceptedRes.status).toBe(200); + } finally { + await srv.close(); + } + }); +}); diff --git a/src/scenarios/server/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index ea025de..91bd24c 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -9,66 +9,111 @@ * Multiple concurrent streams are achieved via POST requests, each getting their own stream. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; +import { buildStandardHeaders, type RunContext } from '../../connection'; import { EventSourceParserStream } from 'eventsource-parser/stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { terminateSessionBestEffort } from '../../connection/sdk-client'; export class ServerSSEMultipleStreamsScenario implements ClientScenario { name = 'server-sse-multiple-streams'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Test server supports multiple concurrent POST SSE streams (SEP-1699)'; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; + // Concurrent POSTs (each answered with JSON or its own SSE stream) are + // core transport behavior in every spec version. Only the request + // scaffolding differs: the stateful lifecycle needs a session id from an + // initialize handshake, the stateless lifecycle carries _meta + the + // MCP-Protocol-Version header on each request instead. + const stateless = specVersion === DRAFT_PROTOCOL_VERSION; + let sessionId: string | undefined; + let negotiatedProtocolVersion: string | undefined; let client: Client | undefined; let transport: StreamableHTTPClientTransport | undefined; try { - // Step 1: Initialize session with the server - client = new Client( - { - name: 'conformance-test-client', - version: '1.0.0' - }, - { - capabilities: { - sampling: {}, - elicitation: {} - } - } - ); - - transport = new StreamableHTTPClientTransport(new URL(serverUrl)); - await client.connect(transport); - - // Extract session ID from transport - sessionId = (transport as unknown as { sessionId?: string }).sessionId; - - if (!sessionId) { - checks.push({ - id: 'server-sse-multiple-streams-session', - name: 'ServerSSEMultipleStreamsSession', - description: 'Server provides session ID for multiple streams test', - status: 'WARNING', - timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'SEP-1699', - url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + if (!stateless) { + // Step 1 (stateful): Initialize session with the server + client = new Client( + { + name: 'conformance-test-client', + version: '1.0.0' + }, + { + capabilities: { + sampling: {}, + elicitation: {} } - ], - details: { - message: - 'Server did not provide session ID - multiple streams test may not work correctly' } - }); - return checks; + ); + + transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + + // Extract session ID and negotiated protocol version from transport + sessionId = (transport as unknown as { sessionId?: string }).sessionId; + negotiatedProtocolVersion = transport.protocolVersion; + + if (!sessionId) { + checks.push({ + id: 'server-sse-multiple-streams-session', + name: 'ServerSSEMultipleStreamsSession', + description: 'Server provides session ID for multiple streams test', + status: 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + message: + 'Server did not provide session ID - multiple streams test may not work correctly' + } + }); + return checks; + } } + // Stateless requests carry the full SEP-2243 header set (Mcp-Method, + // MCP-Protocol-Version, ...) so a strictly-conformant server doesn't + // reject them before the multi-stream behaviour under test is exercised. + const requestHeaders: Record = stateless + ? buildStandardHeaders('tools/list', undefined, { + headers: { Accept: 'text/event-stream, application/json' }, + specVersion + }) + : { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId!, + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion + }; + const requestParams = stateless + ? { + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + : {}; + // Step 2: Open multiple POST SSE streams concurrently // Each POST request gets its own stream with unique streamId // Spec says: "The client MAY remain connected to multiple SSE streams simultaneously" @@ -80,17 +125,12 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { for (let i = 0; i < numStreams; i++) { const promise = fetch(serverUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream, application/json', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - }, + headers: requestHeaders, body: JSON.stringify({ jsonrpc: '2.0', id: 1000 + i, // Different request IDs for each stream method: 'tools/list', - params: {} + params: requestParams }) }); postPromises.push(promise); @@ -254,7 +294,11 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { ] }); } finally { - // Clean up + // Clean up: terminate the session before closing the client so the + // server is left hermetic. + if (transport) { + await terminateSessionBestEffort(transport, serverUrl); + } if (client) { try { await client.close(); diff --git a/src/scenarios/server/sse-polling.test.ts b/src/scenarios/server/sse-polling.test.ts new file mode 100644 index 0000000..e09cd9a --- /dev/null +++ b/src/scenarios/server/sse-polling.test.ts @@ -0,0 +1,235 @@ +import { describe, test, expect } from 'vitest'; +import http from 'http'; +import { testContext } from '../../connection/testing'; +import { ServerSSEPollingScenario } from './sse-polling'; +import { LATEST_SPEC_VERSION, type ConformanceCheck } from '../../types'; + +// Regression coverage for the #412 bug class in sse-polling: raw follow-up +// requests must carry the initialize-negotiated protocol version and session +// id, enforced by a fixture that down-negotiates and 400s mismatched requests. + +const SESSION_ID = 'test-session-sse-polling'; +const DOWN_NEGOTIATED_VERSION = '2025-06-18'; + +interface SeenRequest { + request: string; + version: string | undefined; + hasLastEventId?: boolean; +} + +interface VersionEnforcingServer { + url: string; + seenRequests: () => SeenRequest[]; + close: () => Promise; +} + +// Down-negotiates every initialize to DOWN_NEGOTIATED_VERSION so a scenario +// that hard-codes the latest version cannot pass, records each follow-up's +// version header, and 400s any version or session mismatch. +async function startVersionEnforcingServer(): Promise { + let negotiated: string | undefined; + const seen: SeenRequest[] = []; + + const server = http.createServer((req, res) => { + const rawVersion = req.headers['mcp-protocol-version']; + const version = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion; + const sessionOk = req.headers['mcp-session-id'] === SESSION_ID; + const sseHead = { 'Content-Type': 'text/event-stream' }; + const reject = (payload: object) => { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + const mismatchError = { + jsonrpc: '2.0', + id: null, + error: { + code: -32000, + message: `Session or protocol version mismatch: got ${version}, negotiated ${negotiated}` + } + }; + + if (req.method === 'GET') { + seen.push({ + request: 'GET', + version, + hasLastEventId: Boolean(req.headers['last-event-id']) + }); + if (version !== negotiated || !sessionOk) { + reject(mismatchError); + return; + } + if (!req.headers['last-event-id']) { + // Absorbs the SDK's automatic standalone GET after initialized; + // the SDK tolerates 405, and the scenario's reconnect GET differs + // by carrying Last-Event-ID. + res.writeHead(405, { Allow: 'POST' }).end(); + return; + } + res.writeHead(200, sseHead); + res.end( + 'id: evt-2\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[]}}\n\n' + ); + return; + } + + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let body: { + id?: number | string; + method?: string; + params?: { protocolVersion?: string }; + } = {}; + try { + body = JSON.parse(raw); + } catch { + // Treat unparseable bodies as empty requests. + } + const respond = (status: number, payload?: object) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(payload ? JSON.stringify(payload) : undefined); + }; + + if (body.method === 'initialize') { + negotiated = DOWN_NEGOTIATED_VERSION; + res.setHeader('mcp-session-id', SESSION_ID); + respond(200, { + jsonrpc: '2.0', + id: body.id, + result: { + protocolVersion: negotiated, + capabilities: {}, + serverInfo: { name: 'version-enforcing-server', version: '1.0.0' } + } + }); + return; + } + + seen.push({ request: body.method ?? 'unknown', version }); + if (version !== negotiated || !sessionOk) { + reject({ ...mismatchError, id: body.id ?? null }); + return; + } + + if (body.method === 'notifications/initialized') { + respond(202); + return; + } + if (body.method === 'tools/call') { + // Spec-shaped priming event (id + empty data), then close without + // the result, so the scenario must reconnect via GET + Last-Event-ID. + res.writeHead(200, sseHead); + res.end('id: evt-1\ndata: \n\n'); + return; + } + respond(404, { + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32601, message: 'Not found' } + }); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { + url: `http://127.0.0.1:${port}/mcp`, + seenRequests: () => seen, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }) + }; +} + +const findAll = (checks: ConformanceCheck[], id: string) => + checks.filter((c) => c.id === id); + +describe('server-sse-polling — negotiated protocol version', () => { + test('raw POST and GET carry the down-negotiated version', async () => { + const srv = await startVersionEnforcingServer(); + + const scenario = new ServerSSEPollingScenario(); + let checks: ConformanceCheck[]; + try { + checks = await scenario.run(testContext(srv.url, LATEST_SPEC_VERSION)); + } finally { + await srv.close(); + } + + // Both raw sites must have fired and sent the negotiated version, not + // the latest/spec version. + const toolCalls = srv + .seenRequests() + .filter((s) => s.request === 'tools/call'); + const reconnectGets = srv + .seenRequests() + .filter((s) => s.request === 'GET' && s.hasLastEventId); + expect(toolCalls.length).toBeGreaterThanOrEqual(1); + expect(reconnectGets.length).toBeGreaterThanOrEqual(1); + expect( + [...toolCalls, ...reconnectGets].filter( + (s) => s.version !== DOWN_NEGOTIATED_VERSION + ) + ).toEqual([]); + + const resume = findAll(checks, 'server-sse-disconnect-resume')[0]; + expect(resume?.status).toBe('SUCCESS'); + expect(checks.filter((c) => c.status === 'FAILURE')).toEqual([]); + }); + + test('fixture rejects a supported but non-negotiated version', async () => { + const srv = await startVersionEnforcingServer(); + + try { + const init = await fetch(srv.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_SPEC_VERSION, + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' } + } + }) + }); + expect(init.status).toBe(200); + + const callWith = (version: string) => + fetch(srv.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': SESSION_ID, + 'mcp-protocol-version': version + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'test_reconnection', arguments: {} } + }) + }); + + // The latest version is valid MCP but was not negotiated above. + const rejected = await callWith(LATEST_SPEC_VERSION); + expect(rejected.status).toBe(400); + + const accepted = await callWith(DOWN_NEGOTIATED_VERSION); + expect(accepted.status).toBe(200); + await accepted.body?.cancel(); + } finally { + await srv.close(); + } + }); +}); diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 30deee7..280fe79 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -8,10 +8,16 @@ * - Replaying events when client reconnects with Last-Event-ID */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; +import type { RunContext } from '../../connection'; import { EventSourceParserStream } from 'eventsource-parser/stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { terminateSessionBestEffort } from '../../connection/sdk-client'; function createLoggingFetch(checks: ConformanceCheck[]) { return async (url: string, options: RequestInit): Promise => { @@ -67,14 +73,19 @@ function createLoggingFetch(checks: ConformanceCheck[]) { export class ServerSSEPollingScenario implements ClientScenario { name = 'server-sse-polling'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)'; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; let sessionId: string | undefined; + let negotiatedProtocolVersion: string | undefined; let client: Client | undefined; let transport: StreamableHTTPClientTransport | undefined; @@ -96,8 +107,9 @@ export class ServerSSEPollingScenario implements ClientScenario { transport = new StreamableHTTPClientTransport(new URL(serverUrl)); await client.connect(transport); - // Extract session ID from transport (accessing internal state) - sessionId = (transport as unknown as { sessionId?: string }).sessionId; + // Extract session ID and negotiated protocol version from transport + sessionId = transport.sessionId; + negotiatedProtocolVersion = transport.protocolVersion; if (!sessionId) { checks.push({ @@ -129,7 +141,7 @@ export class ServerSSEPollingScenario implements ClientScenario { 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json', ...(sessionId && { 'mcp-session-id': sessionId }), - 'mcp-protocol-version': '2025-03-26' + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion }, body: JSON.stringify({ jsonrpc: '2.0', @@ -408,7 +420,7 @@ export class ServerSSEPollingScenario implements ClientScenario { headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26', + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion, 'last-event-id': lastEventId } }); @@ -587,7 +599,11 @@ export class ServerSSEPollingScenario implements ClientScenario { ] }); } finally { - // Clean up + // Clean up: terminate the session before closing the client so the + // server is left hermetic. + if (transport) { + await terminateSessionBestEffort(transport, serverUrl); + } if (client) { try { await client.close(); diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts new file mode 100644 index 0000000..9bfd0b5 --- /dev/null +++ b/src/scenarios/server/stateless.test.ts @@ -0,0 +1,909 @@ +import { testContext } from '../../connection/testing'; +import { ServerStatelessScenario } from './stateless'; +import { describe, test, expect } from 'vitest'; +import { ConformanceCheck } from '../../types'; + +const findCheck = (checks: ConformanceCheck[], id: string) => + checks.find((c) => c.id === id); + +describe('Stateless Server Scenario Negative Tests', () => { + // Inline network mocking helper + function mockFetchTarget( + handler: (reqBody: any, reqHeaders: Record) => any + ) { + global.fetch = async (_url: any, init: any) => { + const body = JSON.parse(init.body); + const headers = init.headers || {}; + + let responseConfig = await handler(body, headers); + + // GLOBAL SPEC-COMPLIANT FALLBACKS: Provides successful data loops for downstream checks + // if individual tests are focusing exclusively on separate fields (like discovery or meta checks). + if (!responseConfig) { + if (body.method === 'subscriptions/listen') { + responseConfig = { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { + _meta: { + 'io.modelcontextprotocol/subscriptionId': + 'global-valid-sub-id' + } + } + } + ] + }; + } else if (body.method === 'tools/call') { + responseConfig = { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + result: { + content: [{ type: 'text', text: 'Progress chunk details' }] + } + } + ] + }; + } + } + + if (responseConfig?.isStream) { + const streamData = responseConfig.frames.map( + (f: any) => JSON.stringify(f) + '\n' + ); + let frameIndex = 0; + + const mockReader = { + read: async () => { + if (frameIndex >= streamData.length) { + return { value: undefined, done: true }; + } + const chunk = new TextEncoder().encode(streamData[frameIndex++]); + return { value: chunk, done: false }; + }, + // Required by listenToStream's finally block; without it the + // reader teardown throws, the stream helper swallows the error, + // and every stream-based assertion silently runs against zero + // frames — the exact vacuous-pass failure mode this suite exists + // to prevent. + releaseLock: () => {} + }; + + return { + status: responseConfig?.status ?? 200, + body: { + getReader: () => mockReader, + releaseLock: () => {} + } + } as unknown as Response; + } + + return { + status: responseConfig?.status ?? 404, + json: async () => + responseConfig?.body ?? { + jsonrpc: '2.0', + id: body.id, + error: { code: -32601, message: 'Not found' } + } + } as Response; + }; + return 'http://mock-stateless-mcp-server.local'; + } + + // Shared server/discover stanza for the negative-server mocks below. + const discoverResponse = ( + reqBody: any, + capabilities: object, + serverName: string + ) => ({ + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities, + serverInfo: { name: serverName, version: '1.0.0' } + } + } + }); + + test('Fails validation if missing required fields in _meta are allowed to pass', async () => { + // This bad server completely ignores missing params/_meta fields and returns a fake success result + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 'bad-meta-server', version: '1.0.0' } + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + // The test scenario should flag this server as a FAILURE for skipping meta validation + const missingMetaCheck = findCheck( + checks, + 'sep-2575-request-meta-invalid-missing-meta' + ); + const missingVersionCheck = findCheck( + checks, + 'sep-2575-request-meta-invalid-missing-protocol-version' + ); + + expect(missingMetaCheck?.status).toBe('FAILURE'); + expect(missingVersionCheck?.status).toBe('FAILURE'); + }); + + test('Fails (untestable) the capability checks when the diagnostic tool is not listed', async () => { + // This server has no test_missing_capability tool at all: the + // undeclared-capability MUSTs cannot be exercised and must read red, + // not SKIPPED (issue #248). + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return discoverResponse(reqBody, { tools: {} }, 'no-diagnostic-tools'); + } + if (reqBody.method === 'tools/list') { + return { + status: 200, + body: { jsonrpc: '2.0', id: reqBody.id, result: { tools: [] } } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const rejectCheck = findCheck( + checks, + 'sep-2575-server-rejects-undeclared-capability' + ); + const statusCheck = findCheck( + checks, + 'sep-2575-missing-capability-http-400' + ); + + expect(rejectCheck?.status).toBe('FAILURE'); + expect(rejectCheck?.errorMessage).toContain('Not testable:'); + expect(rejectCheck?.errorMessage).toContain('test_missing_capability'); + expect(statusCheck?.status).toBe('FAILURE'); + expect(statusCheck?.errorMessage).toContain('Not testable:'); + }); + + // A server that lists test_missing_capability and answers its probe call + // with the given response; shared by the capability-shape tests below so + // the fixture contract lives in one place. + function capabilityProbeMock( + label: string, + callResponse: { status: number; body: object } + ) { + return mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return discoverResponse(reqBody, { tools: {} }, label); + } + if (reqBody.method === 'tools/list') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { tools: [{ name: 'test_missing_capability' }] } + } + }; + } + if ( + reqBody.method === 'tools/call' && + reqBody.params?.name === 'test_missing_capability' + ) { + return { + status: callResponse.status, + body: { jsonrpc: '2.0', id: reqBody.id, ...callResponse.body } + }; + } + }); + } + + const spec32021 = (requiredCapabilities: unknown) => ({ + error: { + code: -32021, + message: 'MissingRequiredClientCapabilityError', + data: { requiredCapabilities } + } + }); + + test('Fails the capability check when the listed diagnostic tool executes without -32021', async () => { + // This server lists test_missing_capability but happily executes it even + // though the client never declared the sampling capability — a genuine + // violation of the MUST, not an untestable gap. + const mockUrl = capabilityProbeMock('no-enforcement', { + status: 200, + body: { result: { resultType: 'complete', content: [] } } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const rejectCheck = findCheck( + checks, + 'sep-2575-server-rejects-undeclared-capability' + ); + expect(rejectCheck?.status).toBe('FAILURE'); + expect(rejectCheck?.errorMessage).toContain('MUST reject with -32021'); + }); + + test('Passes the capability checks on a spec-shaped -32021: requiredCapabilities is a ClientCapabilities object', async () => { + // The schema's MissingRequiredClientCapabilityError carries + // `data.requiredCapabilities` as a ClientCapabilities OBJECT keyed by the + // missing capability (e.g. `{ "sampling": {} }`), not an array of names. + const mockUrl = capabilityProbeMock('spec-shaped-32021', { + status: 400, + body: spec32021({ sampling: {} }) + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + expect( + findCheck(checks, 'sep-2575-server-rejects-undeclared-capability')?.status + ).toBe('SUCCESS'); + expect( + findCheck(checks, 'sep-2575-missing-capability-http-400')?.status + ).toBe('SUCCESS'); + }); + + test('Fails the capability check when requiredCapabilities is an array of names instead of the schema object', async () => { + const mockUrl = capabilityProbeMock('array-shaped-32021', { + status: 400, + body: spec32021(['sampling']) + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const rejectCheck = findCheck( + checks, + 'sep-2575-server-rejects-undeclared-capability' + ); + expect(rejectCheck?.status).toBe('FAILURE'); + expect(rejectCheck?.errorMessage).toContain('ClientCapabilities object'); + }); + + test('Fails the capability check when the sampling capability value is not an object (e.g. null)', async () => { + // ClientCapabilities values are themselves objects; `{ sampling: null }` + // is schema-invalid and must not be certified. + const mockUrl = capabilityProbeMock('null-valued-32021', { + status: 400, + body: spec32021({ sampling: null }) + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const rejectCheck = findCheck( + checks, + 'sep-2575-server-rejects-undeclared-capability' + ); + expect(rejectCheck?.status).toBe('FAILURE'); + expect(rejectCheck?.errorMessage).toContain('ClientCapabilities object'); + }); + + test('Fails the subscription checks when listChanged is advertised but listen is rejected', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return discoverResponse( + reqBody, + { tools: { listChanged: true } }, + 'claims-subscriptions' + ); + } + if (reqBody.method === 'subscriptions/listen') { + return { + isStream: true, + status: 404, + frames: [ + { + jsonrpc: '2.0', + id: reqBody.id, + error: { code: -32601, message: 'Method not found' } + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const ackCheck = findCheck( + checks, + 'sep-2575-server-sends-subscription-ack' + ); + expect(ackCheck?.status).toBe('FAILURE'); + expect(ackCheck?.errorMessage).toContain('Not testable:'); + expect(ackCheck?.errorMessage).toContain('-32601'); + }); + + test('Skips the subscription checks when no subscription capability is advertised', async () => { + // A server that never claimed listChanged/subscribe legitimately has + // nothing to serve on subscriptions/listen — this is the one case that + // stays SKIPPED. + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return discoverResponse(reqBody, { tools: {} }, 'no-subscriptions'); + } + if (reqBody.method === 'subscriptions/listen') { + return { + isStream: true, + status: 404, + frames: [ + { + jsonrpc: '2.0', + id: reqBody.id, + error: { code: -32601, message: 'Method not found' } + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const ackCheck = findCheck( + checks, + 'sep-2575-server-sends-subscription-ack' + ); + const tagCheck = findCheck(checks, 'sep-2575-server-tags-subscription-id'); + expect(ackCheck?.status).toBe('SKIPPED'); + expect(tagCheck?.status).toBe('SKIPPED'); + }); + + test('Fails (untestable) the subscription checks when discover was never observed', async () => { + // With no server/discover response at all, a -32601 on listen cannot be + // attributed to an intentionally absent capability — the legitimate skip + // requires an observed advertisement. + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'subscriptions/listen') { + return { + isStream: true, + status: 404, + frames: [ + { + jsonrpc: '2.0', + id: reqBody.id, + error: { code: -32601, message: 'Method not found' } + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const ackCheck = findCheck( + checks, + 'sep-2575-server-sends-subscription-ack' + ); + expect(ackCheck?.status).toBe('FAILURE'); + expect(ackCheck?.errorMessage).toContain('Not testable:'); + expect(ackCheck?.errorMessage).toContain( + 'server/discover was not observed' + ); + }); + + test('Fails validation when missing-_meta rejections are returned with HTTP 200', async () => { + // This bad server picks the right JSON-RPC error code but the wrong HTTP + // status: the spec requires 400 Bad Request for malformed requests. + const mockUrl = mockFetchTarget((reqBody) => { + const meta = reqBody.params?._meta; + const missingRequired = + !meta || + !meta['io.modelcontextprotocol/protocolVersion'] || + !meta['io.modelcontextprotocol/clientInfo'] || + !meta['io.modelcontextprotocol/clientCapabilities']; + if (missingRequired) { + return { + status: 200, // Spec Violation: must be HTTP 400 + body: { + jsonrpc: '2.0', + id: reqBody.id, + error: { + code: -32602, + message: 'Invalid params: missing _meta or required fields' + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + // The JSON-RPC code is correct, so the per-field checks pass; the wrong + // HTTP status is caught by the companion status check. + const missingMetaCheck = findCheck( + checks, + 'sep-2575-request-meta-invalid-missing-meta' + ); + const httpStatusCheck = findCheck( + checks, + 'sep-2575-http-server-meta-invalid-400' + ); + + expect(missingMetaCheck?.status).toBe('SUCCESS'); + expect(httpStatusCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation when the unsupported-version error omits data.requested', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + const meta = reqBody.params?._meta; + if (meta?.['io.modelcontextprotocol/protocolVersion'] === 'v999.0.0') { + return { + status: 400, + body: { + jsonrpc: '2.0', + id: reqBody.id, + error: { + code: -32022, + message: 'Unsupported protocol version', + // Spec Violation: data.requested is a required member + data: { supported: ['2026-07-28'] } + } + } + }; + } + if (reqBody.method === 'server/discover') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: {}, + serverInfo: { name: 'no-requested-server', version: '1.0.0' } + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const negotiationCheck = findCheck( + checks, + 'sep-2575-server-unsupported-version-error' + ); + expect(negotiationCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation if removed legacy RPCs do not return HTTP 404 Not Found', async () => { + // This bad server intercepts the removed 'ping' or 'initialize' methods but incorrectly returns HTTP 200 + const mockUrl = mockFetchTarget((reqBody) => { + if ( + [ + 'initialize', + 'ping', + 'logging/setLevel', + 'resources/subscribe', + 'resources/unsubscribe' + ].includes(reqBody.method) + ) { + return { + status: 200, // Spec Violation: Must be HTTP 404 + body: { + jsonrpc: '2.0', + id: reqBody.id, + error: { + code: -32601, + message: 'Method removed but returning HTTP 200' + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const pingRouteCheck = findCheck( + checks, + 'sep-2575-http-server-method-not-found-404-ping' + ); + const initializeRouteCheck = findCheck( + checks, + 'sep-2575-http-server-method-not-found-404-initialize' + ); + + expect(pingRouteCheck?.status).toBe('FAILURE'); + expect(initializeRouteCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation when version negotiation returns mismatched supported versions data', async () => { + // This bad server returns an unexpected array of supported versions during negotiation + const mockUrl = mockFetchTarget((reqBody) => { + const meta = reqBody.params?._meta; + if (meta?.['io.modelcontextprotocol/protocolVersion'] === 'v999.0.0') { + return { + status: 400, + body: { + jsonrpc: '2.0', + id: reqBody.id, + error: { + code: -32602, + message: 'Unsupported version', + data: { supported: ['UNEXPECTED-VERSION-STRING-DRIFT'] } // Spec Violation: Mismatches actual versions + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const negotiationMatchCheck = findCheck( + checks, + 'sep-2575-server-unsupported-version-error' + ); + expect(negotiationMatchCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation when stream does not send subscription acknowledgement or tracking ID', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + if (reqBody.method === 'subscriptions/listen') { + return { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + method: 'notifications/some-unrelated-event', + params: { _meta: {} } + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const ackCheck = findCheck( + checks, + 'sep-2575-server-sends-subscription-ack' + ); + const idCheck = findCheck(checks, 'sep-2575-server-tags-subscription-id'); + + expect(ackCheck?.status).toBe('FAILURE'); + expect(idCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation when tool response stream leaks raw independent JSON-RPC requests', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + if ( + reqBody.method === 'tools/call' && + reqBody.params?.name === 'test_streaming_elicitation' + ) { + return { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + id: 'server-driven-id-999', + method: 'client/some-arbitrary-request', + params: {} + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const independentRequestCheck = findCheck( + checks, + 'sep-2575-http-server-no-independent-requests-on-stream' + ); + expect(independentRequestCheck?.status).toBe('FAILURE'); + }); + + // A spec-correct server that enforces per-request capability declaration: + // test_missing_capability requires sampling and test_streaming_elicitation + // requires elicitation; each is rejected with a spec-shaped -32021 unless + // the request's _meta declares the capability. A rejection reaches + // listenToStream as the parsed JSON error body — a single error frame. + function capabilityEnforcingMock( + streamingCallFrames: (reqBody: any) => any[] + ) { + return mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return discoverResponse(reqBody, { tools: {} }, 'capability-enforcer'); + } + if (reqBody.method === 'tools/list') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + tools: [ + { name: 'test_missing_capability' }, + { name: 'test_streaming_elicitation' } + ] + } + } + }; + } + if (reqBody.method === 'tools/call') { + const declaredCaps = + reqBody.params?._meta?.[ + 'io.modelcontextprotocol/clientCapabilities' + ] ?? {}; + if (reqBody.params?.name === 'test_missing_capability') { + if (!declaredCaps.sampling) { + return { + status: 400, + body: { + jsonrpc: '2.0', + id: reqBody.id, + ...spec32021({ sampling: {} }) + } + }; + } + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { content: [{ type: 'text', text: 'ok' }] } + } + }; + } + if (reqBody.params?.name === 'test_streaming_elicitation') { + if (!declaredCaps.elicitation) { + return { + isStream: true, + status: 400, + frames: [ + { + jsonrpc: '2.0', + id: reqBody.id, + ...spec32021({ elicitation: {} }) + } + ] + }; + } + return { + isStream: true, + status: 200, + frames: streamingCallFrames(reqBody) + }; + } + } + }); + } + + test('Passes both capability checks against a capability-enforcing server: the streaming probe declares the elicitation capability its fixture requires', async () => { + // sep-2575-server-rejects-undeclared-capability REQUIRES the -32021 + // rejection of an undeclared capability; before the streaming probe + // declared elicitation, that same rejection made + // sep-2575-http-server-no-independent-requests-on-stream untestable — + // one scenario demanded and punished the identical server behavior. + const mockUrl = capabilityEnforcingMock((reqBody) => [ + { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'token-abc', total: 100, value: 50 } + }, + { + jsonrpc: '2.0', + id: reqBody.id, + result: { content: [{ type: 'text', text: 'Streaming complete' }] } + } + ]); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + expect( + findCheck(checks, 'sep-2575-server-rejects-undeclared-capability')?.status + ).toBe('SUCCESS'); + expect( + findCheck( + checks, + 'sep-2575-http-server-no-independent-requests-on-stream' + )?.status + ).toBe('SUCCESS'); + }); + + test('Fails (untestable) the stream check when the server rejects the streaming probe despite the declared elicitation capability', async () => { + // Guards the notTestable branch: a server that rejects even a properly + // declared capability leaves nothing streamed to inspect, which must + // stay a red untestable outcome, not a vacuous SUCCESS. + const mockUrl = capabilityEnforcingMock((reqBody) => [ + { jsonrpc: '2.0', id: reqBody.id, ...spec32021({ elicitation: {} }) } + ]); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const streamCheck = findCheck( + checks, + 'sep-2575-http-server-no-independent-requests-on-stream' + ); + expect(streamCheck?.status).toBe('FAILURE'); + expect(streamCheck?.errorMessage).toContain('Not testable:'); + expect(streamCheck?.errorMessage).toContain('-32021'); + }); + + test('Fails validation when logging occurs without an explicit client request logLevel metadata', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + if ( + reqBody.method === 'tools/call' && + reqBody.params?.name === 'test_logging_tool' + ) { + return { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'debug', text: 'Stray log frame' } + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const logWithoutLevelCheck = findCheck( + checks, + 'sep-2575-server-no-log-without-loglevel' + ); + expect(logWithoutLevelCheck?.status).toBe('FAILURE'); + }); + + test('Fails validation when server leaks out-of-filter notifications on a subscription stream', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + // The scenario subscribes ONLY to prompts list-changed notifications + if ( + reqBody.method === 'subscriptions/listen' && + reqBody.params?.notifications?.promptsListChanged === true + ) { + return { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + _meta: { + 'io.modelcontextprotocol/subscriptionId': 'sub-leak-test' + } + }, + // LEAK VIOLATION: Server sends tools/list_changed on a prompt-only stream + { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + _meta: { + 'io.modelcontextprotocol/subscriptionId': 'sub-leak-test' + } + } + ] + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const filterCheck = findCheck( + checks, + 'sep-2575-server-honors-notification-filter' + ); + expect(filterCheck?.status).toBe('FAILURE'); + }); + + test('Warns when server drops tools or prompts list changed notifications despite declaring capabilities', async () => { + const mockUrl = mockFetchTarget((reqBody) => { + // 1. Declare BOTH capabilities so neither check is skipped + if (reqBody.method === 'server/discover') { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { + supportedVersions: ['2026-07-28'], + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true } + }, + serverInfo: { name: 'test-server', version: '1.0' } + } + } + }; + } + + // 2. Allow BOTH mutation triggers to succeed + if ( + reqBody.method === 'tools/call' && + (reqBody.params?.name === 'test_trigger_prompt_change' || + reqBody.params?.name === 'test_trigger_tool_change') + ) { + return { + status: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + result: { content: [{ type: 'text', text: 'mutated' }] } + } + }; + } + + // 3. Provide the streams but withhold the required notifications for BOTH channels + if (reqBody.method === 'subscriptions/listen') { + const filter = reqBody.params?.notifications; + if ( + filter?.promptsListChanged === true || + filter?.toolsListChanged === true + ) { + return { + isStream: true, + status: 200, + frames: [ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + _meta: { + 'io.modelcontextprotocol/subscriptionId': 'sub-drop-test' + } + } + // MISSING VIOLATION: We never send the expected notifications/*/list_changed + ] + }; + } + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(testContext(mockUrl)); + + const promptsCheck = findCheck( + checks, + 'sep-2575-server-sends-prompts-list-changed-on-subscription' + ); + const toolsCheck = findCheck( + checks, + 'sep-2575-server-sends-tools-list-changed-on-subscription' + ); + + // These map to SHOULD requirements, so severity is WARNING not FAILURE. + expect(promptsCheck?.status).toBe('WARNING'); + expect(toolsCheck?.status).toBe('WARNING'); + }); +}); diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts new file mode 100644 index 0000000..618dc0c --- /dev/null +++ b/src/scenarios/server/stateless.ts @@ -0,0 +1,1352 @@ +/** + * Stateless MCP test scenarios for MCP servers (SEP-2575) + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { + buildStandardHeaders, + readSseJsonRpcResponse, + type RunContext +} from '../../connection'; +import { notTestable } from '../untestable'; + +const SPEC_REF = [ + { + id: 'SEP-2575', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575' + } +]; + +export class ServerStatelessScenario implements ClientScenario { + name = 'server-stateless'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = `Test stateless MCP server architecture (SEP-2575). + +**Server Implementation Requirements:** + +**Endpoints**: +- \`server/discover\`: Returns supportedVersions and capabilities; SHOULD identify itself via \`_meta['io.modelcontextprotocol/serverInfo']\` (spec PR #3002). +- \`tools/call\`: Implement structural test tools like \`test_missing_capability\` requiring explicit capabilities in \`_meta\`. + +**Grouped Specification Requirements**: + +1. **Per-Request _meta Validation (4 Checks)** + - Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature and an HTTP status code \`400 Bad Request\`. + - Serves requests whose \`_meta\` omits \`clientInfo\` (a SHOULD since spec PR #3002 — servers MUST NOT require it). +2. **Discovery & Capabilities (3 Checks)** + - Implements \`server/discover\` mapping exact mandatory protocol elements. + - Dynamically checks prompt capability declaration constraints, validates that active RPC handlers match advertised discovery capacities. +3. **Version Negotiation & Headers (3 Checks)** + - Mismatched or unknown protocol versions must return an \`UnsupportedProtocolVersionError\` (HTTP status code \`400 Bad Request\`) carrying precise version tracking arrays. + - Absent or altered protocol version header metadata must trigger a \`-32020 Header Mismatch\` error with an HTTP 400 boundary state. +4. **Client Capability Constraints (2 Checks)** + - Accessing platform capabilities without explicit declaration drops requests with a \`-32021 MissingRequiredClientCapabilityError\` returning an HTTP status code \`400 Bad Request\`. Its \`error.data.requiredCapabilities\` is a \`ClientCapabilities\` object keyed by the missing capability (e.g. \`{ "sampling": {} }\`), not an array of names. +5. **Methods & Routing Mechanics (5 Checks)** + - Removed legacy endpoints (\`initialize\`, \`ping\`, \`logging/setLevel\`, etc.) or generic unknown methods must cleanly yield an HTTP status code \`404 Not Found\` alongside a JSON-RPC \`-32601 Method not found\` payload. All error returns must preserve original request ID mappings. + - Validates response streams contain only \`IncompleteResult\` chunks and never independent top-level JSON-RPC requests, while enforcing that no log messages are emitted when \`_meta.../logLevel\` is omitted. +6. **Subscription Streams & Filtering (3 Checks)** + - Mandates that \`notifications/subscriptions/acknowledged\` is the first message on a \`subscriptions/listen\` stream, and that subsequent notifications carry a matching \`_meta.../subscriptionId\`. + - Verifies strict containment where servers do not dispatch notification types that fall outside the client's explicit requested subscription filter list. +7. **Dynamic List Mutations (2 Checks)** + - Evaluates that list-changed capable servers notify active listen streams with \`promptsListChanged: true\` or \`toolsListChanged: true\` upon live configuration or capability modifications. `; + + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; + const checks: ConformanceCheck[] = []; + const timestamp = new Date().toISOString(); + + // Executes a validation rule and pushes the structural result metadata. + async function runCheck( + id: string, + name: string, + description: string, + fn: () => + | Promise<{ + error?: string; + warning?: boolean; + skipped?: boolean; + details?: any; + } | void> + | ({ + error?: string; + warning?: boolean; + skipped?: boolean; + details?: any; + } | void), + fallbackDetails = {} + ) { + try { + const result = await fn(); + const errorMessage = result?.error; + // SHOULD-level requirements report WARNING rather than FAILURE + // (severity follows the spec keyword). + const status = errorMessage + ? result?.warning + ? 'WARNING' + : 'FAILURE' + : result?.skipped + ? 'SKIPPED' + : 'SUCCESS'; + + checks.push({ + id, + name, + description, + status, + timestamp, + errorMessage: errorMessage || undefined, + specReferences: SPEC_REF, + details: result?.details || fallbackDetails + }); + } catch (e) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp, + errorMessage: String(e), + specReferences: SPEC_REF, + details: fallbackDetails + }); + } + } + + // Every JSON-RPC error response observed anywhere in the scenario counts + // toward the id-echo backfill's "did we see any error at all" condition. + let errorResponsesObserved = 0; + + // Helper to send raw RPC requests via fetch + const sendRpc = async ( + method: string, + params?: any, + headersOverrides?: Record, + id: string | number | null = 1 + ) => { + // The cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, Accept, + // MCP-Protocol-Version) are not what this scenario exercises, so they + // are always sent conformantly; overrides only alter the dimension a + // test case is about (issue #312). + const headers = buildStandardHeaders(method, params, { + headers: headersOverrides, + specVersion + }); + + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + ...(params !== undefined ? { params } : {}) + }); + + const res = await fetch(serverUrl, { method: 'POST', headers, body }); + let data: any = null; + // Servers may answer single requests over text/event-stream; pick the + // JSON-RPC message matching this request id instead of failing to parse + // the stream as JSON. + const contentType = + typeof res.headers?.get === 'function' + ? (res.headers.get('content-type') ?? '') + : ''; + if (contentType.includes('text/event-stream')) { + const { body: matched } = await readSseJsonRpcResponse(res, id); + data = matched ?? null; + } else { + try { + data = await res.json(); + } catch { + // Response might not be JSON + } + } + if (data?.error !== undefined) errorResponsesObserved++; + return { res, data }; + }; + + // Helper to read multi-frame streaming endpoints (like subscriptions/listen). + // `onFirstFrame` runs once after the first frame arrives (i.e. after the + // server has acknowledged the subscription) so callers can trigger + // side effects on a separate connection while this stream is still open. + const listenToStream = async ( + method: string, + params?: any, + maxFrames = 3, + timeoutMs = 1000, + onFirstFrame?: () => Promise + ): Promise => { + const headers = buildStandardHeaders(method, params, { specVersion }); + + const body = JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method, + ...(params !== undefined ? { params } : {}) + }); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body, + signal: controller.signal + }); + + if (!res.body) { + clearTimeout(timeoutId); + return []; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const frames: any[] = []; + let buffer = ''; + let firstFrameCallbackFired = false; + const maybeFireFirstFrameCallback = async () => { + if (firstFrameCallbackFired || frames.length === 0 || !onFirstFrame) + return; + firstFrameCallbackFired = true; + try { + await onFirstFrame(); + } catch { + // The trigger failed; the caller inspects its own captured result. + } + }; + + try { + while (frames.length < maxFrames) { + let value: Uint8Array | undefined; + let done = false; + try { + ({ value, done } = await reader.read()); + } catch { + // The stream was aborted (timeout) or dropped. A compliant + // server holds a subscriptions/listen stream open indefinitely, + // so hitting the timeout is the normal way these reads end — + // return whatever frames arrived before it fired. + break; + } + + if (value) { + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + + for (const line of lines) { + const cleanLine = line.trim(); + if (!cleanLine) continue; + + const jsonText = cleanLine.startsWith('data:') + ? cleanLine.replace(/^data:\s*/, '') + : cleanLine; + try { + frames.push(JSON.parse(jsonText)); + } catch { + // Keep segment buffering + } + } + await maybeFireFirstFrameCallback(); + } + + if (done) { + const finalLine = buffer.trim(); + if (finalLine) { + const jsonText = finalLine.startsWith('data:') + ? finalLine.replace(/^data:\s*/, '') + : finalLine; + try { + frames.push(JSON.parse(jsonText)); + } catch { + // Trailing formatting mismatch + } + } + break; + } + } + } finally { + reader.releaseLock(); + } + + clearTimeout(timeoutId); + return frames; + } catch { + clearTimeout(timeoutId); + return []; + } + }; + + const validMeta = { + 'io.modelcontextprotocol/protocolVersion': specVersion, + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + + // Helper to check JSON-RPC ID matching on error responses + const checkErrorId = (data: any, expectedId: string | number) => { + if (data && data.error) { + if (data.id !== expectedId) { + checks.push({ + id: 'sep-2575-http-server-error-jsonrpc-id', + name: 'HttpServerErrorJsonrpcId', + description: 'All error responses carry the request JSON-RPC id', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Expected error response id ${expectedId}, got ${data.id}`, + specReferences: SPEC_REF + }); + } + } + }; + + // ========================================== + // 1. Per-request _meta Validation (4 Checks) + // ========================================== + const metaValidationTestCases = [ + { + slug: 'missing-meta', + description: + 'Rejects request with missing _meta with -32602 Invalid params', + params: {}, + rpcId: 101 + }, + { + slug: 'missing-protocol-version', + description: + 'Rejects request with _meta missing io.modelcontextprotocol/protocolVersion', + params: { + _meta: { + 'io.modelcontextprotocol/clientInfo': + validMeta['io.modelcontextprotocol/clientInfo'], + 'io.modelcontextprotocol/clientCapabilities': + validMeta['io.modelcontextprotocol/clientCapabilities'] + } + }, + rpcId: 102 + }, + // No 'missing-client-info' case: spec PR #3002 demoted clientInfo to + // SHOULD, so its absence is valid (asserted positively below). + { + slug: 'missing-client-capabilities', + description: + 'Rejects request with _meta missing io.modelcontextprotocol/clientCapabilities', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': + validMeta['io.modelcontextprotocol/protocolVersion'], + 'io.modelcontextprotocol/clientInfo': + validMeta['io.modelcontextprotocol/clientInfo'] + } + }, + rpcId: 104 + } + ]; + for (const testCase of metaValidationTestCases) { + const metaProbe = await sendRpc( + 'server/discover', + testCase.params, + undefined, + testCase.rpcId + ).catch(() => null); + const metaRes: any = metaProbe?.res ?? null; + const metaData: any = metaProbe?.data ?? null; + if (metaData) checkErrorId(metaData, testCase.rpcId); + + await runCheck( + `sep-2575-request-meta-invalid-${testCase.slug}`, + 'RequestMetaInvalid', + testCase.description, + () => { + if (!metaProbe) + return { error: '_meta validation probe failed completely' }; + if (metaData?.error?.code !== -32602) { + return { + error: `Expected error code -32602, got ${metaData?.error?.code}`, + details: { fieldIssue: testCase.slug, response: metaData } + }; + } + return { details: { fieldIssue: testCase.slug, response: metaData } }; + }, + { fieldIssue: testCase.slug } + ); + + // Companion HTTP-status check: a request missing a required _meta field + // is malformed and, on HTTP, the rejection MUST use 400 Bad Request. + await runCheck( + 'sep-2575-http-server-meta-invalid-400', + 'HttpServerMetaInvalid400', + 'Rejections of requests missing required _meta fields use HTTP 400 Bad Request.', + () => { + if (!metaRes) + return { error: '_meta validation probe failed completely' }; + if (metaRes.status !== 400) { + return { + error: `Expected HTTP 400 Bad Request, got status code ${metaRes.status}`, + details: { fieldIssue: testCase.slug, response: metaData } + }; + } + return { details: { fieldIssue: testCase.slug, response: metaData } }; + }, + { fieldIssue: testCase.slug } + ); + } + + // Positive companion (spec PR #3002): clientInfo is a SHOULD — a request + // whose _meta omits it MUST be served, not rejected. + const noClientInfoProbe = await sendRpc( + 'server/discover', + { + _meta: { + 'io.modelcontextprotocol/protocolVersion': + validMeta['io.modelcontextprotocol/protocolVersion'], + 'io.modelcontextprotocol/clientCapabilities': + validMeta['io.modelcontextprotocol/clientCapabilities'] + } + }, + undefined, + 105 + ).catch(() => null); + const noClientInfoData: any = noClientInfoProbe?.data; + await runCheck( + 'sep-2575-request-meta-client-info-optional', + 'RequestMetaClientInfoOptional', + 'Serves requests whose _meta omits io.modelcontextprotocol/clientInfo (clientInfo is a SHOULD).', + () => { + if (!noClientInfoProbe) + return { error: 'clientInfo-less probe failed completely' }; + if (noClientInfoData?.error) { + return { + error: `Expected a result, got error ${noClientInfoData.error.code}: ${noClientInfoData.error.message}`, + details: { response: noClientInfoData } + }; + } + if (noClientInfoProbe.res?.status !== 200) { + return { + error: `Expected HTTP 200, got status code ${noClientInfoProbe.res?.status}`, + details: { response: noClientInfoData } + }; + } + return { details: { response: noClientInfoData } }; + } + ); + + // ========================================== + // 2. Discovery & Capabilities (4 Checks) + // ========================================== + let discoverSupportedVersions: string[] = []; + let discoverCapabilities: any = {}; + let discoverResult: any = null; + let discoverRpcError: any = null; + + try { + const { data } = await sendRpc( + 'server/discover', + { _meta: validMeta }, + undefined, + 201 + ); + discoverResult = data?.result; + + if (Array.isArray(discoverResult?.supportedVersions)) { + discoverSupportedVersions = discoverResult.supportedVersions; + } + if ( + discoverResult?.capabilities && + typeof discoverResult.capabilities === 'object' + ) { + discoverCapabilities = discoverResult.capabilities; + } + } catch (e) { + discoverRpcError = e; + } + + await runCheck( + 'sep-2575-server-implements-discover', + 'ServerImplementsDiscover', + 'Servers MUST implement server/discover.', + () => { + if (discoverRpcError) + return { error: `Discovery failed: ${discoverRpcError.message}` }; + // Mandatory body fields per the final revision (spec PR #3002 removed + // body serverInfo — identity is a SHOULD in the result _meta, checked + // separately below). + if ( + !discoverResult?.supportedVersions || + !discoverResult?.capabilities + ) { + return { + error: 'Missing mandatory fields in discover response setup', + details: { result: discoverResult } + }; + } + return { details: { result: discoverResult } }; + } + ); + + await runCheck( + 'sep-2575-server-identifies-in-result-meta', + 'ServerIdentifiesInResultMeta', + "Servers SHOULD identify themselves via _meta['io.modelcontextprotocol/serverInfo'] on responses (spec PR #3002).", + () => { + if (discoverRpcError) + // Discover itself failed (already a FAILURE on the implements- + // discover check): nothing is known about identity, so the gap + // must not read as an intentional absence. + return { + skipped: true, + details: { + note: `Prerequisite missing: ${discoverRpcError.message}` + } + }; + const metaServerInfo = + discoverResult?._meta?.['io.modelcontextprotocol/serverInfo']; + if (!metaServerInfo?.name || !metaServerInfo?.version) { + const bodyServerInfo = discoverResult?.serverInfo; + return { + // SHOULD-level: WARNING, never FAILURE. + warning: true, + error: bodyServerInfo + ? 'serverInfo found only in the pre-#3002 result body, not in _meta' + : 'No serverInfo in the discover result _meta', + details: { result: discoverResult } + }; + } + return { details: { serverInfo: metaServerInfo } }; + } + ); + + await runCheck( + 'sep-2575-server-declares-prompts-in-discover', + 'ServerDeclaresPromptsInDiscover', + 'Servers that support prompts MUST declare the prompts capability in their DiscoverResult.', + async () => { + if (discoverRpcError) + return { error: `Prerequisite missing: ${discoverRpcError.message}` }; + const { data: promptsData } = await sendRpc( + 'prompts/list', + { _meta: validMeta }, + undefined, + 203 + ); + const methodExists = + promptsData?.result?.prompts || promptsData?.error?.code !== -32601; + + if (methodExists && !discoverCapabilities.prompts) { + return { + error: + 'Server handles prompts but did not declare prompts capability in discover result', + details: { discoverCapabilities } + }; + } + return { details: { discoverCapabilities, response: promptsData } }; + } + ); + + // Fetched once here and reused by the capability-constraint checks below + // to tell a missing diagnostic tool apart from a genuine violation. + const toolsListResponse = await sendRpc( + 'tools/list', + { _meta: validMeta }, + undefined, + 202 + ).catch(() => null); + const listedToolNames: string[] = Array.isArray( + toolsListResponse?.data?.result?.tools + ) + ? toolsListResponse.data.result.tools + .map((t: any) => t?.name) + .filter((n: any) => typeof n === 'string') + : []; + + // Dynamic verification helper to check capability consistency against true handlers + await runCheck( + 'sep-2575-discover-capabilities-match-handlers', + 'DiscoverCapabilitiesMatchHandlers', + 'capabilities matches what the server honors on real RPC calls', + async () => { + if (discoverRpcError) + return { + error: `Discovery runtime check failed: ${discoverRpcError.message}` + }; + if (!toolsListResponse) + return { error: 'tools/list probe network hit failed' }; + const toolsData = toolsListResponse.data; + + if (discoverCapabilities.tools) { + const toolsPassed = Array.isArray(toolsData?.result?.tools); + if (!toolsPassed) + return { + error: 'Advertised tools capability but tools/list call failed', + details: { response: toolsData } + }; + } else { + if (toolsData?.error?.code !== -32601) + return { + error: + 'Did not advertise tools capability but tools/list did not yield -32601', + details: { response: toolsData } + }; + } + return { details: { response: toolsData } }; + } + ); + + // ========================================== + // 3. Version Negotiation & Headers (3 Checks) + // ========================================== + const requestedUnsupportedVersion = 'v999.0.0'; + const unsupportedMeta = { + ...validMeta, + 'io.modelcontextprotocol/protocolVersion': requestedUnsupportedVersion + }; + const response301 = await sendRpc( + 'server/discover', + { _meta: unsupportedMeta }, + { 'MCP-Protocol-Version': requestedUnsupportedVersion }, + 301 + ).catch(() => null); + const res301: any = response301?.res ?? null; + const data301: any = response301?.data ?? null; + if (data301) checkErrorId(data301, 301); + + await runCheck( + 'sep-2575-server-unsupported-version-error', + 'ServerUnsupportedVersionError', + 'If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support; the error data carries the supported versions and echoes the requested version.', + () => { + if (!data301) + return { error: 'Unsupported version invocation failed completely' }; + const errSupportedVersions = data301?.error?.data?.supported; + const hasErrVersions = + Array.isArray(errSupportedVersions) && + errSupportedVersions.length > 0; + + const validMatch = + hasErrVersions && + errSupportedVersions.every((v: string) => + discoverSupportedVersions.includes(v) + ); + if (!validMatch) + return { + error: `Returned supported versions data layout does not correlate to active server metrics: ${JSON.stringify(errSupportedVersions)}` + }; + + // UnsupportedProtocolVersionError data carries both required members: + // `supported` (asserted above) and `requested`, which echoes the + // version the request asked for. + const requestedEcho = data301?.error?.data?.requested; + if (requestedEcho !== requestedUnsupportedVersion) { + return { + error: `error.data.requested must echo the requested version '${requestedUnsupportedVersion}', got ${JSON.stringify(requestedEcho)}`, + details: { response: data301 } + }; + } + return { details: { response: data301 } }; + } + ); + + await runCheck( + 'sep-2575-http-server-unsupported-version-400', + 'HttpServerUnsupportedVersion400', + 'If the server does not implement the requested protocol version, it MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError listing its supported versions.', + () => { + if (!res301) + return { error: 'Network transaction context unavailable' }; + if (res301.status !== 400) + return { + error: `Expected HTTP 400 Bad Request, got status code ${res301.status}` + }; + return { details: { response: data301 } }; + } + ); + + const headerMismatchMeta = { + ...validMeta, + 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' + }; + const responseAbsent = await sendRpc( + 'server/discover', + { _meta: headerMismatchMeta }, + { 'MCP-Protocol-Version': specVersion }, + 302 + ).catch(() => null); + const resAbsent: any = responseAbsent?.res ?? null; + const dataAbsent: any = responseAbsent?.data ?? null; + + await runCheck( + 'sep-2575-http-server-header-mismatch-400', + 'HttpServerHeaderMismatch400', + 'If the values do not match, the server MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error.', + () => { + if (!resAbsent) + return { error: 'Header verification endpoint network hit failed' }; + if (resAbsent.status !== 400 || dataAbsent?.error?.code !== -32020) { + return { + error: `Expected HTTP 400 and JSON-RPC error -32020, got status ${resAbsent.status} with code ${dataAbsent?.error?.code}` + }; + } + return { details: { response: dataAbsent } }; + } + ); + + // ========================================== + // 4. Client Capability Constraints (2 Checks) + // ========================================== + const response401 = await sendRpc( + 'tools/call', + { name: 'test_missing_capability', arguments: {}, _meta: validMeta }, + undefined, + 401 + ).catch(() => null); + const res401: any = response401?.res ?? null; + const data401: any = response401?.data ?? null; + if (data401) checkErrorId(data401, 401); + + // Determine if this server actively enforces client capabilities + const serverRequiresCapability = data401?.error?.code === -32021; + const capabilityToolListed = listedToolNames.includes( + 'test_missing_capability' + ); + + await runCheck( + 'sep-2575-server-rejects-undeclared-capability', + 'ServerRejectsUndeclaredCapability', + 'A server MUST NOT rely on capabilities the client has not declared. If processing a request requires a capability the client did not include in io.modelcontextprotocol/clientCapabilities, the server MUST return a MissingRequiredClientCapabilityError (-32021).', + () => { + if (!res401) + return { + error: + 'Capability checking call sequence timed out or dropped connection' + }; + + if (!serverRequiresCapability) { + // Distinguish a genuine violation from a fixture gap, strongest + // evidence first: a result proves the tool executed (violation + // regardless of whether tools/list mentioned it); a non--32021 + // error from a listed tool is the wrong code; an error from an + // unlisted tool means the requirement could not be exercised. + if (data401?.result !== undefined) { + return { + error: + "Server executed 'test_missing_capability' although the client did not declare the 'sampling' capability — it MUST reject with -32021", + details: { response: data401 } + }; + } + if (data401?.error && capabilityToolListed) { + return { + error: `Expected MissingRequiredClientCapabilityError (-32021), got error code ${data401.error.code}`, + details: { response: data401 } + }; + } + return { + error: notTestable( + "server does not list the diagnostic tool 'test_missing_capability' in tools/list and the probe call did not exercise it (required for the undeclared-capability rejection)" + ), + details: { untestable: true, response: data401 } + }; + } + + // If it DOES return -32021, strictly validate the requirement payload + // structure. Per the schema's `MissingRequiredClientCapabilityError`, + // `error.data.requiredCapabilities` is a `ClientCapabilities` OBJECT + // keyed by the missing capability — not an array of capability names — + // and each capability value is itself an object. + const reqCaps = data401?.error?.data?.requiredCapabilities; + const isPlainObject = (value: unknown): boolean => + typeof value === 'object' && value !== null && !Array.isArray(value); + if (!isPlainObject(reqCaps) || !isPlainObject(reqCaps.sampling)) { + return { + error: `Server responded with error code -32021 but error.data.requiredCapabilities is not a ClientCapabilities object naming 'sampling' (the schema defines it as an object of capability objects, e.g. { "sampling": {} }, not an array)`, + details: { response: data401 } + }; + } + + return { details: { response: data401 } }; + } + ); + + await runCheck( + 'sep-2575-missing-capability-http-400', + 'MissingCapabilityHttp400', + 'On HTTP, the response status MUST be 400 Bad Request [for MissingRequiredClientCapabilityError].', + () => { + if (!res401) + return { + error: 'Network transport layer layer context failed to instantiate' + }; + + if (!serverRequiresCapability) { + // Same root cause as the previous check: either the diagnostic + // tool is missing (untestable) or the server failed to emit the + // -32021 this status requirement attaches to. + return { + error: notTestable( + capabilityToolListed + ? 'server did not return MissingRequiredClientCapabilityError, so its HTTP status could not be validated' + : "server does not list the diagnostic tool 'test_missing_capability' in tools/list, so the -32021 HTTP status could not be validated" + ), + details: { untestable: true, httpStatus: res401.status } + }; + } + + // If it did trigger the capability error, it MUST use HTTP 400 + if (res401.status !== 400) { + return { + error: `Expected HTTP status code 400 Bad Request for an undeclared capability error response, got ${res401.status}`, + details: { response: data401 } + }; + } + + return { details: { response: data401 } }; + } + ); + + // ========================================== + // 5. Methods & Routing Mechanics (5 Checks) + // ========================================== + const expectedSlugs = [ + 'initialize', + 'ping', + 'logging/setLevel', + 'resources/subscribe', + 'resources/unsubscribe' + ]; + for (const slug of expectedSlugs) { + const cleanMethodParam = slug.toLowerCase().replace('/', '-'); + const response500 = await sendRpc( + slug, + { _meta: validMeta }, + undefined, + 500 + ).catch(() => null); + const res500: any = response500?.res ?? null; + const data500: any = response500?.data ?? null; + + await runCheck( + `sep-2575-http-server-method-not-found-404-${cleanMethodParam}`, + `HttpServerMethodNotFound404${slug.replace('/', '')}`, + `If the server does not implement the removed RPC method '${slug}', it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).`, + () => { + if (!res500 || !data500) + return { + error: + 'Removed method validation hit dropped connections unexpectedly' + }; + if (res500.status !== 404 || data500?.error?.code !== -32601) { + return { + error: `Expected HTTP 404 and code -32601 for removed methods, got HTTP ${res500.status} and code ${data500?.error?.code}` + }; + } + return { details: { response: data500 } }; + } + ); + } + + // Explicit generic unknown method fallback test + const response601 = await sendRpc( + 'unknown/method', + { _meta: validMeta }, + undefined, + 601 + ).catch(() => null); + const res601: any = response601?.res ?? null; + const data601: any = response601?.data ?? null; + + await runCheck( + 'sep-2575-http-server-method-not-found-404', + 'HttpServerMethodNotFound404', + 'If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).', + () => { + if (!res601 || !data601) + return { + error: 'Unknown fallback test target returned an invalid layout' + }; + if (res601.status !== 404 || data601?.error?.code !== -32601) { + return { + error: `Expected HTTP 404 and JSON-RPC error code -32601, got HTTP ${res601.status} and code ${data601?.error?.code}` + }; + } + return { details: { response: data601 } }; + } + ); + + await runCheck( + 'sep-2575-http-server-no-independent-requests-on-stream', + 'HttpServerNoIndependentRequestsOnStream', + 'Request stream contains only IncompleteResult, never independent JSON-RPC requests', + async () => { + const frames = await listenToStream( + 'tools/call', + { + name: 'test_streaming_elicitation', + arguments: {}, + // The fixture legitimately needs the elicitation capability, so a + // capability-enforcing server rejects the shared empty-capability + // envelope with -32021 (the exact behavior + // sep-2575-server-rejects-undeclared-capability requires) and the + // stream could never be exercised. Declare it at the probe site + // only — validMeta must stay empty for that sibling check. + _meta: { + ...validMeta, + 'io.modelcontextprotocol/clientCapabilities': { elicitation: {} } + } + }, + 3, + 600 + ); + + if (frames.length === 0) { + return { + error: + 'Failed to receive progressive stream chunk execution frames from tools/call handler endpoint' + }; + } + + // If the call was rejected outright, nothing was streamed and the + // requirement could not be exercised — fail with the cause rather + // than reporting a vacuous SUCCESS or an invisible SKIPPED. + if (frames.every((f) => f?.error !== undefined)) { + return { + error: notTestable( + listedToolNames.includes('test_streaming_elicitation') + ? `the 'test_streaming_elicitation' call was rejected (code ${frames[0]?.error?.code}), so the response stream could not be exercised` + : "server does not list the diagnostic tool 'test_streaming_elicitation' in tools/list, so the response stream could not be exercised" + ), + details: { untestable: true, response: frames[0] } + }; + } + + const hasIndependentRequest = frames.some( + (f) => f.method && !f.method.startsWith('notifications/') + ); + if (hasIndependentRequest) { + return { + error: + 'Server emitted an independent standard request layout inside a response progress execution block stream context', + details: { frames } + }; + } + return { details: { inspectedFramesCount: frames.length } }; + } + ); + + await runCheck( + 'sep-2575-server-no-log-without-loglevel', + 'ServerNoLogWithoutLogLevel', + "No notifications/message for requests that didn't set _meta.../logLevel", + async () => { + const frames = await listenToStream( + 'tools/call', + { name: 'test_logging_tool', arguments: {}, _meta: validMeta }, + 3, + 500 + ); + + if (frames.length === 0) { + return { + error: + 'Logging target endpoint context dropped or failed to yield frame structures' + }; + } + + // If the call was rejected outright, the server never had anything + // to log and the requirement could not be exercised — fail with the + // cause rather than reporting a vacuous SUCCESS or invisible SKIPPED. + if (frames.every((f) => f?.error !== undefined)) { + return { + error: notTestable( + listedToolNames.includes('test_logging_tool') + ? `the 'test_logging_tool' call was rejected (code ${frames[0]?.error?.code}), so the no-log-without-logLevel requirement could not be exercised` + : "server does not list the diagnostic tool 'test_logging_tool' in tools/list, so the no-log-without-logLevel requirement could not be exercised" + ), + details: { untestable: true, response: frames[0] } + }; + } + + const logFrame = frames.find( + (f) => f.method === 'notifications/message' + ); + if (logFrame) { + return { + error: + 'Server dispatched a notifications/message payload chunk even though the client request did not authorize a log level context descriptor', + details: { logFrame } + }; + } + return { details: { framesFound: frames.length } }; + } + ); + + // ========================================== + // 6. Subscription Streams & Filtering (3 Checks) + // ========================================== + // A server that advertises no subscription-delivered capability has + // nothing to serve on subscriptions/listen, so a -32601 there is a + // legitimate feature absence (SKIPPED). A server that DOES advertise + // listChanged/subscribe but rejects the method fails: it claims a + // feature it does not serve. + const advertisesSubscriptions = !!( + discoverCapabilities?.tools?.listChanged || + discoverCapabilities?.prompts?.listChanged || + discoverCapabilities?.resources?.listChanged || + discoverCapabilities?.resources?.subscribe + ); + // The legitimate skip requires an OBSERVED advertisement: when + // server/discover itself failed, nothing is known about the server's + // capabilities and the gap must not read as an intentional absence. + const discoverObserved = !discoverRpcError && discoverResult != null; + const listenRejected = (frames: any[]) => { + if (frames[0]?.error?.code !== -32601) return null; + if (discoverObserved && !advertisesSubscriptions) { + return { + skipped: true, + details: { + note: 'Server advertises no subscription-delivered capability; subscriptions/listen is not applicable.' + } + }; + } + return { + error: notTestable( + advertisesSubscriptions + ? 'server advertises listChanged/subscribe capabilities but answers subscriptions/listen with -32601 (Method not found)' + : 'server/discover was not observed, so the -32601 on subscriptions/listen cannot be attributed to an intentionally absent capability' + ), + details: { untestable: true, response: frames[0] } + }; + }; + + const subscriptionParams = { + _meta: validMeta, + notifications: { toolsListChanged: true } + }; + + // Open a tools-filtered stream and (best-effort) trigger a tool-list + // change once it is acknowledged, so the stream carries at least one + // post-acknowledgment notification for the subscription-id check. + let streamFrames: any[] = []; + try { + streamFrames = await listenToStream( + 'subscriptions/listen', + subscriptionParams, + 2, + 800, + async () => { + await sendRpc('tools/call', { + name: 'test_trigger_tool_change', + arguments: {}, + _meta: validMeta + }); + } + ); + } catch { + // Stream pipeline tracking failed + } + + await runCheck( + 'sep-2575-server-sends-subscription-ack', + 'ServerSendsSubscriptionAck', + 'notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream', + () => { + const rejectedStream = listenRejected(streamFrames); + if (rejectedStream) return rejectedStream; + if (streamFrames.length === 0) { + return { + error: + 'Failed to open or receive frames from the subscriptions/listen stream endpoint' + }; + } + const firstFrame = streamFrames[0]; + if (firstFrame?.method !== 'notifications/subscriptions/acknowledged') { + return { + error: `Expected first frame method to be 'notifications/subscriptions/acknowledged', got '${firstFrame?.method}'`, + details: { firstFrame } + }; + } + return { details: { firstFrame } }; + } + ); + + await runCheck( + 'sep-2575-server-tags-subscription-id', + 'ServerTagsSubscriptionId', + 'Listen-stream notifications carry _meta.../subscriptionId', + () => { + const rejectedStream = listenRejected(streamFrames); + if (rejectedStream) return rejectedStream; + if (streamFrames.length === 0) { + return { + error: + 'Failed to open stream line or tracking frames are missing completely' + }; + } + + // Every notification delivered on the stream (the acknowledgment and + // anything after it) must carry the subscription id in params._meta. + const notificationFrames = streamFrames.filter( + (f) => + typeof f?.method === 'string' && + f.method.startsWith('notifications/') + ); + if (notificationFrames.length === 0) { + return { + error: + 'subscriptions/listen stream carried no notification frames to inspect', + details: { streamFrames } + }; + } + + const untaggedFrames = notificationFrames.filter( + (f) => !f?.params?._meta?.['io.modelcontextprotocol/subscriptionId'] + ); + if (untaggedFrames.length > 0) { + return { + error: `${untaggedFrames.length} of ${notificationFrames.length} listen-stream notification(s) are missing io.modelcontextprotocol/subscriptionId in params._meta`, + details: { untaggedFrames } + }; + } + + const subId = + notificationFrames[0]?.params?._meta?.[ + 'io.modelcontextprotocol/subscriptionId' + ]; + return { + details: { + subscriptionId: subId, + inspectedNotificationCount: notificationFrames.length + } + }; + } + ); + + await runCheck( + 'sep-2575-server-honors-notification-filter', + 'ServerHonorsNotificationFilter', + "Server doesn't send notification types the client didn't request", + async () => { + const narrowParams = { + _meta: validMeta, + notifications: { promptsListChanged: true } + }; + + // Open a stream filtered to prompts only, then mutate the *tool* list + // on a separate connection once the subscription is acknowledged. A + // compliant server must not deliver the resulting tools notification + // to this stream. + const narrowFrames = await listenToStream( + 'subscriptions/listen', + narrowParams, + 3, + 1500, + async () => { + await sendRpc('tools/call', { + name: 'test_trigger_tool_change', + arguments: {}, + _meta: validMeta + }); + } + ); + + const rejectedNarrow = listenRejected(narrowFrames); + if (rejectedNarrow) return rejectedNarrow; + if (narrowFrames.length === 0) { + return { + error: + 'Strict subscription filtering line failed to return communication frames' + }; + } + + const leakedToolFrame = narrowFrames.find( + (f) => f?.method === 'notifications/tools/list_changed' + ); + + if (leakedToolFrame) { + return { + error: + 'Server leaked a tools/list-changed notification over a stream explicitly filtered to prompts only', + details: { leakedToolFrame } + }; + } + return { details: { narrowFramesCount: narrowFrames.length } }; + } + ); + + // ========================================== + // 7. Dynamic List Mutations (2 Checks) + // ========================================== + await runCheck( + 'sep-2575-server-sends-prompts-list-changed-on-subscription', + 'ServerSendsPromptsListChangedOnSubscription', + 'List-changed-capable servers notify listen streams with promptsListChanged: true (SHOULD)', + async () => { + // Automatically pass/skip if the server didn't declare this capability during discovery + if (!discoverCapabilities?.prompts?.listChanged) { + return { + skipped: true, + details: { + note: 'Server did not declare prompts.listChanged capability in server/discover' + } + }; + } + + const promptsParams = { + _meta: validMeta, + notifications: { promptsListChanged: true } + }; + + // Open the subscription first; mutate the prompt list on a separate + // connection once it is acknowledged. A compliant server only + // notifies streams that are open at the time of the change. + let trigger: any = null; + const frames = await listenToStream( + 'subscriptions/listen', + promptsParams, + 2, + 1500, + async () => { + trigger = await sendRpc('tools/call', { + name: 'test_trigger_prompt_change', + arguments: {}, + _meta: validMeta + }); + } + ); + if (trigger?.data?.error?.code === -32601) { + // SHOULD-level requirement, so the untestable report is a WARNING. + return { + warning: true, + error: notTestable( + "server declares prompts.listChanged but does not expose the diagnostic hook 'test_trigger_prompt_change' to mutate the prompt list" + ), + details: { untestable: true } + }; + } + + if (frames.length === 0) { + return { + warning: true, + error: + 'Failed to open or receive frames from the subscriptions/listen stream endpoint' + }; + } + + const changeFrame = frames.find( + (f) => f.method === 'notifications/prompts/list_changed' + ); + if (!changeFrame) { + return { + warning: true, + error: + 'Mutated the prompt list but no notifications/prompts/list_changed arrived on the open subscription stream. This is a SHOULD requirement.' + }; + } + return { details: { changeFrame } }; + } + ); + + await runCheck( + 'sep-2575-server-sends-tools-list-changed-on-subscription', + 'ServerSendsToolsListChangedOnSubscription', + 'List-changed-capable servers notify listen streams with toolsListChanged: true (SHOULD)', + async () => { + // Automatically pass/skip if the server didn't declare this capability during discovery + if (!discoverCapabilities?.tools?.listChanged) { + return { + skipped: true, + details: { + note: 'Server did not declare tools.listChanged capability in server/discover' + } + }; + } + + const toolsParams = { + _meta: validMeta, + notifications: { toolsListChanged: true } + }; + + // Open the subscription first; mutate the tool list on a separate + // connection once it is acknowledged. A compliant server only + // notifies streams that are open at the time of the change. + let trigger: any = null; + const frames = await listenToStream( + 'subscriptions/listen', + toolsParams, + 2, + 1500, + async () => { + trigger = await sendRpc('tools/call', { + name: 'test_trigger_tool_change', + arguments: {}, + _meta: validMeta + }); + } + ); + if (trigger?.data?.error?.code === -32601) { + // SHOULD-level requirement, so the untestable report is a WARNING. + return { + warning: true, + error: notTestable( + "server declares tools.listChanged but does not expose the diagnostic hook 'test_trigger_tool_change' to mutate the tool list" + ), + details: { untestable: true } + }; + } + + if (frames.length === 0) { + return { + warning: true, + error: + 'Failed to open or receive frames from the subscriptions/listen stream endpoint' + }; + } + + const changeFrame = frames.find( + (f) => f.method === 'notifications/tools/list_changed' + ); + if (!changeFrame) { + return { + warning: true, + error: + 'Mutated the tool list but no notifications/tools/list_changed arrived on the open subscription stream. This is a SHOULD requirement.' + }; + } + return { details: { changeFrame } }; + } + ); + + if (!checks.some((c) => c.id === 'sep-2575-http-server-error-jsonrpc-id')) { + // Only report SUCCESS when at least one error response was actually + // observed; a server that never produced an error response (because it + // wrongly accepted every invalid probe) must not collect a vacuous pass + // for the id-echo requirement on top of its other failures. + checks.push({ + id: 'sep-2575-http-server-error-jsonrpc-id', + name: 'HttpServerErrorJsonrpcId', + description: 'All error responses carry the request JSON-RPC id', + status: errorResponsesObserved > 0 ? 'SUCCESS' : 'FAILURE', + timestamp, + errorMessage: + errorResponsesObserved > 0 + ? undefined + : notTestable( + 'no error responses were observed across the probes, so the id-echo requirement could not be validated' + ), + details: + errorResponsesObserved > 0 + ? { errorResponsesObserved } + : { untestable: true, errorResponsesObserved }, + specReferences: SPEC_REF + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/tasks/capability.ts b/src/scenarios/server/tasks/capability.ts new file mode 100644 index 0000000..2a0bcda --- /dev/null +++ b/src/scenarios/server/tasks/capability.ts @@ -0,0 +1,276 @@ +/** + * SEP-2663 Tasks Extension — capability negotiation conformance. + * + * Tests that the server advertises the io.modelcontextprotocol/tasks + * extension correctly, gates the v2 task surface on negotiation, and + * supports SEP-2575 per-request capability overrides. + * + * Required server fixtures: + * - greet — sync-only, returns "Hello, {name}!" + * - slow_compute — task-supporting, sleeps N seconds + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { MISSING_REQUIRED_CLIENT_CAPABILITY } from '../../../spec-types/draft'; +import { SEP_2575_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID } from './helpers'; + +export class TasksCapabilityNegotiationScenario implements ClientScenario { + name = 'tasks-capability-negotiation'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2663 capability negotiation for the tasks extension. + +**Server Implementation Requirements:** + +**Capability advertisement (SEP-2663):** +- The server MUST advertise \`io.modelcontextprotocol/tasks\` under + \`capabilities.extensions\` in its \`server/discover\` response. +- It MUST NOT use a v1-style \`capabilities.tasks\` slot (the v1 surface + is replaced by the extension). + +**Gating without negotiation (SEP-2663):** +- For requests that do NOT carry the \`io.modelcontextprotocol/tasks\` + extension in \`_meta.clientCapabilities\`, the server MUST reject + \`tasks/get\`, \`tasks/update\`, and \`tasks/cancel\` with a + \`MissingRequiredClientCapability\` error. +- A \`tools/call\` from such a session MUST NOT return + \`CreateTaskResult\`. Task-supporting tools fall through to synchronous + execution and return a plain \`ToolResult\` with + \`resultType:"complete"\`. + +**Per-request opt-in (SEP-2575):** +- A session that did not declare the extension at session level can + opt into task creation for a single \`tools/call\` by including the + extension under \`_meta.io.modelcontextprotocol/clientCapabilities.extensions\`. + The server MUST honor the per-request opt-in and produce a + \`CreateTaskResult\` for that call. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`greet\` — sync-only, returns \`Hello, {name}!\`. +- \`slow_compute\` — task-supporting, \`seconds\`-second sleep then a + result.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + // Two parallel sessions: one declares the extension, one does NOT. + let withExt: Connection; + let withoutExt: Connection; + try { + withExt = await ctx.connect({ + capabilities: { + elicitation: {}, + sampling: {}, + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }); + withoutExt = await ctx.connect({ + capabilities: {} + }); + } catch (error) { + checks.push({ + id: 'tasks-capability-bootstrap', + name: 'TasksCapabilityBootstrap', + description: 'Initialize handshakes (with + without extension) succeed', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2663_REF] + }); + return checks; + } + + // Check 1: server advertises extension under capabilities.extensions. + { + const id = 'tasks-extension-advertised'; + const name = 'TasksExtensionAdvertised'; + const description = `Server advertises ${TASKS_EXTENSION_ID} under capabilities.extensions (and not capabilities.tasks)`; + const discovered = await withExt.discover(); + const caps: any = (discovered.capabilities as any) ?? {}; + const errs: string[] = []; + if (caps.tasks) { + errs.push( + 'v1-style capabilities.tasks slot MUST NOT be used; tasks lives under capabilities.extensions' + ); + } + if (!caps.extensions) { + errs.push('capabilities.extensions MUST be advertised'); + } else if (!caps.extensions[TASKS_EXTENSION_ID]) { + errs.push( + `capabilities.extensions["${TASKS_EXTENSION_ID}"] MUST be present` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { + hasExtensions: Boolean(caps.extensions), + hasTasksExtension: Boolean(caps.extensions?.[TASKS_EXTENSION_ID]), + hasV1TasksSlot: Boolean(caps.tasks) + } + }); + } + + // Check 2: tasks/* methods rejected with the + // MissingRequiredClientCapability error when the client did not + // negotiate the tasks extension. Follows the SEP-2575 §"Missing + // Required Capabilities" pattern — same code path as the + // required-task-error scenario and (when implemented) + // subscriptions/listen for tasks. + { + const id = 'sep-2663-tasks-methods-non-declaring'; + const name = 'TasksMethodsGatedWithoutExtension'; + const description = `tasks/get, tasks/update, tasks/cancel return ${MISSING_REQUIRED_CLIENT_CAPABILITY} when the client did not negotiate the tasks extension (SEP-2575 Missing Required Capabilities)`; + const cases: Array<{ method: string; params: any }> = [ + { method: 'tasks/get', params: { taskId: 'gate-test' } }, + { + method: 'tasks/update', + params: { taskId: 'gate-test', inputResponses: {} } + }, + { method: 'tasks/cancel', params: { taskId: 'gate-test' } } + ]; + const errs: string[] = []; + for (const tc of cases) { + try { + await withoutExt.request(tc.method, tc.params); + errs.push(`${tc.method} MUST reject (it returned a result)`); + } catch (e: any) { + if (e.code !== MISSING_REQUIRED_CLIENT_CAPABILITY) { + errs.push( + `${tc.method} MUST return ${MISSING_REQUIRED_CLIENT_CAPABILITY}; got ${e.code ?? ''}` + ); + } + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + } + + // Check 3: tools/call without extension returns sync ToolResult, not task. + { + const id = 'sep-2663-server-rejects-undeclared-client'; + const name = 'TasksToolsCallWithoutExtensionSync'; + const description = + 'tools/call from a session without the extension MUST fall through to sync (no CreateTaskResult, even for task-supporting tools)'; + try { + const result = (await withoutExt.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 0, label: 'capability-no-ext' } + })) as any; + const errs: string[] = []; + if (result.resultType === 'task') { + errs.push( + 'tools/call without extension MUST NOT return resultType:"task"' + ); + } + if (result.taskId) { + errs.push( + `tools/call without extension MUST NOT carry top-level taskId; got ${result.taskId}` + ); + } + if (result.task) { + errs.push( + 'tools/call without extension MUST NOT carry the v1-style nested `task` envelope' + ); + } + if (!result.content) { + errs.push( + 'tools/call without extension MUST return sync ToolResult with content[]' + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { + resultType: result.resultType, + hasTaskId: Boolean(result.taskId) + } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 4: SEP-2575 per-request _meta opt-in produces CreateTaskResult. + { + const id = 'tasks-per-request-meta-opt-in'; + const name = 'TasksPerRequestMetaOptIn'; + const description = + 'tools/call with extension declared in _meta.io.modelcontextprotocol/clientCapabilities produces a CreateTaskResult even when the session did not negotiate the extension'; + try { + const result = (await withoutExt.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 1, label: 'capability-meta-opt' }, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { + extensions: { [TASKS_EXTENSION_ID]: {} } + } + } + })) as any; + const errs: string[] = []; + if (result.resultType !== 'task') { + errs.push( + `expected resultType:"task" via per-request opt-in; got ${JSON.stringify(result.resultType)}` + ); + } + if (!result.taskId) { + errs.push( + 'per-request opt-in MUST produce a CreateTaskResult with top-level taskId' + ); + } + if (result.task) { + errs.push( + 'CreateTaskResult MUST be flat (no nested `task` wrapper) even on per-request opt-in path' + ); + } + // Best-effort cleanup: cancel the task so we don't leak a 1s + // background goroutine on the server. + if (result.taskId) { + try { + await withExt.request('tasks/cancel', { taskId: result.taskId }); + } catch { + /* swallow — cleanup best-effort */ + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2575_REF, SEP_2663_REF], + details: { + resultType: result.resultType, + taskId: result.taskId + } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2575_REF])); + } + } + + await withExt.close().catch(() => {}); + await withoutExt.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tasks/composition.ts b/src/scenarios/server/tasks/composition.ts new file mode 100644 index 0000000..23daebe --- /dev/null +++ b/src/scenarios/server/tasks/composition.ts @@ -0,0 +1,179 @@ +/** + * SEP-2663 + SEP-2322 — MRTR → Tasks composition. + * + * Tests the composition path made normative by SEP-2663 commit 451f5e1: + * a tool gathers input via the SEP-2322 MRTR loop on `tools/call`, then + * the handler escalates to async on the final round and the server + * returns a `CreateTaskResult`. The inlined result of the eventual task + * MUST reflect the answer gathered during the MRTR phase, so an + * implementation that wires MRTR and tasks as independent surfaces + * fails this end-to-end check. + * + * The SEP-2322 ephemeral-MRTR contract (round 1 InputRequiredResult → + * round 2 ToolResult) is covered exhaustively by the `input-required-*` + * scenarios; only the composition with `CreateTaskResult` on the final + * round is SEP-2663-specific, which is why this is the only check in + * the tasks suite that drives the MRTR loop end-to-end. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { SEP_2322_REF, SEP_2663_REF, failureCheck } from './mrtr-helpers'; +import { + isInputRequiredResult, + mockElicitResponse +} from '../input-required-result-helpers'; +import { TASKS_EXTENSION_ID, waitForTerminal } from './helpers'; + +const MRTR_INPUT_REQUIRED_RESULT_TYPE = 'input_required'; + +export class TasksMrtrCompositionScenario implements ClientScenario { + name = 'tasks-mrtr-composition'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2663 MRTR → Tasks composition (SEP-2663 commit 451f5e1). + +**Server Implementation Requirements:** + +A tool that gathers input via the SEP-2322 MRTR loop and then escalates +to async on the final round MUST return a \`CreateTaskResult\` on that +final round, NOT a sync \`ToolResult\`. The composition is what makes +the two surfaces interoperate — clients should not need to choose one +or the other up front. + +**Spec separation that MUST stay observable:** + +1. Round 1 (MRTR) carries \`inputRequests\` + (optionally) \`requestState\` + and MUST NOT carry \`taskId\`. +2. Round 2 (CreateTaskResult) carries \`taskId\` + \`status\` and MUST + NOT carry \`requestState\` — SEP-2663 removed it from the v2 wire + shape, so the MRTR phase's \`requestState\` does not leak into the + task envelope and clients don't have to deduplicate across flows. +3. The final task \`result\` (inlined on \`tasks/get\` once terminal) + MUST reflect the answer gathered during the MRTR phase, end-to-end. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`test_tool_with_task\` — registered with \`taskSupport=required\`. + Round 1: returns \`InputRequiredResult\` asking for \`user_name\`. + Round 2: with the elicit response echoed back, escalates to async + and returns \`CreateTaskResult\`. The task's eventual result text MUST + contain the gathered \`user_name\` so the round-trip is observable.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + const id = 'sep-2663-mrtr-synchronous-before-task-creation'; + const name = 'Sep2663MrtrSynchronousBeforeTaskCreation'; + const description = + 'MRTR loop gathers input then escalates to a task on the final round (SEP-2322 + SEP-2663 451f5e1).'; + const specRefs = [SEP_2322_REF, SEP_2663_REF]; + + let conn: Connection | undefined; + try { + // Round 2's CreateTaskResult requires the client to declare + // io.modelcontextprotocol/tasks (SEP-2663) at session bootstrap; + // elicitation is needed for the MRTR round. + conn = await ctx.connect({ + capabilities: { + elicitation: {}, + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }); + + // Round 1: MRTR — no inputResponses. Expect InputRequiredResult. + const r1 = (await conn.request('tools/call', { + name: 'test_tool_with_task', + arguments: {} + })) as any; + const errs: string[] = []; + if (!isInputRequiredResult(r1)) { + errs.push( + `round 1 MUST be InputRequiredResult; got ${JSON.stringify(r1)}` + ); + } + if (r1?.resultType !== MRTR_INPUT_REQUIRED_RESULT_TYPE) { + errs.push( + `round 1 resultType MUST be "${MRTR_INPUT_REQUIRED_RESULT_TYPE}"; got ${JSON.stringify(r1?.resultType)}` + ); + } + if (r1 && 'taskId' in r1) { + errs.push( + 'round 1 (MRTR) MUST NOT carry taskId — task is only minted on the final round' + ); + } + const key = Object.keys(r1?.inputRequests ?? {})[0]; + if (!key) { + errs.push('round 1 missing inputRequests; cannot drive round 2'); + } + + // Round 2: MRTR retry with the elicit response. The handler now + // returns GoAsync, so the server MUST return CreateTaskResult. + let taskId: string | undefined; + let r2: any; + if (key) { + r2 = (await conn.request('tools/call', { + name: 'test_tool_with_task', + arguments: {}, + inputResponses: { + [key]: mockElicitResponse({ name: 'Alice' }) + }, + ...(r1.requestState !== undefined + ? { requestState: r1.requestState } + : {}) + })) as any; + if (r2?.resultType !== 'task') { + errs.push( + `round 2 MUST be CreateTaskResult (resultType:"task"); got ${JSON.stringify(r2?.resultType)}` + ); + } + if (!r2?.taskId) { + errs.push('round 2 CreateTaskResult MUST carry top-level taskId'); + } else { + taskId = r2.taskId; + } + if (r2 && 'requestState' in r2) { + errs.push( + 'round 2 CreateTaskResult MUST NOT carry requestState (SEP-2663 spec separation — MRTR requestState does not leak into the task envelope)' + ); + } + if (r2 && 'inputRequests' in r2) { + errs.push( + 'round 2 CreateTaskResult MUST NOT carry inputRequests (those belong on DetailedTask returned by tasks/get)' + ); + } + } + + // Round 3: poll tasks/get until terminal; assert the inlined result + // reflects the answer gathered in the MRTR phase. + if (taskId) { + const terminal = await waitForTerminal(conn, taskId); + if (terminal.status !== 'completed') { + errs.push( + `final task status MUST be "completed"; got ${JSON.stringify(terminal.status)}` + ); + } + const text = terminal?.result?.content?.[0]?.text ?? ''; + if (!/Alice/.test(text)) { + errs.push( + `final task result MUST reflect the user_name gathered during the MRTR phase (expected "Alice"); got ${JSON.stringify(text)}` + ); + } + } + + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: specRefs + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, specRefs)); + } finally { + await conn?.close().catch(() => {}); + } + + return checks; + } +} diff --git a/src/scenarios/server/tasks/dispatch.ts b/src/scenarios/server/tasks/dispatch.ts new file mode 100644 index 0000000..65382fa --- /dev/null +++ b/src/scenarios/server/tasks/dispatch.ts @@ -0,0 +1,529 @@ +/** + * SEP-2663 Tasks Extension — dispatch + envelope conformance. + * + * Bundles a number of small, related checks under one scenario: + * - Removed v1 methods (tasks/result, tasks/list) reject as -32601. + * - Server-directed task creation works without a client `task` hint + * param; legacy v1 `task` param on tools/call is tolerated and + * ignored on sync tools. + * - Immediate-result shortcut: a fast operation MAY skip task creation + * and return a sync ToolResult. + * - SEP-2322 resultType:"complete" discriminator on every non-task + * response (sync tools/call, tasks/get, tasks/update, tasks/cancel). + * - Strong consistency: tasks/get immediately after CreateTaskResult + * MUST resolve. + * - tasks/get with an unknown taskId MUST return -32602. + * + * Required server fixtures: + * - greet — sync-only + * - slow_compute — task-supporting (seconds:0 = instant) + * - confirm_delete — task-supporting, parks for elicitation + * - failing_job — task-supporting, returns tool error + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { SEP_2322_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { + TASKS_EXTENSION_ID, + validTasksParams, + waitForStatus, + waitForTerminal +} from './helpers'; + +/** + * Diagnostic suffix for negative-path checks. When a server returns + * the wrong JSON-RPC error code, the most common cause (per the + * SEP-2663 review on conformance#262) is that the server validated + * some other dimension of the request first — routing headers, + * `_meta`, params shape — and short-circuited before reaching the + * code path the check is actually probing. The check fixture + * already sends an otherwise-valid envelope via `validTasksParams` + * + raw-session auto-headers, so a wrong code here usually means + * either the server has a different validation order than expected + * or the fixture envelope is still missing something this server + * requires. Mention both possibilities so the failure is debuggable. + */ +const ISOLATION_HINT = + '(if the server is otherwise compliant, verify it does not validate other dimensions — routing headers, _meta, params shape — before method dispatch)'; + +export class TasksDispatchScenario implements ClientScenario { + name = 'tasks-dispatch-and-envelope'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2663 dispatch / envelope rules across the tasks surface. + +**Server Implementation Requirements:** + +**Removed v1 methods (SEP-2663):** +- \`tasks/result\` is removed in v2 — the result is inlined on + \`tasks/get\`. Servers MUST reject the method with JSON-RPC \`-32601\`. +- \`tasks/list\` is removed in v2. Servers MUST reject it with + \`-32601\`. + +**Server-directed task creation (SEP-2663):** +- The client does NOT send a \`task\` hint param. The server alone + decides whether to create a task. A \`tools/call\` against a + task-supporting tool MUST produce \`CreateTaskResult\` even with no + client hint. + +**Legacy \`task\` param tolerated (SEP-2663):** +- A v1 client may still send \`task: { ttl, pollInterval }\` on + \`tools/call\`. The server MUST tolerate it (no error) AND MUST NOT + promote a sync-only tool to a task on its presence. The body + arguments + tool registration are authoritative. + +**Immediate-result shortcut (SEP-2663):** +- A server MAY return a sync \`ToolResult\` for task-supporting tools + when the operation completes fast enough. Either return a + \`CreateTaskResult\` (with \`resultType:"task"\`) or a sync + \`ToolResult\` (with \`resultType:"complete"\`); both are valid. + +**resultType:"complete" on non-task responses (SEP-2322):** +- Every JSON-RPC response on the tools+tasks surface other than a + CreateTaskResult MUST carry \`resultType:"complete"\`. This applies + to: sync \`tools/call\`, \`tasks/get\`, \`tasks/update\` ack, + \`tasks/cancel\` ack. + +**Strong consistency / durable create (SEP-2663):** +- A server MUST NOT return \`CreateTaskResult\` until the task is + durably created — that is, until a \`tasks/get\` for the returned + \`taskId\` would resolve. Issuing \`tasks/get\` immediately after the + CreateTaskResult arrives MUST succeed, not -32602. + +**Unknown taskId on tasks/get (SEP-2663):** +- \`tasks/get\` for a taskId the server doesn't recognize MUST return + JSON-RPC \`-32602\` (InvalidParams). Mirrors the same rule for + \`tasks/cancel\` (clarified upstream in spec commit d963ad0). + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`greet\` — sync-only. +- \`slow_compute\` — task-supporting (\`seconds: 0\` for the immediate + shortcut path). +- \`confirm_delete\` — task-supporting, parks for elicitation. +- \`failing_job\` — task-supporting, returns a tool execution error.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + conn = await ctx.connect({ + capabilities: { + elicitation: {}, + sampling: {}, + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }); + } catch (error) { + checks.push({ + id: 'tasks-dispatch-bootstrap', + name: 'TasksDispatchBootstrap', + description: + 'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2663_REF] + }); + return checks; + } + + // Check 1: tasks/result removed. + // + // Isolation: the request body uses a v1-shaped { taskId } via + // validTasksParams, and the raw-session emits Mcp-Method + + // Mcp-Name routing headers and the standard _meta envelope. The + // ONLY thing the server should object to is the method name. + { + const id = 'sep-2663-tasks-result-removed-method-not-found'; + const name = 'TasksRemovedTasksResult'; + const description = + 'tasks/result is removed in v2 and MUST reject with -32601'; + try { + await conn.request('tasks/result', validTasksParams('tasks/result')); + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'tasks/result returned a result instead of -32601', + specReferences: [SEP_2663_REF] + }); + } catch (e: any) { + const errs: string[] = []; + if (e.code !== -32601) { + errs.push( + `expected -32601; got ${e.code ?? ''} ${ISOLATION_HINT}` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } + } + + // Check 2: tasks/list removed. + // + // Isolation: v1 tasks/list took no required params; the empty + // body is fully valid. Routing headers + _meta come from raw- + // conn. Only the method name itself is wrong. + { + const id = 'tasks-removed-tasks-list'; + const name = 'TasksRemovedTasksList'; + const description = + 'tasks/list is removed in v2 and MUST reject with -32601'; + try { + await conn.request('tasks/list', validTasksParams('tasks/list')); + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'tasks/list returned a result instead of -32601', + specReferences: [SEP_2663_REF] + }); + } catch (e: any) { + const errs: string[] = []; + if (e.code !== -32601) { + errs.push( + `expected -32601; got ${e.code ?? ''} ${ISOLATION_HINT}` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } + } + + // Check 3: server-directed task creation without client hint. + { + const id = 'tasks-server-directed-creation-no-hint'; + const name = 'TasksServerDirectedCreationNoHint'; + const description = + 'tools/call with no client `task` hint param MUST still produce CreateTaskResult for task-supporting tools'; + try { + const result = (await conn.request('tools/call', { + name: 'failing_job', + arguments: {} + })) as any; + const errs: string[] = []; + if (result.resultType !== 'task' || !result.taskId) { + errs.push( + `expected CreateTaskResult; got resultType=${JSON.stringify(result.resultType)}, taskId=${JSON.stringify(result.taskId)}` + ); + } + // Best-effort wait so we don't leak. + if (result.taskId) { + try { + await waitForTerminal(conn, result.taskId); + } catch { + /* swallow */ + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 4: legacy `task` param tolerated + ignored on sync tool. + { + const id = 'sep-2663-legacy-task-param-ignored'; + const name = 'TasksLegacyTaskParamIgnored'; + const description = + 'tools/call with legacy `task` param against a sync tool MUST NOT error and MUST NOT be promoted to a task'; + try { + const result = (await conn.request('tools/call', { + name: 'greet', + arguments: { name: 'legacy-hint' }, + // Legacy v1 hint that the server MUST ignore. + task: { ttl: 60_000, pollInterval: 100 } + })) as any; + const errs: string[] = []; + if (result.resultType === 'task') { + errs.push( + 'legacy `task` param MUST NOT promote a sync tool to a task' + ); + } + if (result.taskId) { + errs.push( + `sync tool with legacy hint MUST NOT carry top-level taskId; got ${result.taskId}` + ); + } + if (!Array.isArray(result.content) || result.content.length === 0) { + errs.push('sync tool MUST still return content[]'); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 5: immediate-result shortcut. Either CreateTaskResult OR + // sync ToolResult is acceptable for an instant operation. + { + const id = 'tasks-immediate-result-shortcut'; + const name = 'TasksImmediateResultShortcut'; + const description = + 'For a fast operation, a task-supporting tool MAY skip task creation and return a sync ToolResult; either path is valid'; + try { + const result = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 0, label: 'instant' } + })) as any; + const errs: string[] = []; + if (result.resultType === 'task') { + if (!result.taskId) { + errs.push( + 'task-path response MUST carry top-level taskId on CreateTaskResult' + ); + } + } else { + // Sync path + if (!Array.isArray(result.content)) { + errs.push( + 'sync-path response MUST carry content[] for the immediate ToolResult' + ); + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { resultType: result.resultType } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 6: resultType:"complete" on every non-task response. + { + const id = 'tasks-result-type-complete-on-non-task-responses'; + const name = 'TasksResultTypeCompleteOnNonTaskResponses'; + const description = + 'Sync tools/call, tasks/get, tasks/update ack, and tasks/cancel ack MUST all carry resultType:"complete"'; + const errs: string[] = []; + try { + // Sync tools/call. + const sync = (await conn.request('tools/call', { + name: 'greet', + arguments: { name: 'rt' } + })) as any; + if (sync.resultType !== 'complete') { + errs.push( + `sync tools/call resultType = ${JSON.stringify(sync.resultType)}, want "complete"` + ); + } + + // tasks/get against a fresh task. + const created = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 0, label: 'rt-get' } + })) as any; + const taskIdForGet = created.taskId; + if (taskIdForGet) { + await waitForTerminal(conn, taskIdForGet); + const got = (await conn.request('tasks/get', { + taskId: taskIdForGet + })) as any; + if (got.resultType !== 'complete') { + errs.push( + `tasks/get resultType = ${JSON.stringify(got.resultType)}, want "complete"` + ); + } + } + + // tasks/cancel ack on a fresh long-running task. + const longLived = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 60, label: 'rt-cancel' } + })) as any; + if (longLived.taskId) { + const cancelAck = (await conn.request('tasks/cancel', { + taskId: longLived.taskId + })) as any; + if (cancelAck.resultType !== 'complete') { + errs.push( + `tasks/cancel ack resultType = ${JSON.stringify(cancelAck.resultType)}, want "complete"` + ); + } + } + + // tasks/update ack on a parked elicitation task. + const elicit = (await conn.request('tools/call', { + name: 'confirm_delete', + arguments: { filename: 'rt.txt' } + })) as any; + const elicitTaskId = elicit.taskId; + if (elicitTaskId) { + await waitForStatus(conn, elicitTaskId, 'input_required', 5_000); + const updateAck = (await conn.request('tasks/update', { + taskId: elicitTaskId, + inputResponses: { 'unknown-key': { ignored: true } } + })) as any; + if (updateAck.resultType !== 'complete') { + errs.push( + `tasks/update ack resultType = ${JSON.stringify(updateAck.resultType)}, want "complete"` + ); + } + try { + await conn.request('tasks/cancel', { taskId: elicitTaskId }); + } catch { + /* swallow */ + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2322_REF, SEP_2663_REF] + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2322_REF])); + } + } + + // Check 7: strong consistency — immediate tasks/get after CreateTaskResult. + { + const id = 'sep-2663-durable-create-strong-consistency'; + const name = 'TasksStrongConsistencyImmediateGet'; + const description = + 'tasks/get issued immediately after CreateTaskResult arrives MUST resolve (server MUST NOT return CreateTaskResult before the task is durably created)'; + try { + const created = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 60, label: 'consistency' } + })) as any; + const taskId = created.taskId; + if (!taskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'slow_compute did not create a task', + specReferences: [SEP_2663_REF] + }); + } else { + // No await/sleep between create and get — codifies the + // strong-consistency ordering. + const got = (await conn.request('tasks/get', { taskId })) as any; + const errs: string[] = []; + if (got.taskId !== taskId) { + errs.push( + `immediate tasks/get MUST resolve the same taskId; got ${got.taskId}` + ); + } + // Cleanup. + try { + await conn.request('tasks/cancel', { taskId }); + } catch { + /* swallow */ + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 8: tasks/get with unknown taskId returns -32602. + // + // Isolation: tasks/get is a supported method; the taskId is the + // single property under test. The unknown taskId appears in both + // body and Mcp-Name header (raw-session mirrors it) so a server + // that validates routing headers first will route to tasks/get + // and reject for "unknown taskId" specifically — not for header + // mismatch. + { + const id = 'sep-2663-tasks-get-invalid-task-id-32602'; + const name = 'TasksGetUnknownTaskIdRejected'; + const description = + 'tasks/get for a taskId the server does not recognize MUST return -32602'; + try { + await conn.request( + 'tasks/get', + validTasksParams('tasks/get', { + taskId: 'tasks-conformance-nonexistent-12345' + }) + ); + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'tasks/get with unknown taskId returned a result', + specReferences: [SEP_2663_REF] + }); + } catch (e: any) { + const errs: string[] = []; + if (e.code !== -32602) { + errs.push( + `expected -32602; got ${e.code ?? ''} ${ISOLATION_HINT}` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } + } + + await conn.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tasks/headers.ts b/src/scenarios/server/tasks/headers.ts new file mode 100644 index 0000000..d523994 --- /dev/null +++ b/src/scenarios/server/tasks/headers.ts @@ -0,0 +1,313 @@ +/** + * SEP-2243 Mcp-Method / Mcp-Name request-header validation, tasks + * surface. + * + * SEP-2243 defines Mcp-Method and Mcp-Name as REQUEST headers (client → + * server) used by HTTP infrastructure (proxies, gateways, observability) + * to route or shape JSON-RPC traffic without parsing the body. The + * server MUST reject requests where the routing headers disagree with + * (or are missing for a name-carrying body) the JSON-RPC envelope, with + * HTTP 400 + JSON-RPC `-32020 HeaderMismatch`. + * + * This scenario exercises the validation on the tasks surface + * specifically — the upstream `http-header-validation` scenario covers + * the general case; here we verify mcpkit's tasks/* methods route + * through the same validator (matched headers → success; mismatched + * header → -32020). + * + * Required server fixtures: + * - greet — sync-only, returns "Hello, {name}!" + * - slow_compute — task-supporting, sleeps N seconds + */ + +import { McpError } from '@modelcontextprotocol/sdk/types.js'; + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { HEADER_MISMATCH } from '../../../spec-types/draft'; +import { SEP_2243_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID } from './helpers'; + +const HEADER_MISMATCH_ERROR_CODE = HEADER_MISMATCH; + +export class TasksRequestHeadersScenario implements ClientScenario { + name = 'tasks-request-headers'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2243 Mcp-Method / Mcp-Name request-header validation, tasks surface. + +**Server Implementation Requirements:** + +SEP-2243 defines two required request headers that mirror body fields +into the HTTP layer for routing intermediaries: + +- \`Mcp-Method: \` — REQUIRED on every JSON-RPC request, + matching the body \`method\`. +- \`Mcp-Name: \` — REQUIRED on requests with a + name-shaped body field. Per SEP-2243 §"Standard Headers" this covers + \`tools/call\` (\`params.name\`), \`resources/read\` (\`params.uri\`), and + \`prompts/get\` (\`params.name\`). SEP-2663 §"Streamable HTTP: Routing + Headers" extends the requirement to tasks-namespace methods: + \`tasks/get\`, \`tasks/update\`, and \`tasks/cancel\` MUST carry + \`Mcp-Name: \` matching \`params.taskId\`. + +Per SEP-2243 §"Server Behavior", servers that process the request body +MUST validate that header values match the body. Per its "Validation +Failure Conditions", both missing required headers and mismatched +values trigger rejection with JSON-RPC error code \`-32020\` +(HeaderMismatch) and HTTP 400. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`greet\` — sync-only, returns \`Hello, {name}!\`. +- \`slow_compute\` — task-supporting, \`seconds\`-second sleep then a + result.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + conn = await ctx.connect({ + capabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } + }); + } catch (error) { + checks.push({ + id: 'tasks-headers-bootstrap', + name: 'TasksHeadersBootstrap', + description: + 'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2243_REF] + }); + return checks; + } + + // Check 1: Mcp-Method on tools/call against a sync tool. + { + const id = 'tasks-headers-tolerate-mcp-method-on-tools-call'; + const name = 'TasksHeadersTolerateMcpMethodOnToolsCall'; + const description = + 'Server tolerates Mcp-Method request header on tools/call (sync tool dispatch unaffected)'; + try { + const result = (await conn.request( + 'tools/call', + { name: 'greet', arguments: { name: 'sep-2243' } }, + { 'Mcp-Method': 'tools/call' } + )) as any; + const errs: string[] = []; + if (result.resultType !== 'complete') { + errs.push( + `sync ToolResult.resultType MUST be "complete" regardless of routing header; got ${JSON.stringify(result.resultType)}` + ); + } + if ( + !Array.isArray(result.content) || + result.content.length === 0 || + result.content[0]?.text !== 'Hello, sep-2243!' + ) { + errs.push( + 'tool result content MUST be unaffected by the Mcp-Method header' + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2243_REF] + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2243_REF])); + } + } + + // Check 2: Mcp-Method + Mcp-Name on tasks/get (drive a task first + // so we have a real taskId to route on). With matched routing + // headers the server MUST dispatch normally; this is the positive + // case complementing the negative-path mismatch checks below. + let routingTaskId: string | undefined; + { + const id = 'sep-2663-routing-headers-accepted-on-tasks-get'; + const name = 'Sep2663RoutingHeadersAcceptedOnTasksGet'; + const description = + 'Server accepts matched Mcp-Method + Mcp-Name request headers on tasks/get and dispatches normally'; + try { + const created = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 60, label: 'headers-tasks-get' } + })) as any; + routingTaskId = created.taskId; + if (!routingTaskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'slow_compute did not create a task', + specReferences: [SEP_2243_REF] + }); + } else { + const got = (await conn.request( + 'tasks/get', + { taskId: routingTaskId }, + { + 'Mcp-Method': 'tasks/get', + 'Mcp-Name': routingTaskId + } + )) as any; + const errs: string[] = []; + if (got.taskId !== routingTaskId) { + errs.push( + `tasks/get MUST resolve body taskId regardless of routing headers; got ${got.taskId}` + ); + } + if (!got.status) { + errs.push( + 'tasks/get MUST still return status when routing headers are set' + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2243_REF] + }); + } + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2243_REF])); + } + } + + // Check 3: Mismatched Mcp-Method header is rejected on the tasks + // surface (SEP-2243 §Server Validation, tasks dispatch path). + { + const id = 'tasks-headers-reject-mismatched-method'; + const name = 'TasksHeadersRejectMismatchedMethod'; + const description = `When Mcp-Method header disagrees with body on a tools/call, server MUST reject with ${HEADER_MISMATCH_ERROR_CODE} HeaderMismatch (SEP-2243 §Server Validation)`; + try { + await conn.request( + 'tools/call', + { name: 'greet', arguments: { name: 'header-mismatch' } }, + { 'Mcp-Method': 'tasks/get' } + ); + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `tools/call with Mcp-Method: tasks/get returned a successful response; SEP-2243 requires ${HEADER_MISMATCH_ERROR_CODE} rejection`, + specReferences: [SEP_2243_REF] + }); + } catch (error) { + const code = + error instanceof McpError ? error.code : (error as any)?.code; + if (code === HEADER_MISMATCH_ERROR_CODE) { + checks.push({ + id, + name, + description, + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SEP_2243_REF] + }); + } else { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `expected ${HEADER_MISMATCH_ERROR_CODE} HeaderMismatch; got ${code ?? errMsg(error)}`, + specReferences: [SEP_2243_REF] + }); + } + } + } + + // Check 4: Mismatched Mcp-Name header on tasks/get is rejected. + // Per SEP-2663 §"Streamable HTTP: Routing Headers" the client MUST + // set Mcp-Name to params.taskId on tasks/get / tasks/update / + // tasks/cancel; per SEP-2243 §"Server Behavior" the server MUST + // reject any header that disagrees with the body. tasks/get is the + // representative probe — the same rule applies to update/cancel by + // extension. Confirmed by SEP-2663 author on conformance#262. + { + const id = 'sep-2663-server-rejects-mismatched-mcp-name-on-tasks-get'; + const name = 'Sep2663ServerRejectsMismatchedMcpNameOnTasksGet'; + const description = `When Mcp-Name header disagrees with params.taskId on tasks/get, server MUST reject with ${HEADER_MISMATCH_ERROR_CODE} HeaderMismatch`; + if (!routingTaskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'routing-task fixture from Check 2 unavailable; cannot drive negative-path probe', + specReferences: [SEP_2243_REF, SEP_2663_REF] + }); + } else { + try { + await conn.request( + 'tasks/get', + { taskId: routingTaskId }, + { 'Mcp-Name': 'sep-2663-wrong-task-id-for-routing-header-mismatch' } + ); + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `tasks/get with mismatched Mcp-Name returned a result; SEP-2243 requires ${HEADER_MISMATCH_ERROR_CODE} rejection`, + specReferences: [SEP_2243_REF, SEP_2663_REF] + }); + } catch (error) { + const code = + error instanceof McpError ? error.code : (error as any)?.code; + if (code === HEADER_MISMATCH_ERROR_CODE) { + checks.push({ + id, + name, + description, + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SEP_2243_REF, SEP_2663_REF] + }); + } else { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `expected ${HEADER_MISMATCH_ERROR_CODE} HeaderMismatch; got ${code ?? errMsg(error)}`, + specReferences: [SEP_2243_REF, SEP_2663_REF] + }); + } + } + } + } + + // Cleanup the long-lived task. + if (routingTaskId) { + try { + await conn.request('tasks/cancel', { taskId: routingTaskId }); + } catch { + /* swallow */ + } + } + + await conn.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tasks/helpers.ts b/src/scenarios/server/tasks/helpers.ts new file mode 100644 index 0000000..b786d9c --- /dev/null +++ b/src/scenarios/server/tasks/helpers.ts @@ -0,0 +1,110 @@ +/** + * Tasks-specific helpers for SEP-2663 server-conformance scenarios. + * + * - `TASKS_EXTENSION_ID`: the extension identifier scenarios assert + * when checking server capabilities. + * - `validTasksParams`: well-formed body shapes for negative-path + * checks that need to isolate a single failure dimension. + * - `waitForTerminal` / `waitForStatus`: tasks/get polling loops. + */ + +import type { Connection } from '../../../connection'; + +export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'; + +/** + * Baseline "otherwise well-formed" params for a tasks-namespace + * method, used by negative-path checks that need to isolate a single + * failure dimension. + * + * When a check asserts a specific JSON-RPC error code (e.g. -32601 + * for a removed method, -32602 for an unknown taskId), the rest of + * the request MUST be valid — otherwise a spec-compliant server can + * reject for a different reason (missing routing headers, missing + * `_meta`, malformed params) and the assertion fires on the wrong + * dimension, producing misleading conformance results. + * + * The Connection already auto-populates SEP-2243 routing headers + * and the SEP-2322 `_meta` envelope based on the method and params, + * so this helper's job is to supply a sensible *body* — a params + * object whose shape would be acceptable on the v1 surface — and + * let the caller mutate exactly the field under test. + * + * Example: + * + * // tasks/result is removed in v2; everything else is valid, so + * // -32601 is the only error that can fire on a compliant server. + * await conn.request('tasks/result', validTasksParams('tasks/result')); + * + * // tasks/get with an unknown taskId — the taskId is the property + * // under test; the rest of the envelope is valid. + * await conn.request( + * 'tasks/get', + * validTasksParams('tasks/get', { taskId: 'nonexistent-12345' }) + * ); + */ +export function validTasksParams( + method: string, + overrides?: Record +): Record { + const probeTaskId = 'tasks-conformance-isolation-probe'; + let base: Record; + switch (method) { + case 'tasks/result': // v1 shape: { taskId } + case 'tasks/get': + case 'tasks/cancel': + base = { taskId: probeTaskId }; + break; + case 'tasks/update': + base = { taskId: probeTaskId, inputResponses: {} }; + break; + case 'tasks/list': // v1 shape: optional { cursor? } + base = {}; + break; + default: + base = {}; + } + return { ...base, ...(overrides ?? {}) }; +} + +/** Poll tasks/get until the task reaches a terminal state. */ +export async function waitForTerminal( + conn: Connection, + taskId: string, + timeoutMs = 10_000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const task = (await conn.request('tasks/get', { taskId })) as any; + if (['completed', 'failed', 'cancelled'].includes(task.status)) { + return task; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error( + `Task ${taskId} did not reach terminal state within ${timeoutMs}ms` + ); +} + +/** Poll tasks/get until a specific status (or any terminal state). */ +export async function waitForStatus( + conn: Connection, + taskId: string, + status: string, + timeoutMs = 10_000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const task = (await conn.request('tasks/get', { taskId })) as any; + if ( + task.status === status || + ['completed', 'failed', 'cancelled'].includes(task.status) + ) { + return task; + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error( + `Task ${taskId} did not reach status ${status} within ${timeoutMs}ms` + ); +} diff --git a/src/scenarios/server/tasks/lifecycle.test.ts b/src/scenarios/server/tasks/lifecycle.test.ts new file mode 100644 index 0000000..8926538 --- /dev/null +++ b/src/scenarios/server/tasks/lifecycle.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { testContext } from '../../../connection/testing'; +import { TasksLifecycleScenario } from './lifecycle'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +import type { ConformanceCheck } from '../../../types'; + +/** + * Pins the untestable-failure policy (issue #248) for the SEP-2663 lifecycle + * scenario: when no task is ever created, the downstream task checks must + * fail with a "Not testable:" cause instead of reporting SKIPPED. + */ + +const realFetch = global.fetch; +afterEach(() => { + global.fetch = realFetch; +}); + +function mockServer() { + global.fetch = (async (_url: any, init: any) => { + const body = JSON.parse(init.body); + let result: any; + if (body.method === 'server/discover') { + result = { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { tools: {} }, + serverInfo: { name: 'taskless-server', version: '1.0.0' } + }; + } else if (body.method === 'tools/list') { + result = { tools: [] }; + } else if (body.method === 'tools/call') { + // Always answers synchronously: never creates a task. + result = { + resultType: 'complete', + content: [{ type: 'text', text: 'sync answer' }] + }; + } else { + return { + status: 404, + headers: { get: () => 'application/json' }, + json: async () => ({ + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32601, message: 'Method not found' } + }), + text: async () => + JSON.stringify({ + jsonrpc: '2.0', + id: body.id ?? null, + error: { code: -32601, message: 'Method not found' } + }) + } as unknown as Response; + } + const payload = { jsonrpc: '2.0', id: body.id, result }; + return { + status: 200, + headers: { get: () => 'application/json' }, + json: async () => payload, + text: async () => JSON.stringify(payload) + } as unknown as Response; + }) as typeof fetch; + return 'http://mock-taskless-server.local'; +} + +describe('tasks-lifecycle — no task created', () => { + test('downstream task checks fail as untestable instead of SKIPPED', async () => { + const mockUrl = mockServer(); + const scenario = new TasksLifecycleScenario(); + const checks: ConformanceCheck[] = await scenario.run( + testContext(mockUrl, DRAFT_PROTOCOL_VERSION) + ); + + const gated = checks.filter((c) => + c.errorMessage?.startsWith('Not testable:') + ); + expect(gated.length).toBeGreaterThan(0); + for (const check of gated) { + expect(check.status).toBe('FAILURE'); + expect(check.details).toMatchObject({ untestable: true }); + } + expect(checks.every((c) => c.status !== 'SKIPPED')).toBe(true); + }); +}); diff --git a/src/scenarios/server/tasks/lifecycle.ts b/src/scenarios/server/tasks/lifecycle.ts new file mode 100644 index 0000000..2214ff1 --- /dev/null +++ b/src/scenarios/server/tasks/lifecycle.ts @@ -0,0 +1,547 @@ +/** + * SEP-2663 Tasks Extension — server lifecycle conformance. + * + * Tests a server that implements the io.modelcontextprotocol/tasks + * extension end-to-end: sync vs async dispatch, DetailedTask shape on + * tasks/get, tool errors vs protocol errors, and cancellation + * semantics. + * + * Required server fixtures (tools/list output must include all): + * - greet — sync-only, returns "Hello, {name}!" + * - slow_compute — task-supporting, sleeps N seconds + * - failing_job — task-supporting, returns a tool error + * - protocol_error_job — task-supporting, panics into a protocol error + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { SEP_2322_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID, waitForTerminal } from './helpers'; +import { untestableCheck } from '../../untestable'; +import { isIso8601 } from './mrtr-helpers'; + +export class TasksLifecycleScenario implements ClientScenario { + name = 'tasks-lifecycle'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2663 Tasks extension lifecycle on the server. + +**Server Implementation Requirements (SEP-2663):** + +The server MUST advertise \`io.modelcontextprotocol/tasks\` under +\`capabilities.extensions\` and gate the task surface on negotiation. + +**Sync dispatch (no task created):** +- A \`tools/call\` against a sync-only tool MUST return a flat + \`ToolResult\` with \`resultType:"complete"\` and a \`content[]\` array. +- It MUST NOT carry \`taskId\` at the top level (that would imply a + CreateTaskResult). + +**Server-directed task creation:** +- For task-supporting tools, the server decides whether to create a task — + the client MUST NOT need to opt in via a request param. +- The response MUST be a \`CreateTaskResult\` — a flat \`Result & Task\` + intersection: \`resultType:"task"\`, plus \`taskId\` / \`status\` / + \`createdAt\` / \`lastUpdatedAt\` / \`ttlMs\` at the top level. + There MUST NOT be a nested \`task\` wrapper key. + +**tasks/get DetailedTask:** +- Working tasks return \`status\` and basic metadata; result/error are + absent. +- Completed tasks MUST inline the original tool result under \`result\` + with \`content[]\`. There is no separate \`tasks/result\` method. + +**Tool errors vs protocol errors (SEP-2663 §error-semantics):** +- A tool that ran but reported an error MUST surface as + \`status:"completed"\` with \`result.isError:true\`. The status + \`"failed"\` is reserved for protocol-level errors. +- A protocol-level error (server crash, internal failure) MUST surface + as \`status:"failed"\` with an inlined \`error\` object (JSON-RPC + error shape: code/message/data) and MUST NOT carry \`result\`. + +**Cancellation:** +- \`tasks/cancel\` MUST return an empty + \`{resultType:"complete"}\` ack — no task envelope (SEP-2322 + discriminator). The cancelled status is observed via the next + \`tasks/get\`. +- \`tasks/cancel\` against a terminal task returns the same empty ack + (idempotent) — the spec reserves \`-32602\` for unknown taskIds only. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`greet\` — sync-only, returns \`Hello, {name}!\`. +- \`slow_compute\` — task-supporting, \`seconds\`-second sleep then a + result. \`seconds: 0\` for the immediate path. MUST settle to + \`cancelled\` (not \`completed\`/\`failed\`) when \`tasks/cancel\` + arrives while running, so the lifecycle cancel check has a + deterministic terminal status. +- \`failing_job\` — task-supporting, always returns a tool execution + error after ~1s. +- \`protocol_error_job\` — task-supporting, panics into a protocol + error.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + conn = await ctx.connect({ + capabilities: { + elicitation: {}, + sampling: {}, + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }); + } catch (error) { + checks.push({ + id: 'tasks-lifecycle-bootstrap', + name: 'TasksLifecycleBootstrap', + description: + 'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2663_REF] + }); + return checks; + } + + // Check 1: sync tool call returns ToolResult, no task creation. + { + const id = 'tasks-sync-tool-call'; + const name = 'TasksSyncToolCall'; + const description = + 'Sync tool returns ToolResult (resultType:"complete"), no taskId at top level'; + try { + const result = (await conn.request('tools/call', { + name: 'greet', + arguments: { name: 'World' } + })) as any; + const errs: string[] = []; + if (result.resultType === 'task') { + errs.push('sync tool result MUST NOT carry resultType:"task"'); + } + if (result.taskId) { + errs.push( + `sync tool result MUST NOT carry top-level taskId; got ${result.taskId}` + ); + } + if (!Array.isArray(result.content) || result.content.length === 0) { + errs.push('sync tool result MUST carry a non-empty content[] array'); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF, SEP_2322_REF], + details: { + resultType: result.resultType, + hasTaskId: Boolean(result.taskId), + contentLength: result.content?.length + } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 2: server-directed task creation produces flat CreateTaskResult. + let workingTaskId: string | undefined; + { + const id = 'sep-2663-result-type-task-on-create'; + const name = 'TasksServerTaskCreation'; + const description = + 'Task-supporting tool returns flat CreateTaskResult (no nested `task` wrapper)'; + try { + const result = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 2, label: 'lifecycle-create' } + })) as any; + const errs: string[] = []; + if (result.resultType !== 'task') { + errs.push( + `expected resultType:"task"; got ${JSON.stringify(result.resultType)}` + ); + } + if (result.task) { + errs.push( + 'CreateTaskResult MUST be flat (Result & Task); there must be no nested `task` wrapper key' + ); + } + if (!result.taskId) { + errs.push('CreateTaskResult MUST carry top-level taskId'); + } + if (!result.status) { + errs.push('CreateTaskResult MUST carry top-level status'); + } + if ('result' in result) { + errs.push( + 'CreateTaskResult MUST NOT carry `result` (lives on tasks/get DetailedTask)' + ); + } + if ('error' in result) { + errs.push( + 'CreateTaskResult MUST NOT carry `error` (lives on tasks/get DetailedTask)' + ); + } + if ('inputRequests' in result) { + errs.push( + 'CreateTaskResult MUST NOT carry `inputRequests` (lives on tasks/get DetailedTask)' + ); + } + // Timestamps — both keys present, both ISO-8601 formatted. Per + // SEP-2663 these are required on every TaskInfoV2. See + // `mrtr-helpers.ts` for the regex rationale. + if (!isIso8601(result.createdAt)) { + errs.push( + `createdAt MUST be an ISO-8601 string; got ${JSON.stringify(result.createdAt)}` + ); + } + if (!isIso8601(result.lastUpdatedAt)) { + errs.push( + `lastUpdatedAt MUST be an ISO-8601 string; got ${JSON.stringify(result.lastUpdatedAt)}` + ); + } + if (result.taskId) workingTaskId = result.taskId; + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { + resultType: result.resultType, + taskId: result.taskId, + status: result.status + } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 3: tasks/get during working state returns status + metadata. + { + const id = 'sep-2663-tasks-get-status-working'; + const name = 'TasksGetDuringWorking'; + const description = + 'tasks/get returns status + metadata for an active task'; + if (!workingTaskId) { + checks.push( + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) + ); + } else { + try { + const task = (await conn.request('tasks/get', { + taskId: workingTaskId + })) as any; + const errs: string[] = []; + if (task.taskId !== workingTaskId) { + errs.push( + `taskId mismatch: expected ${workingTaskId}, got ${task.taskId}` + ); + } + if (!task.status) errs.push('tasks/get response MUST carry status'); + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { status: task.status } + }); + } catch (error) { + checks.push( + failureCheck(id, name, description, error, [SEP_2663_REF]) + ); + } + } + } + + // Check 4: terminal tasks/get inlines result with content[]. + { + const id = 'sep-2663-tasks-get-status-completed'; + const name = 'TasksGetTerminalInlinedResult'; + const description = + 'Completed task tasks/get inlines result with content[] (no separate tasks/result method)'; + if (!workingTaskId) { + checks.push( + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) + ); + } else { + try { + const terminal = await waitForTerminal(conn, workingTaskId); + const errs: string[] = []; + if (terminal.status !== 'completed') { + errs.push( + `expected status:"completed"; got ${JSON.stringify(terminal.status)}` + ); + } + if (!terminal.result) { + errs.push('completed task MUST inline `result`'); + } else if ( + !Array.isArray(terminal.result.content) || + terminal.result.content.length === 0 + ) { + errs.push( + 'completed task `result.content[]` MUST be a non-empty array' + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { + status: terminal.status, + hasResult: Boolean(terminal.result), + contentLength: terminal.result?.content?.length + } + }); + } catch (error) { + checks.push( + failureCheck(id, name, description, error, [SEP_2663_REF]) + ); + } + } + } + + // Check 5: tool execution error → completed with isError:true. + { + const id = 'sep-2663-tool-error-uses-completed-status'; + const name = 'TasksToolErrorCompletedIsError'; + const description = + 'Tool execution error reports as completed + result.isError (NOT failed)'; + try { + const created = (await conn.request('tools/call', { + name: 'failing_job', + arguments: {} + })) as any; + const errs: string[] = []; + if (!created.taskId) { + errs.push('failing_job MUST create a task'); + } else { + const terminal = await waitForTerminal(conn, created.taskId); + if (terminal.status !== 'completed') { + errs.push( + `tool error MUST surface as completed (not "${terminal.status}")` + ); + } + if (!terminal.result) { + errs.push('completed task with tool error MUST carry `result`'); + } else if (terminal.result.isError !== true) { + errs.push('result.isError MUST be true for tool execution errors'); + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 6: protocol-level error → failed with inlined error, no result. + { + const id = 'sep-2663-tasks-get-status-failed'; + const name = 'TasksProtocolErrorFailedShape'; + const description = + 'Protocol-level error reports as failed + inlined error{code,message}, no result'; + try { + const created = (await conn.request('tools/call', { + name: 'protocol_error_job', + arguments: {} + })) as any; + const errs: string[] = []; + if (!created.taskId) { + errs.push('protocol_error_job MUST create a task'); + } else { + const terminal = await waitForTerminal(conn, created.taskId); + if (terminal.status !== 'failed') { + errs.push( + `protocol error MUST surface as failed (not "${terminal.status}")` + ); + } + if (!terminal.error) { + errs.push('failed task MUST carry inlined `error`'); + } else { + if (typeof terminal.error.code !== 'number') { + errs.push('failed task error MUST carry numeric `code`'); + } + if (typeof terminal.error.message !== 'string') { + errs.push('failed task error MUST carry string `message`'); + } + } + if (terminal.result) { + errs.push('failed task MUST NOT carry `result`'); + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 7: tasks/cancel returns empty {resultType:"complete"} ack; + // status settles to cancelled. + { + const id = 'sep-2663-cancel-ack-empty-result'; + const name = 'TasksCancelEmptyAck'; + const description = + 'tasks/cancel returns {resultType:"complete"} ack; status settles to cancelled'; + let cancelTaskId: string | undefined; + try { + const created = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 60, label: 'lifecycle-cancel' } + })) as any; + cancelTaskId = created.taskId; + if (!cancelTaskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'slow_compute did not create a task', + specReferences: [SEP_2663_REF, SEP_2322_REF] + }); + } else { + const ack = (await conn.request('tasks/cancel', { + taskId: cancelTaskId + })) as any; + const errs: string[] = []; + if (ack?.resultType !== 'complete') { + errs.push( + `cancel ack MUST carry resultType:"complete"; got resultType=${ack?.resultType}` + ); + } + // Task-envelope fields MUST NOT appear on the ack; _meta and + // other result-shape metadata are permitted. + const ackOffenders = ( + ['taskId', 'status', 'result', 'error', 'inputRequests'] as const + ).filter((f) => f in ack); + if (ackOffenders.length > 0) { + errs.push( + `cancel ack MUST NOT carry task-envelope fields; got: ${ackOffenders.join(', ')}` + ); + } + // SEP-2663 §Task Cancellation: transition to `cancelled` is not + // guaranteed; record the settled status as diagnostic detail only. + const after = await waitForTerminal(conn, cancelTaskId); + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF, SEP_2322_REF], + details: { cancelAck: ack, statusAfterCancel: after.status } + }); + } + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 8: tasks/cancel on a terminal task is idempotent — returns + // the same {resultType:"complete"} empty-ack as on an active task, + // rather than -32602. Required so clients don't have to handle the + // race where a task terminates between observation and the cancel + // request. + { + const id = 'tasks-cancel-terminal-idempotent-ack'; + const name = 'TasksCancelTerminalIdempotentAck'; + const description = + 'tasks/cancel on a terminal task returns the same empty-ack as on an active task (idempotent)'; + try { + const created = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 1, label: 'lifecycle-cancel-terminal' } + })) as any; + const completedTaskId = created.taskId; + if (!completedTaskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'slow_compute did not create a task', + specReferences: [SEP_2663_REF] + }); + } else { + await waitForTerminal(conn, completedTaskId); + const ack = (await conn.request('tasks/cancel', { + taskId: completedTaskId + })) as any; + const errs: string[] = []; + if (ack?.resultType !== 'complete') { + errs.push( + `idempotent cancel ack MUST carry resultType:"complete"; got resultType=${ack?.resultType}` + ); + } + const ackOffenders = ( + ['taskId', 'status', 'result', 'error', 'inputRequests'] as const + ).filter((f) => f in ack); + if (ackOffenders.length > 0) { + errs.push( + `idempotent cancel ack MUST NOT carry task-envelope fields; got: ${ackOffenders.join(', ')}` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { cancelAck: ack } + }); + } + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + await conn.close(); + return checks; + } +} diff --git a/src/scenarios/server/tasks/mrtr-helpers.ts b/src/scenarios/server/tasks/mrtr-helpers.ts new file mode 100644 index 0000000..f7da185 --- /dev/null +++ b/src/scenarios/server/tasks/mrtr-helpers.ts @@ -0,0 +1,114 @@ +/** + * Generic conformance-check helpers shared by the SEP-2663 `tasks/*` + * scenarios. Pure: no I/O, no scenario-specific state. + * + * Split from `helpers.ts` (which holds tasks-specific helpers like + * `validTasksParams` and the polling loops) so that the SEP references + * and the FAILURE/SKIPPED check builders can be reused by any future + * extension scenario without dragging tasks-domain types along. + */ + +import type { ConformanceCheck, SpecReference } from '../../../types'; +import { MRTR_SPEC_REFERENCES } from '../input-required-result-helpers'; + +// ──────────────────────────────────────────────────────────────────────── +// Check builders +// ──────────────────────────────────────────────────────────────────────── + +export function errMsg(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Build a FAILURE check from a thrown error, preserving id/name/description. */ +export function failureCheck( + id: string, + name: string, + description: string, + error: unknown, + specReferences: SpecReference[] +): ConformanceCheck { + return { + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errMsg(error), + specReferences + }; +} + +/** + * Build a SKIPPED check (preserves id stability so Ctrl+F still finds it). + * + * Use ONLY for checks that are legitimately not applicable (e.g. a harness + * gap the suite itself tracks). A check whose prerequisite is missing on the + * server under test must use untestableCheck from src/scenarios/untestable.ts + * instead — SKIPPED reads as green in every consumer (issue #248). + */ +export function skipCheck( + id: string, + name: string, + description: string, + reason: string, + specReferences: SpecReference[] +): ConformanceCheck { + return { + id, + name, + description, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + errorMessage: `Skipped: ${reason}`, + specReferences + }; +} + +// ──────────────────────────────────────────────────────────────────────── +// SEP references +// ──────────────────────────────────────────────────────────────────────── + +export const SEP_2243_REF: SpecReference = { + id: 'SEP-2243', + url: 'https://modelcontextprotocol.io/seps/2243-http-standardization' +}; + +/** + * SEP-2322 (MRTR). Imported from `input-required-result-helpers` so the + * codebase holds a single URL per SEP — the rendered spec page absorbs + * post-merge amendments and `MRTR_SPEC_REFERENCES` is already the + * traceability anchor for SEP-2322 elsewhere in `server/`. + */ +export const SEP_2322_REF: SpecReference = MRTR_SPEC_REFERENCES[0]; + +export const SEP_2575_REF: SpecReference = { + id: 'SEP-2575', + url: 'https://modelcontextprotocol.io/seps/2575-stateless-mcp' +}; + +export const SEP_2663_REF: SpecReference = { + id: 'SEP-2663', + url: 'https://modelcontextprotocol.io/seps/2663-tasks-extension' +}; + +// ──────────────────────────────────────────────────────────────────────── +// Wire-format predicates +// ──────────────────────────────────────────────────────────────────────── + +/** + * ISO-8601 timestamp prefix (YYYY-MM-DDThh:mm:ss). Tolerant about the + * timezone tail (`Z`, `+00:00`, `+0000`) and sub-second precision — + * matches what real servers emit (Go `time.RFC3339Nano`, Python + * `datetime.isoformat()`, JavaScript `toISOString()`). + * + * A regex over `Date.parse` (too permissive, accepts RFC-2822, "May 4 + * 2026") / `new Date(s).toISOString() === s` (too strict, rejects + * valid `+00:00` offsets that don't survive the canonical `Z` + * round-trip) / `Temporal.Instant.from` (Node 24+ experimental). + */ +export const ISO_8601_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + +/** Returns true when the input is a string matching ISO-8601 prefix. */ +export function isIso8601(s: unknown): boolean { + return typeof s === 'string' && ISO_8601_PATTERN.test(s); +} diff --git a/src/scenarios/server/tasks/mrtr-input.ts b/src/scenarios/server/tasks/mrtr-input.ts new file mode 100644 index 0000000..b5b41c6 --- /dev/null +++ b/src/scenarios/server/tasks/mrtr-input.ts @@ -0,0 +1,372 @@ +/** + * SEP-2322 / SEP-2663 — MRTR input flow on the tasks surface. + * + * Tests the input_required → tasks/update → resume loop, including + * partial inputResponses fulfillment when a tool fans out multiple + * simultaneous input requests. + * + * Required server fixtures: + * - confirm_delete — task-supporting, calls TaskElicit once + * - multi_input — task-supporting, fans out two TaskElicits in + * parallel so two keys are pending at once + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { SEP_2322_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID, waitForStatus, waitForTerminal } from './helpers'; + +export class TasksMRTRInputScenario implements ClientScenario { + name = 'tasks-mrtr-input'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2322 MRTR input flow on the tasks surface. + +**Server Implementation Requirements:** + +**Surfacing inputRequests (SEP-2322):** +- A task waiting on client input MUST report \`status:"input_required"\` + on tasks/get and surface a non-empty \`inputRequests\` map keyed by + server-minted opaque ids. Each entry carries the underlying request + (\`elicitation/create\`, \`sampling/createMessage\`, etc.). + +**Resuming via tasks/update (SEP-2663):** +- The client delivers responses through \`tasks/update\` with + \`inputResponses\` keyed to match the server-emitted ids. The server + MUST return an empty \`{resultType:"complete"}\` ack on the + tasks/update response — the resulting task state is observed via the + next tasks/get. +- After the response is delivered, the task MUST resume execution and + proceed to a terminal state (or back to input_required for another + round). + +**Partial fulfillment (SEP-2663):** +- A tool that emits multiple simultaneous input requests parks the task + with multiple keys in \`inputRequests\`. A client MAY answer them one + at a time: + - tasks/update with a subset of keys MUST be acked. + - The task MUST stay in \`input_required\` until every pending request + has been answered. + - tasks/get after a partial update MUST surface only the still-pending + keys; the answered key MUST be removed. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`confirm_delete\` — task-supporting, emits a single + \`elicitation/create\` inputRequest then completes when the response + arrives. +- \`multi_input\` — task-supporting, fans out two \`elicitation/create\` + inputRequests in parallel so two keys are pending at once (used by + the partial-fulfillment check).`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + conn = await ctx.connect({ + capabilities: { + elicitation: {}, + sampling: {}, + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }); + } catch (error) { + checks.push({ + id: 'tasks-mrtr-input-bootstrap', + name: 'TasksMrtrInputBootstrap', + description: + 'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2322_REF] + }); + return checks; + } + + // Check 1: tasks/get surfaces inputRequests when status=input_required. + { + const id = 'sep-2663-tasks-get-status-input-required'; + const name = 'TasksMRTRInputRequestsOnTasksGet'; + const description = + 'tasks/get on an input_required task MUST surface a non-empty inputRequests map'; + try { + const created = (await conn.request('tools/call', { + name: 'confirm_delete', + arguments: { filename: 'mrtr-input.txt' } + })) as any; + const taskId = created.taskId; + if (!taskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'confirm_delete did not create a task', + specReferences: [SEP_2322_REF] + }); + } else { + const task = await waitForStatus( + conn, + taskId, + 'input_required', + 5_000 + ); + const errs: string[] = []; + if (task.status !== 'input_required') { + errs.push( + `expected status:"input_required"; got ${JSON.stringify(task.status)}` + ); + } + if ( + !task.inputRequests || + typeof task.inputRequests !== 'object' || + Array.isArray(task.inputRequests) + ) { + errs.push('inputRequests MUST be a non-null object (map)'); + } else { + const keys = Object.keys(task.inputRequests); + if (keys.length === 0) { + errs.push('inputRequests MUST have at least one entry'); + } else { + const firstReq = task.inputRequests[keys[0]]; + if (!firstReq?.method) { + errs.push( + 'each inputRequest MUST carry a `method` (e.g., elicitation/create)' + ); + } + } + } + // Cancel so we don't leave the task parked. + try { + await conn.request('tasks/cancel', { taskId }); + } catch { + /* swallow */ + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2322_REF, SEP_2663_REF] + }); + } + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2322_REF])); + } + } + + // Check 2: tasks/update delivers inputResponses + resumes the task. + { + const id = 'tasks-mrtr-tasks-update-resumes'; + const name = 'TasksMRTRTasksUpdateResumes'; + const description = + 'tasks/update with matching inputResponses MUST be acked with {resultType:"complete"} and resume the task to a terminal state'; + try { + const created = (await conn.request('tools/call', { + name: 'confirm_delete', + arguments: { filename: 'mrtr-resume.txt' } + })) as any; + const taskId = created.taskId; + if (!taskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'confirm_delete did not create a task', + specReferences: [SEP_2322_REF, SEP_2663_REF] + }); + } else { + const inputTask = await waitForStatus( + conn, + taskId, + 'input_required', + 5_000 + ); + const errs: string[] = []; + const responses: Record = {}; + for (const key of Object.keys(inputTask.inputRequests ?? {})) { + responses[key] = { + action: 'accept', + content: { confirm: true } + }; + } + const ack = (await conn.request('tasks/update', { + taskId, + inputResponses: responses + })) as any; + if (ack?.resultType !== 'complete') { + errs.push( + `tasks/update ack MUST carry resultType:"complete"; got resultType=${ack?.resultType}` + ); + } + const updateAckOffenders = ( + ['taskId', 'status', 'result', 'error', 'inputRequests'] as const + ).filter((f) => f in ack); + if (updateAckOffenders.length > 0) { + errs.push( + `tasks/update ack MUST NOT carry task-envelope fields; got: ${updateAckOffenders.join(', ')}` + ); + } + const terminal = await waitForTerminal(conn, taskId); + if (terminal.status !== 'completed') { + errs.push( + `task MUST resume to completed after tasks/update; got status ${JSON.stringify(terminal.status)}` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2322_REF, SEP_2663_REF] + }); + } + } catch (error) { + checks.push( + failureCheck(id, name, description, error, [ + SEP_2322_REF, + SEP_2663_REF + ]) + ); + } + } + + // Check 3: partial inputResponses fulfillment leaves the rest pending. + { + const id = 'tasks-mrtr-partial-fulfillment'; + const name = 'TasksMRTRPartialFulfillment'; + const description = + 'tasks/update with a subset of keys MUST keep the task in input_required with only the unanswered key remaining'; + try { + const created = (await conn.request('tools/call', { + name: 'multi_input', + arguments: {} + })) as any; + const taskId = created.taskId; + if (!taskId) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'multi_input did not create a task', + specReferences: [SEP_2663_REF] + }); + } else { + // Wait until two keys are pending (the fan-out tool races two + // TaskElicits, so we may briefly see one before the second). + let inputTask: any; + const start = Date.now(); + while (Date.now() - start < 5_000) { + inputTask = (await conn.request('tasks/get', { taskId })) as any; + if ( + inputTask.status === 'input_required' && + inputTask.inputRequests && + Object.keys(inputTask.inputRequests).length >= 2 + ) { + break; + } + await new Promise((r) => setTimeout(r, 100)); + } + const errs: string[] = []; + if (inputTask.status !== 'input_required') { + errs.push( + `task with two parallel elicits MUST be input_required; got ${JSON.stringify(inputTask.status)}` + ); + } + const keys = Object.keys(inputTask.inputRequests ?? {}); + if (keys.length < 2) { + errs.push( + `multi_input MUST surface 2 inputRequests; got ${keys.length}` + ); + } else { + const [firstKey, secondKey] = keys; + + // Answer first key only. + const firstAck = (await conn.request('tasks/update', { + taskId, + inputResponses: { + [firstKey]: { + action: 'accept', + content: { name: 'partial-1', confirm: true } + } + } + })) as any; + if (firstAck.resultType !== 'complete') { + errs.push( + `partial tasks/update ack MUST carry resultType:"complete"; got ${JSON.stringify(firstAck)}` + ); + } + + // Status MUST still be input_required with only the second + // key remaining. + const afterFirst = (await conn.request('tasks/get', { + taskId + })) as any; + if (afterFirst.status !== 'input_required') { + errs.push( + `task MUST stay input_required while another input is still pending; got ${JSON.stringify(afterFirst.status)}` + ); + } + const remaining = Object.keys(afterFirst.inputRequests ?? {}); + if (!remaining.includes(secondKey)) { + errs.push( + `unanswered key MUST remain in inputRequests; got ${JSON.stringify(remaining)}` + ); + } + if (remaining.includes(firstKey)) { + errs.push( + `answered key MUST be removed from inputRequests; still saw ${firstKey}` + ); + } + + // Answer second key — task resumes and finishes. + await conn.request('tasks/update', { + taskId, + inputResponses: { + [secondKey]: { + action: 'accept', + content: { name: 'partial-2', confirm: true } + } + } + }); + const terminal = await waitForTerminal(conn, taskId); + if (terminal.status !== 'completed') { + errs.push( + `task MUST complete after both inputs are satisfied; got ${JSON.stringify(terminal.status)}` + ); + } + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2322_REF, SEP_2663_REF] + }); + } + } catch (error) { + checks.push( + failureCheck(id, name, description, error, [ + SEP_2322_REF, + SEP_2663_REF + ]) + ); + } + } + + await conn.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tasks/notifications.ts b/src/scenarios/server/tasks/notifications.ts new file mode 100644 index 0000000..6022a70 --- /dev/null +++ b/src/scenarios/server/tasks/notifications.ts @@ -0,0 +1,56 @@ +/** + * SEP-2663 Tasks Extension — status notifications conformance. + * + * **SKIPPED — pending subscriptions/listen rewrite.** SEP-2663 delivers + * `notifications/tasks` over the `subscriptions/listen` stream defined + * by SEP-2575, not over the POST SSE response of `tools/call`. This + * scenario's harness reads SSE frames off the `tools/call` POST + * response, so its observation point misses notifications on servers + * that follow the spec — they emit on a stream this harness never + * opens. + * + * The scenario is preserved here as a reference point; rewriting the + * harness against subscriptions/listen is tracked as a follow-up. + * + * Required server fixtures (for the future re-enabled scenario): + * - slow_compute — task-supporting, sleeps N seconds. + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { RunContext } from '../../../connection'; +import { SEP_2663_REF } from './mrtr-helpers'; +import { skipCheck } from './mrtr-helpers'; + +export class TasksStatusNotificationsScenario implements ClientScenario { + name = 'tasks-status-notifications'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2663 status notifications (optional). + +**Server Implementation Requirements:** + +Servers MAY emit \`notifications/tasks\` on the subscriptions/listen +stream to inform clients of task state changes without polling. +Notifications are optional — a server is conformant whether it sends +them or not. When sent, the notification params MUST carry: + +- \`taskId\`: the task the notification refers to. +- \`status\`: the new task status. +- For terminal statuses (\`completed\`/\`failed\`/\`cancelled\`), + notifications MAY inline the corresponding \`result\` or \`error\` + per the SEP-2663 DetailedTask shape.`; + + async run(_ctx: RunContext): Promise { + // Prior implementation (POST-SSE observation harness on tools/call) + // was removed in this commit; see git blame for the working code to + // adapt when rewriting against subscriptions/listen. + return [ + skipCheck( + 'tasks-status-notifications', + 'TasksStatusNotificationsScenario', + 'Status notifications conformance, pending subscriptions/listen rewrite.', + 'SEP-2663 delivers notifications/tasks via the SEP-2575 subscriptions/listen stream, not via the tools/call POST SSE response that the prior harness observed. The harness needs a rewrite against the new channel; tracked as a follow-up.', + [SEP_2663_REF] + ) + ]; + } +} diff --git a/src/scenarios/server/tasks/request-state.ts b/src/scenarios/server/tasks/request-state.ts new file mode 100644 index 0000000..0e6559c --- /dev/null +++ b/src/scenarios/server/tasks/request-state.ts @@ -0,0 +1,181 @@ +/** + * SEP-2663 Tasks Extension — `requestState` absence on the tasks-v2 wire. + * + * SEP-2663 does not define a `requestState` field on the tasks-v2 wire. + * This scenario asserts the absence on the two task-bearing message + * shapes that a server can populate at task creation and during polling: + * + * - `CreateTaskResult` MUST NOT carry `requestState`. + * - `DetailedTask` (tasks/get response) MUST NOT carry `requestState`, + * regardless of status. + * + * Why a negative test exists for a field the spec never defines: + * SEP-2322 (MRTR) places `requestState` on `InputRequiredResult` — the + * same JSON shape slot a fresh implementer might also reach for on the + * tasks-v2 `DetailedTask` while reading the two SEPs together. The + * absence-assert here catches that cross-SEP confusion. SEP-2322's + * `InputRequiredResult.requestState` is unrelated to the tasks-v2 wire + * and is exercised by mrtr-input.ts. + * + * Required server fixtures: + * - slow_compute — task-supporting, sleeps N seconds + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID } from './helpers'; + +export class TasksRequestStateRemovalScenario implements ClientScenario { + name = 'tasks-request-state-removal'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Verify the absence of \`requestState\` on the tasks-v2 wire. + +**Server Implementation Requirements:** + +SEP-2663 does not define a \`requestState\` field on the \`Task\` base +interface, so: + +- \`CreateTaskResult\` MUST NOT carry \`requestState\` on the + \`tools/call\` response that creates a task. +- The \`tasks/get\` response (DetailedTask) MUST NOT carry + \`requestState\` for any status (\`working\` / \`input_required\` / + \`completed\` / \`cancelled\` / \`failed\`). + +SEP-2322's \`InputRequiredResult\` does carry \`requestState\` — that is +the MRTR multi-round-trip surface, unrelated to the tasks-v2 wire, and +is exercised by mrtr-input.ts. This scenario exists because the two +SEPs put \`requestState\` in lexically adjacent positions, making +accidental copy-paste from the MRTR shape into the tasks-v2 shape a +foreseeable mistake for fresh implementations. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`slow_compute\` — task-supporting, \`seconds\`-second sleep then a + result.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + conn = await ctx.connect({ + capabilities: { extensions: { [TASKS_EXTENSION_ID]: {} } } + }); + } catch (error) { + checks.push({ + id: 'tasks-request-state-bootstrap', + name: 'TasksRequestStateBootstrap', + description: + 'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2663_REF] + }); + return checks; + } + + // Drive a long-running task so we can interrogate `tasks/get` while + // the task is still in `working`. A terminal-only sample would miss + // any field a server populated only during the in-flight window. + let taskId: string | undefined; + let createdTask: any; + try { + createdTask = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 60, label: 'request-state-removal' } + })) as any; + taskId = createdTask?.taskId; + } catch (error) { + checks.push( + failureCheck( + 'tasks-request-state-removal-setup', + 'TasksRequestStateRemovalSetup', + 'Failed to create a long-running task to exercise the absence-asserts', + error, + [SEP_2663_REF] + ) + ); + await conn.close().catch(() => {}); + return checks; + } + if (!taskId) { + checks.push({ + id: 'tasks-request-state-removal-setup', + name: 'TasksRequestStateRemovalSetup', + description: + 'slow_compute did not return a CreateTaskResult; cannot exercise absence-asserts', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'no taskId in tools/call response', + specReferences: [SEP_2663_REF] + }); + await conn.close().catch(() => {}); + return checks; + } + + // Check 1: CreateTaskResult MUST NOT carry requestState. + { + const id = 'tasks-create-result-no-request-state'; + const name = 'TasksCreateResultNoRequestState'; + const description = + 'CreateTaskResult MUST NOT carry `requestState` (SEP-2663 does not define the field on the Task base interface)'; + const has = Object.prototype.hasOwnProperty.call( + createdTask, + 'requestState' + ); + checks.push({ + id, + name, + description, + status: has ? 'FAILURE' : 'SUCCESS', + timestamp: new Date().toISOString(), + errorMessage: has + ? `CreateTaskResult carries requestState = ${JSON.stringify(createdTask.requestState)}; SEP-2663 does not define this field on the tasks-v2 wire.` + : undefined, + specReferences: [SEP_2663_REF] + }); + } + + // Check 2: tasks/get response (DetailedTask) MUST NOT carry requestState. + { + const id = 'tasks-get-detailed-no-request-state'; + const name = 'TasksGetDetailedNoRequestState'; + const description = + 'tasks/get response (DetailedTask) MUST NOT carry `requestState` for any status (per SEP-2663)'; + try { + const detailed = (await conn.request('tasks/get', { + taskId + })) as any; + const has = Object.prototype.hasOwnProperty.call( + detailed, + 'requestState' + ); + checks.push({ + id, + name, + description, + status: has ? 'FAILURE' : 'SUCCESS', + timestamp: new Date().toISOString(), + errorMessage: has + ? `DetailedTask carries requestState = ${JSON.stringify(detailed.requestState)} (status=${detailed.status}); SEP-2663 does not define this field on the tasks-v2 wire.` + : undefined, + specReferences: [SEP_2663_REF], + details: { observedStatus: detailed.status } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + try { + await conn.request('tasks/cancel', { taskId }); + } catch { + /* swallow */ + } + + await conn.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tasks/required-task-error.ts b/src/scenarios/server/tasks/required-task-error.ts new file mode 100644 index 0000000..1da07c8 --- /dev/null +++ b/src/scenarios/server/tasks/required-task-error.ts @@ -0,0 +1,222 @@ +/** + * SEP-2663 Tasks Extension — required-task error conformance. + * + * SEP-2575 (Stateless MCP) §"Missing Required Capabilities" defines + * the `MissingRequiredClientCapability` JSON-RPC error code (-32021) for + * the case where a server cannot service a request without a client + * capability the client did not declare. SEP-2663 §"Required + * Capabilities" applies that rule to the tasks extension: if a tool's + * declared task support is "required" and the client did not declare + * `io.modelcontextprotocol/tasks` during `initialize`, the server MUST + * reject with that error. The error data + * SHOULD carry a `requiredCapabilities` object whose shape mirrors the + * `InitializeRequest` capabilities, so the client can self-describe + * what to add without needing out-of-band documentation. + * + * This scenario verifies the failure path: + * 1. Initialize a session WITHOUT declaring the tasks extension. + * 2. Call a tool whose task support is `required`. + * 3. Expect a JSON-RPC error with `code: -32021` and + * `data.requiredCapabilities.extensions["io.modelcontextprotocol/tasks"]` + * present. + * + * Required server fixtures: + * - failing_job — a tool registered with task support declared as + * `required`. The tool's payload behaviour is + * irrelevant; only the registration-time declaration + * matters because the error is returned by the + * middleware before the handler runs. + */ + +import { McpError } from '@modelcontextprotocol/sdk/types.js'; + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { MISSING_REQUIRED_CLIENT_CAPABILITY } from '../../../spec-types/draft'; +import { SEP_2575_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID } from './helpers'; + +const REQUIRED_TASK_TOOL = 'failing_job'; + +export class TasksRequiredTaskErrorScenario implements ClientScenario { + name = 'tasks-required-task-error'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Verify the -32021 error path for required-task tools when the +client has not negotiated the io.modelcontextprotocol/tasks extension. + +**Server Implementation Requirements:** + +Per SEP-2575 §"Missing Required Capabilities" and SEP-2663 §"Required +Capabilities": + +> If a server is unable to service a request to a client that does not +> declare this extension capability without returning \`CreateTaskResult\`, +> the server MUST return an error with the code \`-32021\` (Missing +> Required Client Capability), indicating the required extension in the +> error response. + +The error data SHOULD include a \`requiredCapabilities\` object whose +shape mirrors \`InitializeRequest.capabilities\`, e.g. + +\`\`\`json +{ + "requiredCapabilities": { + "extensions": { + "io.modelcontextprotocol/tasks": {} + } + } +} +\`\`\` + +The scenario calls \`tools/call\` for a tool registered with task support +\`required\` from a client that did NOT declare the extension. A +conformant server MUST reject with \`-32021\`. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`failing_job\` — registered with task support declared as \`required\`. + The tool's payload behavior is irrelevant; only the registration-time + declaration matters, because the error is returned by the middleware + before the handler runs.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + // Intentionally declare NO capabilities — the point of the test is + // to exercise the "did not negotiate the tasks extension" path. + conn = await ctx.connect({ + capabilities: {} + }); + } catch (error) { + checks.push({ + id: 'tasks-required-error-bootstrap', + name: 'TasksRequiredErrorBootstrap', + description: + 'Initialize handshake without the tasks extension capability succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + return checks; + } + + // Check 1: tools/call for a required-task tool returns the + // MissingRequiredClientCapability error. + const id = 'sep-2663-server-returns-missing-capability-when-required'; + const name = 'Sep2663ServerReturnsMissingCapabilityWhenRequired'; + const description = `tools/call for a TaskSupport=required tool MUST reject with code ${MISSING_REQUIRED_CLIENT_CAPABILITY} (Missing Required Client Capability) when the client did not declare ${TASKS_EXTENSION_ID}`; + + let observed: { code?: number; data?: unknown } = {}; + let errored = false; + try { + await conn.request('tools/call', { + name: REQUIRED_TASK_TOOL, + arguments: {} + }); + } catch (error) { + errored = true; + if (error instanceof McpError) { + observed = { code: error.code, data: error.data }; + } else if (error && typeof error === 'object') { + const anyErr = error as any; + observed = { + code: typeof anyErr.code === 'number' ? anyErr.code : undefined, + data: anyErr.data + }; + } + } + + if (!errored) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `tools/call for ${REQUIRED_TASK_TOOL} returned a successful response from a client that did not declare ${TASKS_EXTENSION_ID}; spec requires ${MISSING_REQUIRED_CLIENT_CAPABILITY} rejection in this case.`, + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + await conn.close().catch(() => {}); + return checks; + } + + if (observed.code !== MISSING_REQUIRED_CLIENT_CAPABILITY) { + checks.push({ + id, + name, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `tools/call for ${REQUIRED_TASK_TOOL} returned error code ${observed.code ?? '(unknown)'}; spec requires ${MISSING_REQUIRED_CLIENT_CAPABILITY}.`, + specReferences: [SEP_2575_REF, SEP_2663_REF], + details: { observedCode: observed.code } + }); + await conn.close().catch(() => {}); + return checks; + } + + checks.push({ + id, + name, + description, + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + + // Check 2: the error data carries the structured `requiredCapabilities` + // payload so a client can self-describe what to add. SHOULD, but the + // canonical error example in the spec shows this shape; flag a + // FAILURE only when the field shape is broken, not when it's absent. + { + const id2 = 'sep-2663-server-returns-missing-capability-data-shape'; + const name2 = 'Sep2663ServerReturnsMissingCapabilityDataShape'; + const description2 = `Error data for ${MISSING_REQUIRED_CLIENT_CAPABILITY} SHOULD carry data.requiredCapabilities.extensions["${TASKS_EXTENSION_ID}"]`; + const data = observed.data as any; + if (data === undefined || data === null) { + checks.push({ + id: id2, + name: name2, + description: description2, + status: 'INFO', + timestamp: new Date().toISOString(), + errorMessage: `Error returned ${MISSING_REQUIRED_CLIENT_CAPABILITY} but carried no \`data\` field; the spec example shows requiredCapabilities, but data is SHOULD.`, + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + } else if ( + typeof data !== 'object' || + Array.isArray(data) || + !data.requiredCapabilities || + typeof data.requiredCapabilities !== 'object' || + !data.requiredCapabilities.extensions || + typeof data.requiredCapabilities.extensions !== 'object' || + data.requiredCapabilities.extensions[TASKS_EXTENSION_ID] === undefined + ) { + checks.push({ + id: id2, + name: name2, + description: description2, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Error data shape does not include requiredCapabilities.extensions["${TASKS_EXTENSION_ID}"]; got data = ${JSON.stringify(data)}`, + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + } else { + checks.push({ + id: id2, + name: name2, + description: description2, + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SEP_2575_REF, SEP_2663_REF] + }); + } + } + + await conn.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tasks/wire-fields.ts b/src/scenarios/server/tasks/wire-fields.ts new file mode 100644 index 0000000..73684a1 --- /dev/null +++ b/src/scenarios/server/tasks/wire-fields.ts @@ -0,0 +1,255 @@ +/** + * SEP-2663 Tasks Extension — wire-format / TTL conformance. + * + * Tests the renamed wire fields (ttlMs, pollIntervalMs), + * the no-early-TTL-expiry rule, and confirms the v1 `related-task` _meta + * key is absent on tasks/get's inlined result (taskId is at root level + * already, so the metadata is redundant). + * + * Required server fixtures: + * - slow_compute — task-supporting, sleeps N seconds + */ + +import { ClientScenario, ConformanceCheck } from '../../../types'; +import type { Connection, RunContext } from '../../../connection'; +import { SEP_2663_REF } from './mrtr-helpers'; +import { errMsg, failureCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID, waitForTerminal } from './helpers'; +import { untestableCheck } from '../../untestable'; + +export class TasksWireFieldsScenario implements ClientScenario { + name = 'tasks-wire-fields'; + readonly source = { extensionId: 'io.modelcontextprotocol/tasks' } as const; + description = `Test SEP-2663 wire-field renames + TTL semantics. + +**Server Implementation Requirements:** + +**Wire-field renames (SEP-2663):** +- The TTL field is named \`ttlMs\` on the wire (the v1 \`ttl\` key was + in milliseconds-by-convention; SEP-2663 puts the unit in the field + name and standardised on the \`Ms\` suffix for all duration fields). +- The poll-interval field is named \`pollIntervalMs\` (v1 used + \`pollInterval\`). +- A \`CreateTaskResult\` MUST NOT carry the legacy \`ttl\` or + \`pollInterval\` keys — clients keying off v1 names on a v2 server + would silently miss the TTL guidance. +- Both \`ttlMs\` and \`pollIntervalMs\` are integer milliseconds. + Servers MUST NOT emit fractional values. + +**TTL non-expiry (SEP-2663):** +- A task MUST remain accessible via \`tasks/get\` for the duration of + its \`ttlMs\`; a server MUST NOT expire it earlier. + +**Inlined-result \`_meta\` (SEP-2663):** +- The v1 \`io.modelcontextprotocol/related-task\` \`_meta\` key MUST NOT + appear on tasks/get's inlined \`result\` — the \`taskId\` is already + at the root level of the \`tasks/get\` response, so the metadata is + redundant. + +**Required server fixtures (\`tools/list\` MUST include all):** +- \`slow_compute\` — task-supporting, \`seconds\`-second sleep then a + result.`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + + let conn: Connection; + try { + conn = await ctx.connect({ + capabilities: { + extensions: { [TASKS_EXTENSION_ID]: {} } + } + }); + } catch (error) { + checks.push({ + id: 'tasks-wire-fields-bootstrap', + name: 'TasksWireFieldsBootstrap', + description: + 'Initialize handshake declaring io.modelcontextprotocol/tasks extension succeeds', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed to initialize: ${errMsg(error)}`, + specReferences: [SEP_2663_REF] + }); + return checks; + } + + // Check 1: ttlMs + pollIntervalMs wire shape. + let createdTaskId: string | undefined; + { + const id = 'tasks-wire-field-renames'; + const name = 'TasksWireFieldRenames'; + const description = + 'CreateTaskResult uses ttlMs + pollIntervalMs (integer milliseconds); legacy ttl / pollInterval keys absent'; + try { + const result = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 1, label: 'wire-fields' } + })) as any; + createdTaskId = result.taskId; + const errs: string[] = []; + // ttlMs — required, positive integer milliseconds (or null = + // unlimited; treat either as well-formed). Legacy `ttl` MUST + // be absent. + if (!('ttlMs' in result)) { + errs.push( + 'CreateTaskResult MUST carry ttlMs (renamed from v1 `ttl`)' + ); + } else if ( + result.ttlMs !== null && + (typeof result.ttlMs !== 'number' || + !Number.isInteger(result.ttlMs) || + result.ttlMs <= 0) + ) { + errs.push( + `ttlMs MUST be null or a positive integer (milliseconds); got ${JSON.stringify(result.ttlMs)}` + ); + } + if ('ttl' in result) { + errs.push( + 'CreateTaskResult MUST NOT carry the v1 `ttl` key (use ttlMs)' + ); + } + // pollIntervalMs — optional. When present it MUST be a positive + // integer (milliseconds), and the legacy `pollInterval` key MUST + // NOT appear. + if ( + result.pollIntervalMs !== undefined && + (typeof result.pollIntervalMs !== 'number' || + !Number.isInteger(result.pollIntervalMs) || + result.pollIntervalMs <= 0) + ) { + errs.push( + `pollIntervalMs MUST be a positive integer when present; got ${JSON.stringify(result.pollIntervalMs)}` + ); + } + if ('pollInterval' in result) { + errs.push( + 'CreateTaskResult MUST NOT carry the v1 `pollInterval` key (use pollIntervalMs)' + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { + ttlMs: result.ttlMs, + pollIntervalMs: result.pollIntervalMs, + hasLegacyTtl: 'ttl' in result, + hasLegacyPollInterval: 'pollInterval' in result + } + }); + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + // Check 2: task accessible before TTL elapses. + { + const id = 'tasks-no-early-ttl-expiry'; + const name = 'TasksNoEarlyTtlExpiry'; + const description = + 'Task remains accessible via tasks/get for the duration of its ttlMs'; + if (!createdTaskId) { + checks.push( + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) + ); + } else { + try { + await waitForTerminal(conn, createdTaskId); + // Sanity probe well before TTL elapses. ttlMs is integer + // milliseconds and servers typically pick order-of-minutes + // defaults, so a 500ms wait is comfortably inside any sane TTL. + await new Promise((r) => setTimeout(r, 500)); + const after = (await conn.request('tasks/get', { + taskId: createdTaskId + })) as any; + const errs: string[] = []; + if (after.taskId !== createdTaskId) { + errs.push( + `task MUST still be accessible before TTL; got taskId=${after.taskId}` + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF] + }); + } catch (error) { + checks.push( + failureCheck(id, name, description, error, [SEP_2663_REF]) + ); + } + } + } + + // Check 3: no related-task _meta on inlined result. + { + const id = 'tasks-no-related-task-meta-on-inlined-result'; + const name = 'TasksNoRelatedTaskMetaOnInlinedResult'; + const description = + 'tasks/get inlined result MUST NOT include the v1 io.modelcontextprotocol/related-task _meta key (taskId is at the root)'; + try { + const created = (await conn.request('tools/call', { + name: 'slow_compute', + arguments: { seconds: 1, label: 'wire-fields-meta' } + })) as any; + const taskId = created.taskId; + if (!taskId) { + checks.push( + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) + ); + } else { + const terminal = await waitForTerminal(conn, taskId); + const errs: string[] = []; + const meta = terminal.result?._meta; + if (meta && meta['io.modelcontextprotocol/related-task']) { + errs.push( + 'related-task _meta MUST NOT appear on tasks/get inlined result' + ); + } + checks.push({ + id, + name, + description, + status: errs.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errs.length > 0 ? errs.join('; ') : undefined, + specReferences: [SEP_2663_REF], + details: { + hasMeta: Boolean(meta), + hasRelatedTask: Boolean( + meta?.['io.modelcontextprotocol/related-task'] + ) + } + }); + } + } catch (error) { + checks.push(failureCheck(id, name, description, error, [SEP_2663_REF])); + } + } + + await conn.close().catch(() => {}); + return checks; + } +} diff --git a/src/scenarios/server/tools.test.ts b/src/scenarios/server/tools.test.ts new file mode 100644 index 0000000..c8f72ad --- /dev/null +++ b/src/scenarios/server/tools.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect } from 'vitest'; +import { + buildToolsListDeterministicOrderCheck, + buildToolsNameFormatCheck, + toolNameFormatCheckApplies, + ToolsListScenario, + validateToolNameFormat +} from './tools.js'; +import type { Connection, RunContext } from '../../connection'; + +describe('validateToolNameFormat', () => { + it('accepts a typical snake_case name', () => { + expect(validateToolNameFormat('test_simple_text')).toBeNull(); + }); + + it('accepts all allowed character classes', () => { + expect(validateToolNameFormat('Aa0_.-')).toBeNull(); + }); + + it('accepts a single-character name (lower length boundary)', () => { + expect(validateToolNameFormat('a')).toBeNull(); + }); + + it('accepts a 128-character name (upper length boundary)', () => { + expect(validateToolNameFormat('a'.repeat(128))).toBeNull(); + }); + + it('rejects an empty name', () => { + expect(validateToolNameFormat('')).toMatch(/length 0 is outside/); + }); + + it('rejects a 129-character name', () => { + expect(validateToolNameFormat('a'.repeat(129))).toMatch( + /length 129 is outside/ + ); + }); + + it('rejects forward slash (allowed in SEP-986 markdown but not spec prose)', () => { + expect(validateToolNameFormat('namespace/tool')).toMatch( + /contains characters outside/ + ); + }); + + it.each([ + ['space', 'bad name'], + ['colon', 'bad:name'], + ['at sign', 'bad@name'], + ['unicode', 'bad\u00e9name'], + ['backslash', 'bad\\name'], + ['plus', 'bad+name'] + ])('rejects a name with a disallowed character (%s)', (_label, name) => { + expect(validateToolNameFormat(name)).toMatch(/contains characters outside/); + }); +}); + +describe('buildToolsNameFormatCheck', () => { + it('returns INFO when tools is undefined', () => { + const check = buildToolsNameFormatCheck(undefined); + expect(check.status).toBe('INFO'); + expect(check.id).toBe('tools-name-format'); + expect(check.details).toEqual({ toolCount: 0 }); + }); + + it('returns INFO when tools is an empty array', () => { + const check = buildToolsNameFormatCheck([]); + expect(check.status).toBe('INFO'); + expect(check.errorMessage).toBe('No tools advertised; nothing to validate'); + }); + + it('returns SUCCESS when all tool names are valid', () => { + const check = buildToolsNameFormatCheck([ + { name: 'test_simple_text' }, + { name: 'admin.tools.list' } + ]); + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + expect(check.details).toMatchObject({ + toolCount: 2, + results: { + test_simple_text: 'valid', + 'admin.tools.list': 'valid' + } + }); + }); + + it('returns WARNING with per-tool details when some names are invalid', () => { + const check = buildToolsNameFormatCheck([ + { name: 'good_tool' }, + { name: 'bad name with spaces' }, + { name: 'a'.repeat(129) } + ]); + + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toContain( + '2 tool name(s) violate spec Tool Names SHOULD rules' + ); + + const results = (check.details as { results: Record }) + .results; + expect(results['good_tool']).toBe('valid'); + expect(results['bad name with spaces']).toMatch(/^invalid: /); + expect(results['a'.repeat(129)]).toMatch(/^invalid: length 129/); + }); + + it('flags a tool whose name is not a string', () => { + const check = buildToolsNameFormatCheck([{ name: 123 as unknown }]); + expect(check.status).toBe('WARNING'); + const results = (check.details as { results: Record }) + .results; + expect(results['']).toBe( + 'invalid: name is not a string' + ); + }); + + it('lists core spec Tool Names first, then SEP history for context', () => { + const check = buildToolsNameFormatCheck([{ name: 'ok' }]); + const ids = check.specReferences?.map((r) => r.id); + expect(ids).toEqual([ + 'MCP-Tool-Names', + 'MCP-Tool-Names-Draft', + 'SEP-986-History', + 'SEP-986-Spec-Integration' + ]); + expect(check.specReferences?.[0]?.url).toContain( + '2025-11-25/server/tools#tool-names' + ); + expect(check.specReferences?.[1]?.url).toContain( + 'draft/server/tools#tool-names' + ); + expect(check.specReferences?.[2]?.url).toContain('issues/986'); + expect(check.specReferences?.[3]?.url).toContain('pull/1603'); + }); +}); + +describe('toolNameFormatCheckApplies', () => { + it('is false before 2025-11-25 and true from that version onward', () => { + expect(toolNameFormatCheckApplies('2025-03-26')).toBe(false); + expect(toolNameFormatCheckApplies('2025-06-18')).toBe(false); + expect(toolNameFormatCheckApplies('2025-11-25')).toBe(true); + expect(toolNameFormatCheckApplies('2026-07-28')).toBe(true); + }); +}); + +describe('ToolsListScenario version gate', () => { + // Scenario-level gate: invalid names must not emit the check before 2025-11-25. + function mockContext( + specVersion: RunContext['specVersion'], + tools: Array<{ name: string; description: string; inputSchema: object }> + ): RunContext { + const connection: Connection = { + notifications: [], + request: (async () => ({ tools })) as Connection['request'], + discover: async () => ({}), + close: async () => {} + }; + + return { + serverUrl: 'http://example.test/mcp', + specVersion, + connect: async () => connection + }; + } + + it('does not emit tools-name-format on 2025-06-18 even when names violate 2025-11-25 rules', async () => { + const scenario = new ToolsListScenario(); + const checks = await scenario.run( + mockContext('2025-06-18', [ + { + name: 'bad name', + description: 'invalid under 2025-11-25 rules', + inputSchema: { type: 'object' } + } + ]) + ); + + expect(checks.some((c) => c.id === 'tools-name-format')).toBe(false); + expect(checks.find((c) => c.id === 'tools-list')?.status).toBe('SUCCESS'); + }); + + it('emits tools-name-format on 2025-11-25 when names violate spec prose', async () => { + const scenario = new ToolsListScenario(); + const checks = await scenario.run( + mockContext('2025-11-25', [ + { + name: 'bad name', + description: 'invalid under 2025-11-25 rules', + inputSchema: { type: 'object' } + } + ]) + ); + + expect(checks.find((c) => c.id === 'tools-name-format')?.status).toBe( + 'WARNING' + ); + }); +}); + +describe('buildToolsListDeterministicOrderCheck', () => { + const names = (...ns: string[]) => ns.map((name) => ({ name })); + + it('returns SUCCESS when every probe lists the same tools in the same order', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a', 'b', 'c'), + names('a', 'b', 'c'), + names('a', 'b', 'c') + ]); + expect(check.id).toBe('tools-list-deterministic-order'); + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + expect(check.details).toEqual({ + toolCount: 3, + probes: 3, + orders: [ + ['a', 'b', 'c'], + ['a', 'b', 'c'], + ['a', 'b', 'c'] + ] + }); + expect(check.specReferences?.[0]?.url).toContain('2026-07-28/server/tools'); + }); + + it('returns WARNING when the same tools come back in a different order', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a', 'b', 'c'), + names('b', 'c', 'a'), + names('c', 'a', 'b') + ]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/different order/); + expect(check.errorMessage).toMatch(/index 0/); + expect(check.details).toMatchObject({ toolCount: 3, probes: 3 }); + expect(check.details?.untestable).toBeUndefined(); + expect(check.details?.orders).toEqual([ + ['a', 'b', 'c'], + ['b', 'c', 'a'], + ['c', 'a', 'b'] + ]); + }); + + it('flags a divergence that only appears on the last probe', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a', 'b', 'c'), + names('a', 'b', 'c'), + names('a', 'c', 'b') + ]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/probe 3/); + expect(check.errorMessage).toMatch(/index 1/); + }); + + it('reports untestable (WARNING) when the set of tools changed between probes', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a', 'b', 'c'), + names('a', 'b', 'd') + ]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/^Not testable: /); + expect(check.errorMessage).toMatch(/added: d/); + expect(check.errorMessage).toMatch(/removed: c/); + expect(check.details).toMatchObject({ untestable: true }); + }); + + it('reports untestable (WARNING) when a probe returned no tools array', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a', 'b'), + undefined + ]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/^Not testable: /); + expect(check.details).toMatchObject({ untestable: true }); + }); + + it('reports untestable (WARNING) with fewer than two probes', () => { + const check = buildToolsListDeterministicOrderCheck([names('a', 'b')]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/^Not testable: /); + }); + + it('returns INFO when no probe saw two tools to order', () => { + const one = buildToolsListDeterministicOrderCheck([names('a'), names('a')]); + expect(one.status).toBe('INFO'); + expect(one.errorMessage).toMatch(/nothing to compare/); + expect(one.details).toEqual({ + toolCount: 1, + probes: 2, + orders: [['a'], ['a']] + }); + const none = buildToolsListDeterministicOrderCheck([[], []]); + expect(none.status).toBe('INFO'); + }); + + it('reports untestable when a probe grows from one tool to two', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a'), + names('a', 'b') + ]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/^Not testable: /); + expect(check.errorMessage).toMatch(/added: b/); + }); + + it('treats a tool without a string name as one placeholder entry', () => { + const check = buildToolsListDeterministicOrderCheck([ + [{ name: 'a' }, { name: 42 }], + [{ name: 42 }, { name: 'a' }] + ]); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toMatch(/different order/); + expect(check.details?.orders).toEqual([ + ['a', ''], + ['', 'a'] + ]); + }); + + it('treats duplicate names as a set change only when their multiplicity changes', () => { + const stable = buildToolsListDeterministicOrderCheck([ + names('a', 'a', 'b'), + names('a', 'a', 'b') + ]); + expect(stable.status).toBe('SUCCESS'); + const changed = buildToolsListDeterministicOrderCheck([ + names('a', 'a', 'b'), + names('a', 'b', 'b') + ]); + expect(changed.status).toBe('WARNING'); + expect(changed.errorMessage).toMatch(/^Not testable: /); + }); + + it('gates itself to the 2026-07-28 wire', () => { + const check = buildToolsListDeterministicOrderCheck([ + names('a', 'b'), + names('a', 'b') + ]); + expect(check.source).toEqual({ introducedIn: '2026-07-28' }); + }); +}); diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index 7ecbfdd..fd423f1 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -2,18 +2,253 @@ * Tools test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer, NotificationCollector } from './client-helper'; import { - CallToolResultSchema, + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + specVersionAtLeast, + type SpecVersion +} from '../../types'; +import type { RunContext } from '../../connection'; +import { notTestable, untestableCheck } from '../untestable'; +import type { + ListToolsResult, + CallToolResult +} from '../../spec-types/2025-06-18'; +import { + connectToServer, + NotificationCollector +} from '../../connection/sdk-client'; +import { CreateMessageRequestSchema, - ElicitRequestSchema, - Progress + ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +/** + * Tool name rules per the 2025-11-25 spec prose (#tool-names): 1–128 chars of + * [A-Za-z0-9_.-]. (The SEP-986 markdown still shows the older 64-char / `/` + * rules; the published spec is authoritative.) + */ +const TOOL_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; +const TOOL_NAME_MAX_LENGTH = 128; + +const TOOLS_NAME_FORMAT_SPEC_REFS = [ + { + id: 'MCP-Tool-Names', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names' + }, + { + id: 'MCP-Tool-Names-Draft', + url: 'https://modelcontextprotocol.io/specification/draft/server/tools#tool-names' + }, + // Background only; the dated spec above is the rule source. + { + id: 'SEP-986-History', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986' + }, + { + id: 'SEP-986-Spec-Integration', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1603' + } +]; + +/** The Tool Names rules first appear in the 2025-11-25 revision. */ +export function toolNameFormatCheckApplies(specVersion: SpecVersion): boolean { + return specVersionAtLeast(specVersion, '2025-11-25'); +} + +export function validateToolNameFormat(name: string): string | null { + if (name.length < 1 || name.length > TOOL_NAME_MAX_LENGTH) { + return `length ${name.length} is outside 1-${TOOL_NAME_MAX_LENGTH}`; + } + if (!TOOL_NAME_PATTERN.test(name)) { + return 'contains characters outside [A-Za-z0-9_.-]'; + } + return null; +} + +export function buildToolsNameFormatCheck( + tools: ReadonlyArray<{ name?: unknown }> | undefined +): ConformanceCheck { + const timestamp = new Date().toISOString(); + const baseCheck = { + id: 'tools-name-format', + name: 'ToolsNameFormat', + description: + 'Tool names SHOULD be 1-128 characters and match ^[A-Za-z0-9_.-]+$', + specReferences: TOOLS_NAME_FORMAT_SPEC_REFS, + timestamp + }; + + if (!Array.isArray(tools) || tools.length === 0) { + return { + ...baseCheck, + status: 'INFO', + errorMessage: 'No tools advertised; nothing to validate', + details: { toolCount: 0 } + }; + } + + const toolResults: Record = {}; + const violations: string[] = []; + + tools.forEach((tool, index) => { + const name = typeof tool.name === 'string' ? tool.name : ''; + const key = name || ``; + const reason = + typeof tool.name === 'string' + ? validateToolNameFormat(tool.name) + : 'name is not a string'; + if (reason) { + toolResults[key] = `invalid: ${reason}`; + violations.push(`${key}: ${reason}`); + } else { + toolResults[key] = 'valid'; + } + }); + + return { + ...baseCheck, + status: violations.length === 0 ? 'SUCCESS' : 'WARNING', + errorMessage: + violations.length > 0 + ? `${violations.length} tool name(s) violate spec Tool Names SHOULD rules: ${violations.join('; ')}` + : undefined, + details: { + toolCount: tools.length, + results: toolResults + } + }; +} + +/** How many consecutive tools/list snapshots the ordering check compares. */ +const TOOLS_LIST_ORDER_PROBES = 3; + +const TOOLS_LIST_ORDER_SPEC_REFS = [ + { + id: 'MCP-Tools-Deterministic-Order', + url: 'https://modelcontextprotocol.io/specification/2026-07-28/server/tools#capabilities' + } +]; + +/** + * Build the tools-list-deterministic-order check from consecutive tools/list + * snapshots. + * + * 2026-07-28 server/tools.mdx: "Servers SHOULD return tools in a deterministic + * order (i.e., the same ordering across requests when the underlying set of + * tools has not changed)." SHOULD, so a violation is WARNING. + * + * The spec scopes the SHOULD to an unchanged set, so a set that differs + * between probes is reported as untestable (issue #248) rather than as a + * violation: from the outside, a sample cannot tell a nondeterministic server + * from one whose tools legitimately changed between two requests. + */ +export function buildToolsListDeterministicOrderCheck( + snapshots: ReadonlyArray | undefined> +): ConformanceCheck { + const timestamp = new Date().toISOString(); + const baseCheck = { + id: 'tools-list-deterministic-order', + name: 'ToolsListDeterministicOrder', + description: + 'Consecutive tools/list requests return the same tools in the same order', + specReferences: TOOLS_LIST_ORDER_SPEC_REFS, + source: { introducedIn: '2026-07-28' as const }, + timestamp + }; + const untestable = (reason: string): ConformanceCheck => ({ + ...baseCheck, + status: 'WARNING', + errorMessage: notTestable(reason), + details: { untestable: true, reason, probes: snapshots.length } + }); + + if (snapshots.length < 2) { + return untestable( + `needs at least two tools/list snapshots to compare, got ${snapshots.length}` + ); + } + const missing = snapshots.findIndex((tools) => !Array.isArray(tools)); + if (missing !== -1) { + return untestable( + `tools/list probe ${missing + 1} did not return a tools array` + ); + } + + // A position-independent placeholder, so that a nameless tool moving + // around reads as an order change rather than as a set change. + const orders = snapshots.map((tools) => + (tools as ReadonlyArray<{ name?: unknown }>).map((tool) => + typeof tool.name === 'string' ? tool.name : '' + ) + ); + + // Nothing to order when no probe saw two tools. + if (orders.every((order) => order.length < 2)) { + return { + ...baseCheck, + status: 'INFO', + errorMessage: `${orders[0].length} tool(s) advertised; nothing to compare`, + details: { toolCount: orders[0].length, probes: orders.length, orders } + }; + } + + // The SHOULD only binds while the set is unchanged: compare multisets first. + const countNames = (names: string[]): Map => { + const counts = new Map(); + for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1); + return counts; + }; + const baseline = countNames(orders[0]); + for (let probe = 1; probe < orders.length; probe++) { + const current = countNames(orders[probe]); + const added: string[] = []; + const removed: string[] = []; + for (const [name, count] of current) { + const before = baseline.get(name) ?? 0; + if (count > before) added.push(name); + } + for (const [name, count] of baseline) { + const now = current.get(name) ?? 0; + if (count > now) removed.push(name); + } + if (added.length > 0 || removed.length > 0) { + return untestable( + `the set of tools changed between tools/list probe 1 and probe ${probe + 1} ` + + `(added: ${added.join(', ') || 'none'}; removed: ${removed.join(', ') || 'none'}), ` + + 'so the deterministic-order SHOULD does not apply to this sample' + ); + } + } + + const toolCount = orders[0].length; + + for (let probe = 1; probe < orders.length; probe++) { + const index = orders[probe].findIndex((name, i) => name !== orders[0][i]); + if (index !== -1) { + return { + ...baseCheck, + status: 'WARNING', + errorMessage: + `tools/list returned the same ${toolCount} tools in a different order across ` + + `consecutive requests: probe ${probe + 1} diverges from probe 1 at index ${index} ` + + `(${orders[0][index]} vs ${orders[probe][index]})`, + details: { toolCount, probes: orders.length, orders } + }; + } + } + + return { + ...baseCheck, + status: 'SUCCESS', + details: { toolCount, probes: orders.length, orders } + }; +} + export class ToolsListScenario implements ClientScenario { name = 'tools-list'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test listing available tools. **Server Implementation Requirements:** @@ -25,15 +260,18 @@ export class ToolsListScenario implements ClientScenario { - Each tool MUST have: - \`name\` (string) - \`description\` (string) - - \`inputSchema\` (valid JSON Schema object)`; + - \`inputSchema\` (valid JSON Schema object) +- From 2025-11-25 onward, advertised \`name\` values SHOULD follow the spec Tool Names rules (1–128 chars, \`[A-Za-z0-9_.-]\` only) — see \`tools-name-format\` check +- From 2026-07-28: return tools in a deterministic order across requests + when the set of tools has not changed (SHOULD)`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.listTools(); + const result = await conn.request('tools/list'); // Validate response structure const errors: string[] = []; @@ -72,7 +310,43 @@ export class ToolsListScenario implements ClientScenario { } }); - await connection.close(); + if (toolNameFormatCheckApplies(ctx.specVersion)) { + checks.push(buildToolsNameFormatCheck(result.tools)); + } + + // 2026-07-28: tools SHOULD come back in a deterministic order across + // requests. Take two more consecutive tools/list snapshots and compare. + if (specVersionAtLeast(ctx.specVersion, '2026-07-28')) { + const snapshots: Array = [ + result.tools + ]; + let probeError: unknown; + try { + while (snapshots.length < TOOLS_LIST_ORDER_PROBES) { + const again = await conn.request('tools/list'); + snapshots.push(again.tools); + } + } catch (error) { + probeError = error; + } + checks.push( + probeError === undefined + ? buildToolsListDeterministicOrderCheck(snapshots) + : { + ...untestableCheck( + 'tools-list-deterministic-order', + 'ToolsListDeterministicOrder', + 'Consecutive tools/list requests return the same tools in the same order', + `repeated tools/list request failed: ${probeError instanceof Error ? probeError.message : String(probeError)}`, + TOOLS_LIST_ORDER_SPEC_REFS, + 'WARNING' + ), + source: { introducedIn: '2026-07-28' } + } + ); + } + + await conn.close(); } catch (error) { checks.push({ id: 'tools-list', @@ -96,7 +370,7 @@ export class ToolsListScenario implements ClientScenario { export class ToolsCallSimpleTextScenario implements ClientScenario { name = 'tools-call-simple-text'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test calling a tool that returns simple text. **Server Implementation Requirements:** @@ -114,13 +388,13 @@ Implement tool \`test_simple_text\` with no arguments that returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.callTool({ + const result = await conn.request('tools/call', { name: 'test_simple_text' /* omit arguments as it is not required in the schema */ }); @@ -157,7 +431,7 @@ Implement tool \`test_simple_text\` with no arguments that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-simple-text', @@ -181,7 +455,7 @@ Implement tool \`test_simple_text\` with no arguments that returns: export class ToolsCallImageScenario implements ClientScenario { name = 'tools-call-image'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test calling a tool that returns image content. **Server Implementation Requirements:** @@ -202,13 +476,13 @@ Implement tool \`test_image_content\` with no arguments that returns: **Implementation Note**: Use a minimal test image (e.g., 1x1 red pixel PNG)`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.callTool({ + const result = await conn.request('tools/call', { name: 'test_image_content', arguments: {} }); @@ -245,7 +519,7 @@ Implement tool \`test_image_content\` with no arguments that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-image', @@ -269,7 +543,7 @@ Implement tool \`test_image_content\` with no arguments that returns: export class ToolsCallMultipleContentTypesScenario implements ClientScenario { name = 'tools-call-mixed-content'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test tool returning multiple content types. **Server Implementation Requirements:** @@ -300,13 +574,13 @@ Implement tool \`test_multiple_content_types\` with no arguments that returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.callTool({ + const result = await conn.request('tools/call', { name: 'test_multiple_content_types', arguments: {} }); @@ -346,7 +620,7 @@ Implement tool \`test_multiple_content_types\` with no arguments that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-mixed-content', @@ -370,7 +644,10 @@ Implement tool \`test_multiple_content_types\` with no arguments that returns: export class ToolsCallWithLoggingScenario implements ClientScenario { name = 'tools-call-with-logging'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test tool that sends log messages during execution. **Server Implementation Requirements:** @@ -386,11 +663,12 @@ Implement tool \`test_tool_with_logging\` with no arguments. **Implementation Note**: The delays are important to test that clients can receive multiple log notifications during tool execution`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); const notifications = new NotificationCollector(connection.client); // Set logging level to debug @@ -459,7 +737,7 @@ Implement tool \`test_tool_with_logging\` with no arguments. export class ToolsCallErrorScenario implements ClientScenario { name = 'tools-call-error'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test tool error reporting. **Server Implementation Requirements:** @@ -482,13 +760,13 @@ Implement tool \`test_error_handling\` with no arguments. } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result: any = await connection.client.callTool({ + const result: any = await conn.request('tools/call', { name: 'test_error_handling', arguments: {} }); @@ -520,7 +798,7 @@ Implement tool \`test_error_handling\` with no arguments. } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-error', @@ -544,7 +822,7 @@ Implement tool \`test_error_handling\` with no arguments. export class ToolsCallWithProgressScenario implements ClientScenario { name = 'tools-call-with-progress'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test tool that reports progress notifications. **Server Implementation Requirements:** @@ -575,31 +853,32 @@ If no progress token provided, just execute with delays. } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); - const progressUpdates: Array = []; - // TODO: investigate why await connection.client.callTool didn't work for progress. - const result = await connection.client.request( - { - method: 'tools/call', - params: { - name: 'test_tool_with_progress', - arguments: {}, - _meta: { - progressToken: 'progress-test-1' - } - } - }, - CallToolResultSchema, - { - onprogress: (progress) => { - progressUpdates.push(progress); - } + const conn = await ctx.connect(); + const result = await conn.request('tools/call', { + name: 'test_tool_with_progress', + arguments: {}, + _meta: { + progressToken: 'progress-test-1' } - ); + }); + + const progressUpdates = conn.notifications + .filter((n) => n.method === 'notifications/progress') + .map( + (n) => + n.params as { + progressToken?: string | number; + progress: number; + total?: number; + } + ) + // The server must echo the request's progressToken back; ignore + // notifications for other tokens. + .filter((p) => p.progressToken === 'progress-test-1'); const errors: string[] = []; if (progressUpdates.length === 0) { @@ -635,12 +914,12 @@ If no progress token provided, just execute with delays. ], details: { progressCount: progressUpdates.length, - progressNotifications: progressUpdates.map((n: Progress) => n), + progressNotifications: progressUpdates, result } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-with-progress', @@ -664,7 +943,10 @@ If no progress token provided, just execute with delays. export class ToolsCallSamplingScenario implements ClientScenario { name = 'tools-call-sampling'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test tool that requests LLM sampling from client. **Server Implementation Requirements:** @@ -709,11 +991,12 @@ Implement tool \`test_sampling\` with argument: **Implementation Note**: If the client doesn't support sampling (no \`sampling\` capability), return an error.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); let samplingRequested = false; connection.client.setRequestHandler( @@ -792,7 +1075,10 @@ Implement tool \`test_sampling\` with argument: export class ToolsCallElicitationScenario implements ClientScenario { name = 'tools-call-elicitation'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test tool that requests user input (elicitation) from client. **Server Implementation Requirements:** @@ -842,11 +1128,12 @@ Implement tool \`test_elicitation\` with argument: **Implementation Note**: If the client doesn't support elicitation (no \`elicitation\` capability), return an error.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); let elicitationRequested = false; connection.client.setRequestHandler( @@ -923,7 +1210,7 @@ Implement tool \`test_elicitation\` with argument: export class ToolsCallAudioScenario implements ClientScenario { name = 'tools-call-audio'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test calling a tool that returns audio content. **Server Implementation Requirements:** @@ -944,13 +1231,13 @@ Implement tool \`test_audio_content\` with no arguments that returns: **Implementation Note**: Use a minimal test audio file`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.callTool({ + const result = await conn.request('tools/call', { name: 'test_audio_content', arguments: {} }); @@ -994,7 +1281,7 @@ Implement tool \`test_audio_content\` with no arguments that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-audio', @@ -1018,7 +1305,7 @@ Implement tool \`test_audio_content\` with no arguments that returns: export class ToolsCallEmbeddedResourceScenario implements ClientScenario { name = 'tools-call-embedded-resource'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test calling a tool that returns embedded resource content. **Server Implementation Requirements:** @@ -1040,13 +1327,13 @@ Implement tool \`test_embedded_resource\` with no arguments that returns: } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); - const result = await connection.client.callTool({ + const result = await conn.request('tools/call', { name: 'test_embedded_resource', arguments: {} }); @@ -1093,7 +1380,7 @@ Implement tool \`test_embedded_resource\` with no arguments that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-embedded-resource', diff --git a/src/scenarios/server/utils.ts b/src/scenarios/server/utils.ts index 0a4a391..b229715 100644 --- a/src/scenarios/server/utils.ts +++ b/src/scenarios/server/utils.ts @@ -2,12 +2,20 @@ * Utilities test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { connectToServer } from './client-helper'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import type { RunContext } from '../../connection'; +import type { CompleteResult, EmptyResult } from '../../spec-types/2025-06-18'; export class LoggingSetLevelScenario implements ClientScenario { name = 'logging-set-level'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test setting logging level. **Server Implementation Requirements:** @@ -29,14 +37,16 @@ export class LoggingSetLevelScenario implements ClientScenario { - \`alert\` - \`emergency\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); // Send logging/setLevel request - const result = await connection.client.setLoggingLevel('info'); + const result = await conn.request('logging/setLevel', { + level: 'info' + }); // Validate response (should return empty object {}) const errors: string[] = []; @@ -62,7 +72,7 @@ export class LoggingSetLevelScenario implements ClientScenario { } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'logging-set-level', @@ -86,7 +96,10 @@ export class LoggingSetLevelScenario implements ClientScenario { export class PingScenario implements ClientScenario { name = 'ping'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test ping utility for connection health check. **Server Implementation Requirements:** @@ -119,14 +132,14 @@ export class PingScenario implements ClientScenario { **Implementation Note**: The ping utility allows either party to verify that their counterpart is still responsive and the connection is alive.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); // Send ping request - const result = await connection.client.ping(); + const result = await conn.request('ping'); // Validate response (should return empty object {}) const errors: string[] = []; @@ -152,7 +165,7 @@ export class PingScenario implements ClientScenario { } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'ping', @@ -176,7 +189,7 @@ export class PingScenario implements ClientScenario { export class CompletionCompleteScenario implements ClientScenario { name = 'completion-complete'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test completion endpoint. **Server Implementation Requirements:** @@ -220,14 +233,14 @@ export class CompletionCompleteScenario implements ClientScenario { **Implementation Note**: For conformance testing, completion support can be minimal or return empty arrays. The capability just needs to be declared and the endpoint must respond correctly.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const conn = await ctx.connect(); // Send completion/complete request - const result = await connection.client.complete({ + const result = await conn.request('completion/complete', { ref: { type: 'ref/prompt', name: 'test_prompt_with_arguments' @@ -267,7 +280,7 @@ export class CompletionCompleteScenario implements ClientScenario { } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'completion-complete', diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index 0b8e652..7e5f451 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -1,94 +1,166 @@ import { describe, it, expect } from 'vitest'; import { listScenarios, - listClientScenarios, listScenariosForSpec, + listDraftScenarios, + listDraftClientScenarios, + listActiveClientScenarios, + listExtensionScenarios, + getScenario, getScenarioSpecVersions, - ALL_SPEC_VERSIONS + matchesSpecVersion, + resolveSpecVersion, + ALL_SPEC_VERSIONS, + scenarios, + clientScenarios } from './index'; +import { + DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION +} from '../types'; describe('specVersions helpers', () => { - it('every Scenario has specVersions', () => { - for (const name of listScenarios()) { - const versions = getScenarioSpecVersions(name); - expect( - versions, - `scenario "${name}" is missing specVersions` - ).toBeDefined(); - expect(versions!.length).toBeGreaterThan(0); - for (const v of versions!) { - expect(ALL_SPEC_VERSIONS).toContain(v); - } + // The ScenarioSource union (introducedIn XOR extensionId) is enforced by the + // type system; no runtime invariant test is needed. + + it('listScenariosForSpec returns scenarios whose range covers that version', () => { + const selected = listScenariosForSpec('2025-06-18'); + expect(selected.length).toBeGreaterThan(0); + for (const name of selected) { + const tags = getScenarioSpecVersions(name); + expect(tags).toContain('2025-06-18'); } }); - it('every ClientScenario has specVersions', () => { - for (const name of listClientScenarios()) { - const versions = getScenarioSpecVersions(name); - expect( - versions, - `client scenario "${name}" is missing specVersions` - ).toBeDefined(); - expect(versions!.length).toBeGreaterThan(0); - for (const v of versions!) { - expect(ALL_SPEC_VERSIONS).toContain(v); + it('scenarios with removedIn do not appear in versions at or after the cutoff', () => { + for (const v of ALL_SPEC_VERSIONS) { + const selected = new Set(listScenariosForSpec(v)); + const vIdx = ALL_SPEC_VERSIONS.indexOf(v); + for (const name of listScenarios()) { + const src = scenarios.get(name)!.source; + if ('extensionId' in src || src.removedIn === undefined) continue; + if (vIdx >= ALL_SPEC_VERSIONS.indexOf(src.removedIn)) { + expect( + selected.has(name), + `scenario "${name}" (removedIn ${src.removedIn}) should not appear in --spec-version ${v}` + ).toBe(false); + } } } }); - it('listScenariosForSpec returns scenarios that include that version', () => { - const scenarios = listScenariosForSpec('2025-06-18'); - expect(scenarios.length).toBeGreaterThan(0); - for (const name of scenarios) { - expect(getScenarioSpecVersions(name)).toContain('2025-06-18'); - } - }); - it('2025-11-25 includes scenarios carried forward from 2025-06-18', () => { const base = listScenariosForSpec('2025-06-18'); const current = listScenariosForSpec('2025-11-25'); - // scenarios tagged with both versions should appear in both lists const currentSet = new Set(current); - // at least some overlap (carried-forward scenarios) const overlap = base.filter((s) => currentSet.has(s)); expect(overlap.length).toBeGreaterThan(0); - // current should have more total (new 2025-11-25-only scenarios) expect(current.length).toBeGreaterThan(overlap.length); }); - it('2025-11-25 does not include 2025-03-26-only scenarios', () => { - const backcompat = listScenariosForSpec('2025-03-26'); - const current = listScenariosForSpec('2025-11-25'); - const currentSet = new Set(current); - // backcompat-only scenarios should not appear in 2025-11-25 - for (const name of backcompat) { - const versions = getScenarioSpecVersions(name)!; - if (!versions.includes('2025-11-25')) { - expect(currentSet.has(name)).toBe(false); + it('every scenario in latest but not in draft is explicitly removedIn: DRAFT', () => { + const latest = new Set(listScenariosForSpec(LATEST_SPEC_VERSION)); + const draft = new Set(listScenariosForSpec(DRAFT_PROTOCOL_VERSION)); + for (const name of latest) { + if (!draft.has(name)) { + const s = getScenario(name)!; + expect( + 'removedIn' in s.source && s.source.removedIn, + `"${name}" is in ${LATEST_SPEC_VERSION} but not in draft without removedIn` + ).toBe(DRAFT_PROTOCOL_VERSION); + } + } + for (const name of listDraftScenarios()) { + expect(draft.has(name)).toBe(true); + } + }); + + it('draft-introduced scenarios are not matched by any dated spec version', () => { + for (const name of listDraftScenarios()) { + for (const dated of DATED_SPEC_VERSIONS) { + const selected = new Set(listScenariosForSpec(dated)); + expect( + selected.has(name), + `draft scenario "${name}" should not appear in --spec-version ${dated}` + ).toBe(false); } } }); - it('draft and extension scenarios are isolated', () => { - const draft = listScenariosForSpec('draft'); - for (const name of draft) { - expect(getScenarioSpecVersions(name)).toContain('draft'); - } - const ext = listScenariosForSpec('extension'); - for (const name of ext) { - expect(getScenarioSpecVersions(name)).toContain('extension'); - } + it("resolveSpecVersion accepts 'draft' as an alias", () => { + expect(resolveSpecVersion('draft')).toBe(DRAFT_PROTOCOL_VERSION); + expect(resolveSpecVersion(LATEST_SPEC_VERSION)).toBe(LATEST_SPEC_VERSION); }); - it('draft scenarios are not in dated versions', () => { - const draft = listScenariosForSpec('draft'); - const dated = new Set([ - ...listScenariosForSpec('2025-03-26'), - ...listScenariosForSpec('2025-06-18'), - ...listScenariosForSpec('2025-11-25') - ]); - for (const name of draft) { - expect(dated.has(name)).toBe(false); + describe('matchesSpecVersion (per-check gating)', () => { + const src = { introducedIn: '2025-11-25' } as const; + it.each(['2025-11-25', DRAFT_PROTOCOL_VERSION] as const)( + 'includes %s', + (v) => expect(matchesSpecVersion(src, v)).toBe(true) + ); + it.each(['2025-03-26', '2025-06-18'] as const)('excludes %s', (v) => + expect(matchesSpecVersion(src, v)).toBe(false) + ); + }); + + it('extension-tagged scenarios are not selected by any --spec-version', () => { + for (const version of ALL_SPEC_VERSIONS) { + const selected = new Set(listScenariosForSpec(version)); + for (const name of listExtensionScenarios()) { + expect( + selected.has(name), + `extension scenario "${name}" was selected by --spec-version ${version}` + ).toBe(false); + } + } + }); +}); + +describe('draft suite membership', () => { + it('every scenario introduced in the draft spec is selected by its draft suite', () => { + const draftClientTesting = new Set(listDraftScenarios()); + for (const [name, scenario] of scenarios) { + if ( + 'introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ) { + expect( + draftClientTesting.has(name), + `client-testing scenario "${name}" should be in the draft suite` + ).toBe(true); + } + } + + const draftServerTesting = new Set(listDraftClientScenarios()); + for (const [name, scenario] of clientScenarios) { + if ( + 'introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ) { + expect( + draftServerTesting.has(name), + `server-testing scenario "${name}" should be in the draft suite` + ).toBe(true); + } + } + }); + + it('the draft suite covers the non-auth draft client scenarios', () => { + const draft = new Set(listDraftScenarios()); + expect(draft.has('request-metadata')).toBe(true); + expect(draft.has('http-standard-headers')).toBe(true); + expect(draft.has('sep-2322-client-request-state')).toBe(true); + }); + + it('draft server-testing scenarios are excluded from the active suite', () => { + const active = new Set(listActiveClientScenarios()); + expect(listDraftClientScenarios().length).toBeGreaterThan(0); + for (const name of listDraftClientScenarios()) { + expect( + active.has(name), + `draft scenario "${name}" should not be in the active suite` + ).toBe(false); } }); }); diff --git a/src/scenarios/untestable.test.ts b/src/scenarios/untestable.test.ts new file mode 100644 index 0000000..4ad06b8 --- /dev/null +++ b/src/scenarios/untestable.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { notTestable, untestableCheck } from './untestable'; + +describe('untestable helpers', () => { + it('notTestable formats the stable prefix', () => { + expect(notTestable('server lacks tool X')).toBe( + 'Not testable: server lacks tool X' + ); + }); + + it('untestableCheck defaults to FAILURE and marks details.untestable', () => { + const check = untestableCheck( + 'some-check-id', + 'SomeCheck', + 'A MUST requirement', + 'server lacks the diagnostic tool', + [{ id: 'SEP-0000', url: 'https://example.com' }] + ); + expect(check).toMatchObject({ + id: 'some-check-id', + status: 'FAILURE', + errorMessage: 'Not testable: server lacks the diagnostic tool', + details: { untestable: true } + }); + }); + + it('untestableCheck supports WARNING severity for SHOULD requirements', () => { + const check = untestableCheck( + 'should-check-id', + 'ShouldCheck', + 'A SHOULD requirement', + 'missing hook', + [{ id: 'SEP-0000', url: 'https://example.com' }], + 'WARNING' + ); + expect(check.status).toBe('WARNING'); + expect(check.errorMessage).toBe('Not testable: missing hook'); + }); +}); diff --git a/src/scenarios/untestable.ts b/src/scenarios/untestable.ts new file mode 100644 index 0000000..5ebd822 --- /dev/null +++ b/src/scenarios/untestable.ts @@ -0,0 +1,55 @@ +/** + * "Untestable" check policy (issue #248). + * + * A check whose prerequisite is missing — the server under test lacks a + * diagnostic fixture tool, rejects the probe that would exercise the + * requirement, or advertises a feature it does not actually serve — MUST NOT + * report SKIPPED: SKIPPED is excluded from pass/fail counts, exit codes, and + * the expected-failures baseline, so the run reads as green and the gap is + * invisible to anyone burning down a conformance list. Instead the check + * fails, with an errorMessage that names the missing prerequisite, so the + * result is red until the prerequisite exists and the scenario can sit in an + * expected-failures baseline meanwhile (the documented escape hatch). + * + * SKIPPED remains correct only for checks that are legitimately not + * applicable: an optional capability the server never claimed (e.g. a SHOULD + * check gated on `prompts.listChanged` the server did not declare), or + * spec-version inapplicability handled by the runner. + */ + +import type { ConformanceCheck, SpecReference } from '../types'; + +/** + * Format the errorMessage for a check that could not be exercised. The + * stable prefix lets reports and dashboards distinguish "requirement was + * violated" from "requirement could not be verified" without a new status. + */ +export function notTestable(reason: string): string { + return `Not testable: ${reason}`; +} + +/** + * Build a failing check for a requirement that could not be exercised + * because a prerequisite is missing. `severity` follows the spec keyword of + * the underlying requirement (MUST -> FAILURE, SHOULD -> WARNING), matching + * what the check could have reported had it run. + */ +export function untestableCheck( + id: string, + name: string, + description: string, + reason: string, + specReferences: SpecReference[], + severity: 'FAILURE' | 'WARNING' = 'FAILURE' +): ConformanceCheck { + return { + id, + name, + description, + status: severity, + timestamp: new Date().toISOString(), + errorMessage: notTestable(reason), + specReferences, + details: { untestable: true, reason } + }; +} diff --git a/src/schemas.ts b/src/schemas.ts index 5e81c8c..b8f6783 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; -import { getScenario, getClientScenario } from './scenarios'; +import { + getScenario, + getClientScenario, + getClientScenarioForAuthorizationServer +} from './scenarios'; // Client command options schema export const ClientOptionsSchema = z.object({ @@ -7,12 +11,9 @@ export const ClientOptionsSchema = z.object({ scenario: z .string() .min(1, 'Scenario cannot be empty') - .refine( - (scenario) => getScenario(scenario) !== undefined, - (scenario) => ({ - message: `Unknown scenario '${scenario}'` - }) - ), + .refine((scenario) => getScenario(scenario) !== undefined, { + error: (iss) => `Unknown scenario '${iss.input}'` + }), timeout: z .string() .transform((val) => parseInt(val, 10)) @@ -33,28 +34,58 @@ export const ServerOptionsSchema = z.object({ url: z.string().url('Invalid server URL'), scenario: z .string() - .refine( - (scenario) => getClientScenario(scenario) !== undefined, - (scenario) => ({ - message: `Unknown scenario '${scenario}'` - }) - ) + .refine((scenario) => getClientScenario(scenario) !== undefined, { + error: (iss) => `Unknown scenario '${iss.input}'` + }) .optional() }); export type ServerOptions = z.infer; +// Authorization server command options schema +export const AuthorizationServerOptionsSchema = z.object({ + url: z.string().url('Invalid authorization server URL'), + scenario: z + .string() + .refine( + (scenario) => + getClientScenarioForAuthorizationServer(scenario) !== undefined, + { + error: (iss) => `Unknown scenario '${iss.input}'` + } + ) + .optional(), + clientId: z.string().min(1, 'Client id cannot be empty').optional(), + clientSecret: z.string().min(1, 'Client secret cannot be empty').optional(), + // RFC 8707 §2: the resource value is an absolute URI with no fragment. + resource: z + .string() + .refine((value) => URL.canParse(value), 'Resource must be an absolute URI') + .refine( + (value) => !URL.canParse(value) || !new URL(value).hash, + 'Resource must not include a fragment' + ) + .optional(), + port: z + .number() + .int('Port must be an integer') + .min(1, 'Port must be >= 1') + .max(65535, 'Port must be <= 65535') + .default(3000) +}); + +export type AuthorizationServerOptions = z.infer< + typeof AuthorizationServerOptionsSchema +>; + // Interactive command options schema export const InteractiveOptionsSchema = z.object({ scenario: z .string() .min(1, 'Scenario cannot be empty') - .refine( - (scenario) => getScenario(scenario) !== undefined, - (scenario) => ({ - message: `Unknown scenario '${scenario}'` - }) - ) + .refine((scenario) => getScenario(scenario) !== undefined, { + error: (iss) => `Unknown scenario '${iss.input}'` + }) }); export type InteractiveOptions = z.infer; diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 9a5e249..ecfe524 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -21,16 +21,24 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ z.object({ name: z.literal('auth/pre-registration'), client_id: z.string(), - client_secret: z.string() + client_secret: z.string(), + issuer: z.string() }), z.object({ - name: z.literal('auth/cross-app-access-complete-flow'), + name: z.literal('auth/enterprise-managed-authorization'), client_id: z.string(), client_secret: z.string(), idp_client_id: z.string(), idp_id_token: z.string(), idp_issuer: z.string(), idp_token_endpoint: z.string() + }), + z.object({ + name: z.literal('auth/wif-jwt-bearer'), + client_id: z.string(), + valid_jwt: z.string(), + wrong_audience_jwt: z.string(), + expired_jwt: z.string() }) ]); diff --git a/src/sdk-runner/checkout.ts b/src/sdk-runner/checkout.ts new file mode 100644 index 0000000..fb91e07 --- /dev/null +++ b/src/sdk-runner/checkout.ts @@ -0,0 +1,126 @@ +import { spawn } from 'child_process'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface SdkSpec { + name: string; + ref: string; +} + +/** + * A parsed `[@]` argument. `ref` is left undefined when the user + * omits `@` so the caller can fall back to a per-SDK default branch + * (KNOWN_SDKS `defaultRef`) before settling on `main`. + */ +export interface ParsedSdkSpec { + name: string; + ref?: string; +} + +const DEFAULT_ORG = 'modelcontextprotocol'; + +export function parseSdkSpec(spec: string): ParsedSdkSpec { + const at = spec.lastIndexOf('@'); + if (at <= 0) { + return { name: spec }; + } + // A trailing `@` (empty ref) is treated as "no ref given" so the caller's + // defaultRef/main fallback applies, rather than checking out the empty ref. + const ref = spec.slice(at + 1); + return ref ? { name: spec.slice(0, at), ref } : { name: spec.slice(0, at) }; +} + +function repoUrl(name: string): string { + if (name.includes('/')) { + return `https://github.com/${name}.git`; + } + return `https://github.com/${DEFAULT_ORG}/${name}.git`; +} + +async function git( + args: string[], + cwd: string +): Promise<{ stdout: string; stderr: string }> { + const cmd = 'git'; + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject( + new Error( + `${cmd} ${args.join(' ')} exited with ${code}\n${stderr || stdout}` + ) + ); + } + }); + }); +} + +async function dirExists(dir: string): Promise { + try { + const stat = await fs.stat(dir); + return stat.isDirectory(); + } catch { + return false; + } +} + +/** + * Ensure an SDK is checked out at the requested ref under cacheDir. + * Clones on first use; on subsequent calls fetches and resets to the ref. + * Returns the absolute path to the checkout. + */ +export async function ensureCheckout( + spec: SdkSpec, + cacheDir: string +): Promise { + const safeName = spec.name.replace(/\//g, '__'); + // Key the checkout by ref as well, so different refs of the same repo (e.g. + // the typescript-sdk `main` and typescript-sdk-v1 `v1.x` entries) get their + // own directory instead of thrashing one checkout between refs/build systems. + const safeRef = spec.ref.replace(/[^a-zA-Z0-9._-]/g, '_'); + const dir = path.resolve(cacheDir, safeName, safeRef); + await fs.mkdir(path.dirname(dir), { recursive: true }); + + if (await dirExists(path.join(dir, '.git'))) { + console.error(`[sdk] Fetching ${spec.name} (cached at ${dir})`); + await git(['fetch', '--tags', 'origin'], dir); + } else { + console.error(`[sdk] Cloning ${repoUrl(spec.name)} -> ${dir}`); + await git(['clone', repoUrl(spec.name), dir], path.dirname(dir)); + } + + // Try the ref as a remote branch first, then fall back to a local-resolvable + // ref (tag or SHA). + const candidates = [`origin/${spec.ref}`, spec.ref]; + let resolved: string | undefined; + for (const candidate of candidates) { + try { + await git(['rev-parse', '--verify', `${candidate}^{commit}`], dir); + resolved = candidate; + break; + } catch { + // rev-parse failure means this candidate doesn't exist; try the next form + } + } + if (!resolved) { + throw new Error( + `Ref '${spec.ref}' not found in ${spec.name} (tried ${candidates.join(', ')})` + ); + } + + console.error(`[sdk] Checking out ${spec.name}@${spec.ref} (${resolved})`); + await git(['checkout', '--detach', resolved], dir); + + const { stdout } = await git(['rev-parse', '--short', 'HEAD'], dir); + console.error(`[sdk] HEAD is ${stdout.trim()}`); + + return dir; +} diff --git a/src/sdk-runner/config.ts b/src/sdk-runner/config.ts new file mode 100644 index 0000000..edcd77a --- /dev/null +++ b/src/sdk-runner/config.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import { + DRAFT_PROTOCOL_VERSION, + SPEC_VERSION_TIMELINE, + isSpecVersion +} from '../types'; + +// Fields an entry may vary per targeted spec version. Identity fields +// (repo / defaultRef / specVersion) can't vary — they pick what to clone, +// not how to drive it. `server` is partial here so an override can change +// just the command (go-sdk) or just the url (csharp-sdk). +const SpecOverrideSchema = z.object({ + build: z.string().optional(), + client: z + .object({ + command: z.string() + }) + .optional(), + server: z + .object({ + command: z.string().optional(), + url: z.string().url().optional(), + readyTimeoutMs: z.number().int().positive().optional() + }) + .optional(), + expectedFailures: z.string().optional() +}); + +export type SpecOverride = z.infer; + +export const SdkConfigSchema = z.object({ + // Clone this repo instead of the KNOWN_SDKS key — lets an alias entry + // (e.g. typescript-sdk-v1) point at the real repo (typescript-sdk). + repo: z.string().optional(), + // Ref to check out when the SDK is named with no @ref (the "default branch"). + defaultRef: z.string().optional(), + build: z.string().optional(), + client: z + .object({ + command: z.string() + }) + .optional(), + server: z + .object({ + command: z.string(), + url: z.string().url(), + readyTimeoutMs: z.number().int().positive().optional() + }) + .optional(), + expectedFailures: z.string().optional(), + // Spec version this SDK targets, used as the default --spec-version when + // the flag isn't given (e.g. a v1 SDK pinned to the latest dated spec). + // An explicit --spec-version on the sdk command always wins. + specVersion: z.string().optional(), + // Per-spec-version defaults, keyed by the canonical spec version a run + // targets (--spec-version after alias resolution — so keys are the dated + // strings, never 'draft' — or the entry's own specVersion). Matched entries + // are merged over the base config field-by-field before CLI flags apply, so + // `sdk go-sdk --mode server --spec-version 2026-07-28` picks up the right + // server invocation with no manual overrides. + // Precedence: CLI flag > specOverrides > base entry. + specOverrides: z + .record(z.string(), SpecOverrideSchema) + .superRefine((overrides, ctx) => { + // Keys must be canonical spec versions: a 'draft' key (or a typo'd + // date) would silently never match, because the requested version is + // resolved to its dated form before the lookup. + for (const key of Object.keys(overrides)) { + if (!isSpecVersion(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + `specOverrides key '${key}' is not a spec version. ` + + `Use one of: ${SPEC_VERSION_TIMELINE.join(', ')} ` + + `('draft' resolves to ${DRAFT_PROTOCOL_VERSION} before lookup, so key the dated form).` + }); + } + } + }) + .optional() +}); + +export type SdkConfig = z.infer; + +/** + * Resolve the config to use for a run targeting `specVersion`: the base + * entry with the matching specOverrides entry (if any) merged on top. + * Returns the input unchanged when nothing matches; never mutates it. + */ +export function resolveConfigForSpec( + config: SdkConfig, + specVersion: string | undefined +): SdkConfig { + const override = specVersion + ? config.specOverrides?.[specVersion] + : undefined; + if (!override) return config; + const server = + config.server || override.server + ? { ...config.server, ...override.server } + : undefined; + return { + ...config, + build: override.build ?? config.build, + client: override.client ?? config.client, + // The base schema guarantees command+url when a base server exists; an + // override without a base server must itself be complete to be usable. + server: server as SdkConfig['server'], + expectedFailures: override.expectedFailures ?? config.expectedFailures + }; +} diff --git a/src/sdk-runner/index.ts b/src/sdk-runner/index.ts new file mode 100644 index 0000000..bcd5b20 --- /dev/null +++ b/src/sdk-runner/index.ts @@ -0,0 +1,364 @@ +import { spawn, ChildProcess } from 'child_process'; +import path from 'path'; +import { Command, Option } from 'commander'; +import { SdkConfig, resolveConfigForSpec } from './config'; +import { parseSdkSpec, ensureCheckout } from './checkout'; +import { lookupBuiltinConfig, knownSdkNames } from './known-sdks'; +import { resolveSpecVersion } from '../scenarios'; + +type Mode = 'client' | 'server'; + +export function execShell(command: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, { shell: true, cwd, stdio: 'inherit' }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`Command failed (exit ${code}): ${command}`)); + }); + }); +} + +/** + * Re-invoke this CLI as a subprocess so scenario selection / reporting stay in + * one place (same approach tier-check uses). Preserves execArgv so tsx/loader + * hooks carry over when running from source. + */ +function selfInvoke(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [...process.execArgv, process.argv[1], ...args], + { cwd, stdio: 'inherit' } + ); + child.on('error', reject); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +async function waitForReady(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + // Per-probe timeout: a server that accepts the socket but never responds must + // not block past the overall deadline (fetch has no timeout of its own). + const probeTimeoutMs = 2000; + let lastErr: unknown; + while (Date.now() < deadline) { + try { + await fetch(url, { + method: 'GET', + signal: AbortSignal.timeout(probeTimeoutMs) + }); + return; + } catch (err) { + lastErr = err; + await new Promise((r) => setTimeout(r, 250)); + } + } + throw new Error( + `Server at ${url} did not become ready within ${timeoutMs}ms: ${lastErr}` + ); +} + +export async function withManagedServer( + command: string, + cwd: string, + url: string, + readyTimeoutMs: number, + fn: () => Promise +): Promise { + console.error(`[sdk] Starting server: ${command}`); + const child: ChildProcess = spawn(command, { + shell: true, + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32' + }); + + let stderr = ''; + child.stdout?.on('data', (d) => process.stderr.write(`[server] ${d}`)); + child.stderr?.on('data', (d) => { + stderr += d.toString(); + process.stderr.write(`[server] ${d}`); + }); + + let stopping = false; + const exited = new Promise((_, reject) => { + child.on('exit', (code) => { + if (stopping) return; + reject( + new Error( + `Server exited with code ${code} before tests completed\n${stderr}` + ) + ); + }); + child.on('error', reject); + }); + exited.catch(() => {}); + + try { + await Promise.race([waitForReady(url, readyTimeoutMs), exited]); + console.error(`[sdk] Server ready at ${url}`); + return await Promise.race([fn(), exited]); + } finally { + stopping = true; + console.error(`[sdk] Stopping server`); + const gone = new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) resolve(); + else child.once('exit', () => resolve()); + }); + const signal = (sig: NodeJS.Signals) => { + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, sig); + } catch { + child.kill(sig); + } + } else { + child.kill(sig); + } + }; + signal('SIGTERM'); + // Wait for the process to actually release its port: a consecutive run on + // the same port must never race a dying predecessor. Escalate if needed. + const timer = (ms: number) => new Promise((r) => setTimeout(r, ms)); + if ((await Promise.race([gone.then(() => true), timer(5000)])) !== true) { + signal('SIGKILL'); + await Promise.race([gone, timer(2000)]); + } + } +} + +function passThrough(options: { + scenario?: string; + suite?: string; + timeout?: string; + verbose?: boolean; + output?: string; + specVersion?: string; + requirements?: string; +}): string[] { + const args: string[] = []; + // A requirement set replaces scenario/suite/spec-version selection: it names + // the scenarios AND pins the wire version they run at. + if (options.requirements) { + args.push('--requirements', options.requirements); + } else if (options.scenario) { + args.push('--scenario', options.scenario); + } else if (options.suite) { + args.push('--suite', options.suite); + } + if (options.timeout) args.push('--timeout', options.timeout); + if (options.verbose) args.push('--verbose'); + if (options.output) args.push('-o', options.output); + if (!options.requirements && options.specVersion) + args.push('--spec-version', options.specVersion); + return args; +} + +export function createSdkCommand(): Command { + return new Command('sdk') + .description( + 'Run the local conformance build against an SDK checked out at a specific ref' + ) + .argument( + '[sdk]', + 'SDK to test as [@], e.g. typescript-sdk@main. Name may be owner/repo.' + ) + .option( + '--path ', + 'Use an existing local SDK checkout instead of cloning' + ) + .option( + '--cache-dir ', + 'Directory for cached SDK clones', + '.sdk-under-test' + ) + .addOption( + new Option( + '--mode ', + 'Which side to test (required): client or server' + ).choices(['client', 'server']) + ) + .option('--scenario ', 'Run a single scenario (passed through)') + .option('--suite ', 'Run a suite (passed through)') + .option('--skip-build', 'Skip the SDK build step (reuse prior build)') + .option('--build-cmd ', 'Override the build command from config') + .option('--client-cmd ', 'Override the client command from config') + .option('--server-cmd ', 'Override the server command from config') + .option('--server-url ', 'Override the server URL from config') + .option( + '--expected-failures ', + 'Override the expected-failures baseline file from config' + ) + .option('--timeout ', 'Per-scenario client timeout (passed through)') + .option('-o, --output ', 'Output directory (passed through)') + .option( + '--spec-version ', + 'Spec version to target (passed through; defaults to the SDK config)' + ) + .option( + '--requirements ', + "Run exactly the scenarios this spec revision requires, at that revision's wire, with the SDK invocation resolved for that revision (e.g. 2026-07-28). Replaces --suite/--scenario/--spec-version" + ) + .option('--verbose', 'Verbose output (passed through)') + .action(async (sdkArg: string | undefined, options) => { + try { + const mode = options.mode as Mode | undefined; + if (!mode) { + throw new Error(`--mode is required (client | server)`); + } + if (!sdkArg && !options.path) { + throw new Error( + `Provide an SDK spec (e.g. typescript-sdk@main) or --path` + ); + } + + const spec = sdkArg ? parseSdkSpec(sdkArg) : undefined; + const sdkName = + spec?.name ?? path.basename(path.resolve(options.path!)); + + // Resolution: CLI flag > built-in entry (KNOWN_SDKS). + const baseConfig: SdkConfig = lookupBuiltinConfig(sdkName) ?? {}; + + // The targeted spec version (explicit flag wins over the per-SDK + // default) selects that version's specOverrides entry, so version- + // specific invocations live in config instead of copy-paste flags. + // 'draft' resolves to its dated alias so overlay keys stay canonical. + if ( + options.requirements && + (options.specVersion || options.suite || options.scenario) + ) { + throw new Error( + '--requirements cannot be combined with --spec-version, --suite or --scenario: a requirement set already fixes which scenarios run and at which wire.' + ); + } + // A requirements revision doubles as the spec version for config + // resolution, so the specOverrides entry for that revision picks the + // right server command/url (go-sdk needs a different process per wire + // era, csharp-sdk a different endpoint). + const requestedSpec = + options.requirements ?? options.specVersion ?? baseConfig.specVersion; + const specVersion: string | undefined = requestedSpec + ? resolveSpecVersion(requestedSpec) + : undefined; + const builtinConfig = resolveConfigForSpec(baseConfig, specVersion); + + // The built-in entry may be an alias (e.g. typescript-sdk-v1): honor its + // `repo` (real clone target) and `defaultRef` (branch when no @ref given). + const dir = options.path + ? path.resolve(options.path) + : await ensureCheckout( + { + name: builtinConfig.repo ?? spec!.name, + ref: spec!.ref ?? builtinConfig.defaultRef ?? 'main' + }, + options.cacheDir + ); + const buildCmd: string | undefined = + options.buildCmd ?? builtinConfig.build; + const clientCmd: string | undefined = + options.clientCmd ?? builtinConfig.client?.command; + const serverCmd: string | undefined = + options.serverCmd ?? builtinConfig.server?.command; + const serverUrl: string | undefined = + options.serverUrl ?? builtinConfig.server?.url; + // CLI override resolves relative to the user's invocation cwd; the + // built-in default resolves relative to the SDK checkout. + // Under --requirements the frozen set decides pass/fail and tier-check + // ignores baselines entirely, so auto-attaching the SDK's own baseline + // would let the two commands return opposite verdicts for the same + // run. An explicit --expected-failures remains a deliberate choice. + const expectedFailures = options.expectedFailures + ? path.resolve(options.expectedFailures) + : builtinConfig.expectedFailures && !options.requirements + ? path.resolve(dir, builtinConfig.expectedFailures) + : undefined; + // Resolve -o to an absolute path so it lands where the user expects, + // not relative to the SDK checkout (selfInvoke runs with cwd = dir). + const output = options.output + ? path.resolve(options.output) + : undefined; + if (buildCmd && !options.skipBuild) { + console.error(`[sdk] Building: ${buildCmd}`); + await execShell(buildCmd, dir); + } else if (!buildCmd) { + console.error( + `[sdk] No build command in config; assuming SDK is already built` + ); + } + + let exitCode: number; + + if (mode === 'client') { + if (!clientCmd) { + throw new Error( + `No client command for '${sdkName}'. Pass --client-cmd, or add it to KNOWN_SDKS in src/sdk-runner/known-sdks.ts (known: ${knownSdkNames().join(', ')}).` + ); + } + const args = [ + 'client', + '--command', + clientCmd, + ...passThrough({ + scenario: options.scenario, + suite: options.requirements + ? undefined + : (options.suite ?? 'all'), + timeout: options.timeout, + verbose: options.verbose, + output, + specVersion, + requirements: options.requirements + }) + ]; + if (expectedFailures) + args.push('--expected-failures', expectedFailures); + console.error(`\n[sdk] conformance ${args.join(' ')}\n`); + exitCode = await selfInvoke(args, dir); + } else { + if (!serverCmd || !serverUrl) { + throw new Error( + `No server command/url for '${sdkName}'. Pass --server-cmd / --server-url, or add it to KNOWN_SDKS in src/sdk-runner/known-sdks.ts (known: ${knownSdkNames().join(', ')}).` + ); + } + const args = [ + 'server', + '--url', + serverUrl, + ...passThrough({ + scenario: options.scenario, + // Default to the `active` suite (excludes pending/draft) — the same + // suite tiering runs, and the most reasonable default to avoid + // surfacing intentionally-deferred `pending` scenarios. + suite: options.requirements + ? undefined + : (options.suite ?? 'active'), + verbose: options.verbose, + output, + specVersion, + requirements: options.requirements + }) + ]; + if (expectedFailures) + args.push('--expected-failures', expectedFailures); + exitCode = await withManagedServer( + serverCmd, + dir, + serverUrl, + builtinConfig.server?.readyTimeoutMs ?? 15000, + async () => { + console.error(`\n[sdk] conformance ${args.join(' ')}\n`); + return selfInvoke(args, dir); + } + ); + } + + process.exit(exitCode); + } catch (error) { + console.error( + `[sdk] ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } + }); +} diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts new file mode 100644 index 0000000..843590d --- /dev/null +++ b/src/sdk-runner/known-sdks.ts @@ -0,0 +1,196 @@ +import type { SdkConfig } from './config'; + +/** + * Built-in conformance configs for official SDKs, keyed by repo name. + * + * These live here (not in the SDK repos) so adding an SDK to the matrix + * doesn't require a coordinated cross-repo PR. Any field can be overridden + * per-invocation via the CLI flags (--build-cmd / --client-cmd / etc.). + */ +export const KNOWN_SDKS: Record = { + // v2 — the monorepo on `main` (pnpm). Default ref is `main`. + 'typescript-sdk': { + build: 'pnpm install && pnpm run build:all', + client: { + command: 'npx tsx test/conformance/src/everythingClient.ts' + }, + server: { + command: 'npx tsx test/conformance/src/everythingServer.ts', + url: 'http://localhost:3000/mcp' + }, + expectedFailures: 'test/conformance/expected-failures.yaml' + }, + // v1.x — the published npm line. Same fixtures as v2; differs only in the + // build (npm, not pnpm) and the baseline filename. Clones the typescript-sdk + // repo, defaulting to the `v1.x` branch. Targets the latest dated spec, so + // draft-only scenarios and checks are excluded by default. + 'typescript-sdk-v1': { + repo: 'typescript-sdk', + defaultRef: 'v1.x', + specVersion: '2025-11-25', + build: 'npm ci && npm run build', + client: { + command: 'npx tsx test/conformance/src/everythingClient.ts' + }, + server: { + command: 'npx tsx test/conformance/src/everythingServer.ts', + url: 'http://localhost:3000/mcp' + }, + expectedFailures: 'test/conformance/conformance-baseline.yml' + }, + // Fixtures live under conformance/ (everything-client + everything-server, + // mirroring scripts/{client,server}-conformance.sh). The server's -stateless + // flag defaults to true; the dated-spec (`active` suite) scenarios need the + // stateful transport, so the base command pins -stateless=false and the + // 2026-07-28 override drops it for the SEP-2575 stateless lifecycle. + 'go-sdk': { + build: + 'go build -o ./.conformance-server ./conformance/everything-server && go build -o ./.conformance-client ./conformance/everything-client', + client: { + command: './.conformance-client' + }, + server: { + command: './.conformance-server -http=localhost:3000 -stateless=false', + url: 'http://localhost:3000' + }, + expectedFailures: 'conformance/baseline.yml', + specOverrides: { + '2026-07-28': { + server: { command: './.conformance-server -http=localhost:3000' } + } + } + }, + // main — targets the 2026-07-28 revision. Same uv workspace layout as v1.x + // (client fixture in .github/actions/conformance/, mcp-everything-server + // workspace package). Two baselines exist upstream, one per spec target; + // the 2026-07-28 override picks the matching one. + 'python-sdk': { + // Mirrors the SDK's own CI, which syncs the two conformance packages + // individually (--inexact so the second sync doesn't prune the first). + // --all-packages is unbuildable on main: an example package's declared + // README is missing, and uv builds every workspace member. + build: + 'uv sync --frozen --all-extras --package mcp-everything-server && uv sync --frozen --all-extras --package mcp --inexact', + client: { + command: 'uv run --frozen python .github/actions/conformance/client.py' + }, + server: { + command: 'uv run --frozen mcp-everything-server --port 3000', + url: 'http://localhost:3000/mcp' + }, + expectedFailures: '.github/actions/conformance/expected-failures.yml', + specOverrides: { + '2026-07-28': { + expectedFailures: + '.github/actions/conformance/expected-failures.2026-07-28.yml' + } + } + }, + // v1.x — the stable, published line of the python-sdk, analogous to + // typescript-sdk-v1. Clones the python-sdk repo, defaulting to the `v1.x` + // branch, and targets the latest + // dated spec so draft-only scenarios/checks are excluded by default. uv + // workspace: the `mcp` (client) and `mcp-everything-server` (server) packages + // are both members, so one `uv sync --all-packages` covers both modes. + // Fixtures live in the python-sdk repo (.github/actions/conformance/ and + // examples/servers/everything-server). `--port 3000` matches the url and the + // 3000 convention used above; the server's own default is 3001. + 'python-sdk-v1': { + repo: 'python-sdk', + defaultRef: 'v1.x', + specVersion: '2025-11-25', + build: 'uv sync --frozen --all-extras --all-packages', + client: { + command: 'uv run --frozen python .github/actions/conformance/client.py' + }, + server: { + command: 'uv run --frozen mcp-everything-server --port 3000', + url: 'http://localhost:3000/mcp' + }, + expectedFailures: '.github/actions/conformance/expected-failures.yml' + }, + // Fixtures live in conformance/ (the mcp-conformance package's + // conformance-client + conformance-server bins; the package is excluded from + // the workspace default-members, so build it explicitly). The server reads + // PORT and STATELESS from the environment: the stateful (dated-spec) + // lifecycle is the default, and the 2026-07-28 override sets STATELESS=1 + // for the SEP-2575 stateless lifecycle. + // One default server serves every revision — rust-sdk's own CI starts a + // single un-flagged conformance-server and runs both the 2025-11-25 and + // 2026-07-28 legs against it. (A STATELESS=1 override here used to force the + // 2026 leg onto a mode the SDK's CI never exercises, failing its SEP-2575 + // input-required scenarios.) + 'rust-sdk': { + build: 'cargo build -p mcp-conformance', + client: { + command: './target/debug/conformance-client' + }, + server: { + command: 'PORT=3000 ./target/debug/conformance-server', + url: 'http://localhost:3000/mcp' + } + }, + // Fixtures live in tests/ModelContextProtocol.ConformanceClient and + // tests/ModelContextProtocol.ConformanceServer (requires the .NET 10 SDK, + // per global.json); build output goes to the repo-level artifacts/ tree + // (UseArtifactsOutput), not per-project bin/. The client binary takes the scenario as its first + // argument rather than reading MCP_CONFORMANCE_SCENARIO, so the command + // bridges the env var into argv ($1 is the server URL the harness appends). + // The server serves the stateful lifecycle at / and the SEP-2575 stateless + // lifecycle at /stateless from the same port; the 2026-07-28 override + // points runs at the stateless endpoint. + 'csharp-sdk': { + build: + 'dotnet build tests/ModelContextProtocol.ConformanceClient -c Release -f net10.0 && dotnet build tests/ModelContextProtocol.ConformanceServer -c Release -f net10.0', + client: { + command: `bash -c 'exec dotnet artifacts/bin/ModelContextProtocol.ConformanceClient/Release/net10.0/ModelContextProtocol.ConformanceClient.dll "$MCP_CONFORMANCE_SCENARIO" "$1"' conformance-client` + }, + server: { + // Launch from the build-output directory: ASP.NET resolves + // appsettings.json from the content root (the cwd), and that file + // carries the AllowedHosts filter dns-rebinding-protection tests. + // Launched from the repo root it silently never loads. + command: + "bash -c 'cd artifacts/bin/ModelContextProtocol.ConformanceServer/Release/net10.0 && exec dotnet ModelContextProtocol.ConformanceServer.dll --urls http://localhost:3000'", + url: 'http://localhost:3000' + }, + specOverrides: { + '2026-07-28': { + server: { url: 'http://localhost:3000/stateless' } + } + } + }, + // Fixtures live under conformance/ (server.rb + client.rb, the same files + // the SDK's own `rake conformance` CI drives). The client reads + // MCP_CONFORMANCE_SCENARIO from the environment and the server URL from argv. + // One dual-era server serves every revision from the same /mcp endpoint — + // the stateful (dated-spec) handshake and the SEP-2575 stateless lifecycle + // side by side — so no specOverrides are needed. The rake task reads PORT + // from the environment (the SDK's own default is 9292); PORT=3000 matches + // the 3000 convention used above. + 'ruby-sdk': { + build: 'bundle install', + client: { + command: 'bundle exec ruby conformance/client.rb' + }, + server: { + command: 'PORT=3000 bundle exec rake conformance:server', + url: 'http://localhost:3000/mcp' + }, + expectedFailures: 'conformance/expected_failures.yml' + } +}; + +/** + * Look up a built-in config by SDK name. Accepts bare names (typescript-sdk), + * owner/repo (modelcontextprotocol/typescript-sdk), or a checkout path + * basename — only the final path segment is used as the key. + */ +export function lookupBuiltinConfig(name: string): SdkConfig | null { + const key = name.split('/').pop() ?? name; + return KNOWN_SDKS[key] ?? null; +} + +export function knownSdkNames(): string[] { + return Object.keys(KNOWN_SDKS); +} diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts new file mode 100644 index 0000000..76fd7e5 --- /dev/null +++ b/src/sdk-runner/sdk-runner.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest'; +import { parseSdkSpec } from './checkout'; +import { lookupBuiltinConfig, KNOWN_SDKS } from './known-sdks'; +import { SdkConfigSchema, resolveConfigForSpec } from './config'; + +describe('parseSdkSpec', () => { + it('leaves ref undefined when omitted (resolved later via defaultRef/main)', () => { + expect(parseSdkSpec('typescript-sdk')).toEqual({ + name: 'typescript-sdk' + }); + }); + + it('splits name@ref', () => { + expect(parseSdkSpec('typescript-sdk@v1.29.0')).toEqual({ + name: 'typescript-sdk', + ref: 'v1.29.0' + }); + }); + + it('handles owner/repo@ref', () => { + expect(parseSdkSpec('someorg/some-sdk@abc123')).toEqual({ + name: 'someorg/some-sdk', + ref: 'abc123' + }); + }); + + it('treats leading @ as part of the name', () => { + expect(parseSdkSpec('@scope/pkg')).toEqual({ + name: '@scope/pkg' + }); + }); + + it('treats a trailing @ as no ref (falls through to defaultRef/main)', () => { + expect(parseSdkSpec('typescript-sdk@')).toEqual({ name: 'typescript-sdk' }); + }); +}); + +describe('SdkConfigSchema', () => { + it('accepts a minimal client-only config', () => { + const cfg = SdkConfigSchema.parse({ + client: { command: 'tsx fixture.ts' } + }); + expect(cfg.client?.command).toBe('tsx fixture.ts'); + expect(cfg.server).toBeUndefined(); + }); + + it('accepts an optional specVersion default', () => { + const cfg = SdkConfigSchema.parse({ + client: { command: 'tsx fixture.ts' }, + specVersion: '2025-11-25' + }); + expect(cfg.specVersion).toBe('2025-11-25'); + }); + + it('rejects server config without a url', () => { + expect(() => + SdkConfigSchema.parse({ server: { command: 'tsx server.ts' } }) + ).toThrow(); + }); +}); + +describe('lookupBuiltinConfig', () => { + it('finds an SDK by bare name', () => { + expect(lookupBuiltinConfig('typescript-sdk')?.client?.command).toBeTruthy(); + }); + + it('strips owner/ prefix and path segments', () => { + expect(lookupBuiltinConfig('modelcontextprotocol/typescript-sdk')).toBe( + KNOWN_SDKS['typescript-sdk'] + ); + expect(lookupBuiltinConfig('/some/path/to/go-sdk')).toBe( + KNOWN_SDKS['go-sdk'] + ); + }); + + it('returns null for unknown SDKs', () => { + expect(lookupBuiltinConfig('swift-sdk')).toBeNull(); + }); + + it('exposes python-sdk-v1 with repo + defaultRef + specVersion and both commands', () => { + const py = lookupBuiltinConfig('python-sdk-v1'); + expect(py?.repo).toBe('python-sdk'); + expect(py?.defaultRef).toBe('v1.x'); + expect(py?.specVersion).toBe('2025-11-25'); + expect(py?.client?.command).toContain('client.py'); + expect(py?.server?.command).toContain('mcp-everything-server'); + expect(py?.server?.url).toBe('http://localhost:3000/mcp'); + }); + + it('exposes the typescript-sdk-v1 alias with repo + defaultRef', () => { + const v1 = lookupBuiltinConfig('typescript-sdk-v1'); + expect(v1?.repo).toBe('typescript-sdk'); + expect(v1?.defaultRef).toBe('v1.x'); + }); + + it('typescript-sdk-v1 defaults to the latest dated spec version', () => { + expect(lookupBuiltinConfig('typescript-sdk-v1')?.specVersion).toBe( + '2025-11-25' + ); + }); + + it('bare typescript-sdk (v2) has no defaultRef or specVersion default', () => { + expect(lookupBuiltinConfig('typescript-sdk')?.defaultRef).toBeUndefined(); + expect(lookupBuiltinConfig('typescript-sdk')?.specVersion).toBeUndefined(); + }); + + it('go-sdk exposes both fixtures from conformance/ and a baseline', () => { + const go = lookupBuiltinConfig('go-sdk'); + expect(go?.build).toContain('./conformance/everything-server'); + expect(go?.build).toContain('./conformance/everything-client'); + expect(go?.client?.command).toBe('./.conformance-client'); + expect(go?.server?.url).toBe('http://localhost:3000'); + expect(go?.expectedFailures).toBe('conformance/baseline.yml'); + }); + + it('exposes python-sdk (main) with both commands and no ref/spec pin', () => { + const py = lookupBuiltinConfig('python-sdk'); + expect(py?.repo).toBeUndefined(); + expect(py?.defaultRef).toBeUndefined(); + expect(py?.specVersion).toBeUndefined(); + expect(py?.client?.command).toContain('client.py'); + expect(py?.server?.command).toContain('mcp-everything-server'); + expect(py?.server?.url).toBe('http://localhost:3000/mcp'); + }); + + it('exposes csharp-sdk with dotnet fixtures and the scenario-argv bridge', () => { + const cs = lookupBuiltinConfig('csharp-sdk'); + expect(cs?.build).toContain('ModelContextProtocol.ConformanceClient'); + expect(cs?.build).toContain('ModelContextProtocol.ConformanceServer'); + // The C# client takes the scenario as argv[0]; the command bridges the + // MCP_CONFORMANCE_SCENARIO env var into it. + expect(cs?.client?.command).toContain('$MCP_CONFORMANCE_SCENARIO'); + expect(cs?.server?.url).toBe('http://localhost:3000'); + }); + + it('exposes rust-sdk with the mcp-conformance fixture bins', () => { + const rs = lookupBuiltinConfig('rust-sdk'); + expect(rs?.build).toBe('cargo build -p mcp-conformance'); + expect(rs?.client?.command).toBe('./target/debug/conformance-client'); + expect(rs?.server?.command).toContain('conformance-server'); + expect(rs?.server?.url).toBe('http://localhost:3000/mcp'); + }); + + it('exposes ruby-sdk with the conformance/ fixtures and a baseline', () => { + const rb = lookupBuiltinConfig('ruby-sdk'); + expect(rb?.build).toBe('bundle install'); + expect(rb?.client?.command).toBe('bundle exec ruby conformance/client.rb'); + expect(rb?.server?.command).toBe( + 'PORT=3000 bundle exec rake conformance:server' + ); + expect(rb?.server?.url).toBe('http://localhost:3000/mcp'); + expect(rb?.expectedFailures).toBe('conformance/expected_failures.yml'); + // One dual-era server serves every revision, so no per-spec overrides. + expect(rb?.specOverrides).toBeUndefined(); + }); + + it('every built-in entry validates against SdkConfigSchema', () => { + for (const [name, cfg] of Object.entries(KNOWN_SDKS)) { + expect(() => SdkConfigSchema.parse(cfg), name).not.toThrow(); + } + }); +}); + +describe('resolveConfigForSpec', () => { + it("rejects a 'draft' or typo'd specOverrides key at schema level", () => { + const bad = { + server: { command: 'x', url: 'http://localhost:3000' }, + specOverrides: { draft: { server: { command: 'y' } } } + }; + expect(() => SdkConfigSchema.parse(bad)).toThrow(/not a spec version/); + const typo = { ...bad, specOverrides: { '2026-7-28': {} } }; + expect(() => SdkConfigSchema.parse(typo)).toThrow(/not a spec version/); + }); + + it('returns the base config when no spec version is given', () => { + const go = KNOWN_SDKS['go-sdk']; + expect(resolveConfigForSpec(go, undefined)).toBe(go); + }); + + it('returns the base config for a version with no override', () => { + const go = KNOWN_SDKS['go-sdk']; + expect(resolveConfigForSpec(go, '2025-11-25')).toBe(go); + }); + + it('swaps the whole server command for go-sdk at 2026-07-28', () => { + const resolved = resolveConfigForSpec(KNOWN_SDKS['go-sdk'], '2026-07-28'); + expect(resolved.server?.command).toBe( + './.conformance-server -http=localhost:3000' + ); + // untouched fields carry through + expect(resolved.server?.url).toBe('http://localhost:3000'); + expect(resolved.client?.command).toBe('./.conformance-client'); + expect(resolved.expectedFailures).toBe('conformance/baseline.yml'); + }); + + it('merges a partial server override (csharp url) over the base command', () => { + const resolved = resolveConfigForSpec( + KNOWN_SDKS['csharp-sdk'], + '2026-07-28' + ); + expect(resolved.server?.url).toBe('http://localhost:3000/stateless'); + expect(resolved.server?.command).toContain( + 'ModelContextProtocol.ConformanceServer.dll' + ); + }); + + it('swaps the expected-failures baseline for python-sdk at 2026-07-28', () => { + const resolved = resolveConfigForSpec( + KNOWN_SDKS['python-sdk'], + '2026-07-28' + ); + expect(resolved.expectedFailures).toBe( + '.github/actions/conformance/expected-failures.2026-07-28.yml' + ); + expect(resolved.server?.command).toContain('mcp-everything-server'); + }); + + it('runs rust-sdk with the same default server at every revision', () => { + // Mirrors rust-sdk's own CI, which starts one un-flagged server and runs + // both the 2025-11-25 and 2026-07-28 legs against it. A STATELESS=1 + // override here forced the 2026 leg onto a mode that CI never exercises + // and that fails its SEP-2575 input-required scenarios. + for (const rev of ['2025-11-25', '2026-07-28']) { + const resolved = resolveConfigForSpec(KNOWN_SDKS['rust-sdk'], rev); + expect(resolved.server?.command).toBe( + 'PORT=3000 ./target/debug/conformance-server' + ); + } + }); + + it('does not mutate the base entry', () => { + const go = KNOWN_SDKS['go-sdk']; + const before = JSON.stringify(go); + resolveConfigForSpec(go, '2026-07-28'); + expect(JSON.stringify(go)).toBe(before); + }); +}); diff --git a/src/seps/sep-1932.yaml b/src/seps/sep-1932.yaml new file mode 100644 index 0000000..c3c650f --- /dev/null +++ b/src/seps/sep-1932.yaml @@ -0,0 +1,48 @@ +sep: 1932 +spec_url: https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx +requirements: + - check: sep-1932-client-dpop-auth-scheme + text: 'When making requests to protected MCP server resources, clients MUST include the access token using the `Authorization` header with the `DPoP` authentication scheme as specified in RFC 9449 Section 7.1' + - check: sep-1932-client-fresh-proof + text: 'When making requests to protected MCP server resources, clients MUST include a fresh DPoP proof in the `DPoP` header' + - check: sep-1932-client-token-request-proof + text: 'To obtain a DPoP-bound access token, the client MUST include a DPoP proof in the `DPoP` header of its token request (RFC 9449 Section 5)' + - check: sep-1932-client-as-nonce + text: 'When the authorization server responds with `use_dpop_nonce`, the client MUST retry the token request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 8)' + - check: sep-1932-client-rs-nonce + text: 'When the MCP server responds with `use_dpop_nonce`, the client MUST retry the request with a DPoP proof that includes the supplied `nonce` (RFC 9449 Section 9)' + - check: sep-1932-server-validate-proof + text: 'MCP servers MUST validate DPoP proofs according to RFC 9449 Section 4.3' + - check: sep-1932-server-iat-window + text: "Implementations conforming to this specification MUST verify that the `iat` claim is within ±5 minutes of the server's current time / MCP servers that are not capable of keeping state or performing global `jti` tracking MUST provide DPoP proof replay protection by enforcing short `iat` acceptance windows of ±5 minutes and standard RFC 9449 claim validation" + - check: sep-1932-server-reject-401 + text: 'If any validation step fails, the MCP server MUST reject the request with an HTTP 401 response and include appropriate error information in the `WWW-Authenticate` header as specified in RFC 9449 Section 7.1' + - check: sep-1932-as-metadata-alg-values + text: 'Authorization servers supporting DPoP MUST include the `dpop_signing_alg_values_supported` field in their Authorization Server Metadata as defined in RFC 9449 Section 5.1. This field MUST contain a JSON array listing the JWS algorithm values supported for DPoP proof JWTs' + - check: sep-1932-as-no-none-alg + text: 'Only asymmetric signature algorithms are permitted; the `none` algorithm MUST NOT be included' + - check: sep-1932-as-dpop-bound-enforcement + text: 'When `dpop_bound_access_tokens` is set to `true`, the authorization server MUST reject token requests from the client that do not include a valid DPoP proof' + - check: sep-1932-as-token-binding + text: "When issuing a DPoP-bound access token, the authorization server MUST bind it to the client's DPoP public key by including a `cnf` claim carrying the JWK SHA-256 thumbprint (`jkt`) of that key (RFC 9449 Section 6) and MUST set the token response `token_type` to `DPoP` (RFC 9449 Section 5)" + - check: sep-1932-asymmetric-alg-only + text: 'Only asymmetric signature algorithms MUST be used for DPoP proofs. Symmetric algorithms and the `none` algorithm MUST NOT be permitted as specified in RFC 9449 Section 11.6' + - check: sep-1932-server-nonce + text: 'For high-security environments, MCP servers SHOULD implement server-provided nonces as described in RFC 9449 Section 9 to further limit proof lifetime, even if it is stateless' + - check: sep-1932-server-audience-validation + text: 'MCP servers MUST continue to validate that access tokens were specifically issued for them, even when DPoP is used' + + - text: 'Implementations MUST conform to all requirements specified in this extension' + excluded: 'Umbrella requirement satisfied by the specific checks above; not separately observable on the wire.' + - text: 'Implementations MUST also conform to the baseline authorization requirements' + excluded: 'Covered by the existing baseline MCP authorization conformance suite; not DPoP-specific.' + - text: 'Authorization servers and clients SHOULD support modern, secure algorithms such as ES256 (ECDSA using P-256 and SHA-256)' + excluded: 'No crisp wire-observable behaviour; the spec explicitly does not mandate specific algorithms.' + - text: 'Implementations adopting this extension MUST follow the security considerations outlined in RFC 9449 Section 11, the baseline MCP Authorization specification security requirements, and OAuth 2.1 security best practices' + excluded: 'Umbrella reference to the security considerations of other specifications; not a discrete observable behaviour.' + - text: 'Clients MUST protect DPoP private keys using appropriate security measures. If hardware security modules or secure enclaves are available, they SHOULD be used to protect private keys to prevent key extraction' + excluded: 'Key storage is an implementation/internal concern, not observable on the wire.' + - text: 'Browser-based clients SHOULD use the Web Crypto API with non-extractable keys to prevent key exfiltration via XSS attacks' + excluded: 'Client implementation detail, not observable on the wire.' + - text: 'MCP servers requiring maximum replay protection SHOULD implement `jti` tracking despite the operational complexity' + excluded: 'Explicitly optional (servers are NOT required to track jti) and stateful/internal; only indirectly observable via replay.' diff --git a/src/seps/sep-1933.yaml b/src/seps/sep-1933.yaml new file mode 100644 index 0000000..5e72f51 --- /dev/null +++ b/src/seps/sep-1933.yaml @@ -0,0 +1,35 @@ +sep: 1933 +spec_url: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933 +requirements: + - check: wif-grant-type + text: 'The request includes grant_type: urn:ietf:params:oauth:grant-type:jwt-bearer (RFC 7523 §2.1 — clients MUST use this grant type for JWT-bearer authorization grants)' + + - check: wif-assertion-missing + text: 'The request includes assertion: (RFC 7523 §2.1 — the assertion parameter is REQUIRED and MUST contain the JWT)' + + - check: wif-assertion-verified + text: 'Client successfully authenticates to the MCP authorization server using a JWT-bearer grant with a valid workload identity JWT assertion' + + - check: wif-assertion-expired + text: 'The JWT MUST contain an exp claim and the authorization server MUST reject assertions whose exp has passed (RFC 7523 §3 — client MUST surface this error and not silently ignore it)' + + - check: wif-assertion-audience + text: 'The JWT MUST contain an aud claim identifying the authorization server (RFC 7523 §3 — client MUST surface invalid audience errors and not silently ignore them)' + + - check: wif-assertion-malformed + text: 'The authorization server MUST reject JWTs with invalid signatures or malformed claims (RFC 7523 §3 — client MUST surface verification failures)' + + - check: wif-no-retry + text: 'Clients should not retry a JWT-bearer token request after the authorization server has rejected the assertion, including after invalid_scope responses (WARNING until SEP-1933 lands normative spec text; RFC 7523 is silent on client retry behaviour)' + + - check: wif-grant-fallback + text: 'When the authorization server returns unauthorized_client for a JWT-bearer token request, the client should not fall back to a different grant type (WARNING until SEP-1933 lands normative spec text; RFC 7523 §2.1 defines the grant but does not forbid grant-type switching after failure)' + + - text: 'The JWT MUST include an iss (issuer) claim identifying the workload identity provider (RFC 7523 §3)' + excluded: 'Issuer validation is AS policy; the client presents a pre-signed token and cannot vary the iss value. The scenario does not expose issuer in context because it is not a client-observable parameter — the client has no mechanism to select or alter the iss of an assertion it did not construct.' + + - text: 'The JWT MUST include a sub (subject) claim identifying the workload (RFC 7523 §3)' + excluded: 'Subject allowlist enforcement is AS policy; the client has no control over the subject in a pre-signed IdP token. From the client perspective this collapses to a generic invalid_grant response, already covered by existing checks.' + + - text: 'The JWT MUST include a jti (JWT ID) claim to prevent replay (RFC 7523 §3, recommended)' + excluded: 'Replay detection is AS policy; the client cannot influence jti uniqueness across requests. No client-observable protocol difference from other invalid_grant responses.' diff --git a/src/seps/sep-2106.yaml b/src/seps/sep-2106.yaml new file mode 100644 index 0000000..6fc7793 --- /dev/null +++ b/src/seps/sep-2106.yaml @@ -0,0 +1,34 @@ +sep: 2106 +spec_url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications +requirements: + - check: sep-2106-no-network-ref-deref + text: 'Implementations MUST NOT automatically dereference `$ref` values that resolve to a network URI (i.e. anything that is not a same-document JSON Pointer such as `#/$defs/Foo` or an internal `$anchor`); an opt-in mode that fetches non-local `$ref`s MUST be disabled by default / Schemas that fail to validate due to an unresolved external `$ref` SHOULD be rejected rather than silently treated as permissive' + + # Client-side keyword-preservation checks, derived from issue #101 rather + # than a normative sentence in the SEP: SEP-2106 §1 "Loosen inputSchema" + # redefines `inputSchema` to permit any additional JSON Schema 2020-12 + # keyword, and a client that silently strips such a keyword while parsing + # a server's tool definition breaks that contract, since downstream + # consumers can no longer recover the intended semantics from what the + # client exposes. The scenario's SEP-1613-scoped checks ($schema, $defs, + # additionalProperties) are not declared here: their IDs are not + # sep-2106-prefixed, so the traceability manifest never collects them. + - check: sep-2106-client-composition-keywords-preserved + text: 'The `inputSchema` field "now accepts any additional JSON Schema properties. This enables: Composition keywords: anyOf, oneOf, allOf, not". Derived expectation (issue #101): a client parsing a tool definition received from a server is expected to preserve these composition keywords.' + url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#1-loosen-inputschema + - check: sep-2106-client-conditional-keywords-preserved + text: 'The `inputSchema` field "now accepts any additional JSON Schema properties. This enables: ... Conditional schemas: if/then/else". Derived expectation (issue #101): a client parsing a tool definition received from a server is expected to preserve these conditional keywords.' + url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#1-loosen-inputschema + - check: sep-2106-client-anchor-keyword-preserved + text: 'The `inputSchema` field "now accepts any additional JSON Schema properties. This enables: ... Reference schemas: $ref, $defs". Derived expectation (issue #101): a client parsing a tool definition received from a server is expected to preserve `$anchor` (a JSON Schema 2020-12 reference-anchor keyword that lives inside `$defs`).' + url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#1-loosen-inputschema + + - text: "SDK maintainers SHOULD: Document the migration in SDK release notes; Where ergonomic, provide typed helpers (e.g. generics over a tool's `outputSchema`) so consumers do not need to write narrowing guards by hand" + excluded: 'Migration/deprecation guidance for SDK maintainers; about SDK source code, not protocol-observable wire behavior' + url: https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#backward-compatibility + - text: 'JSON Schema validation already handles type checking, value constraints, and required field validation, and implementations MUST continue to validate all inputs and outputs against declared schemas' + excluded: 'Restates pre-existing schema-validation behavior and is too broad to attribute to a specific observable SEP-2106 check; input/output validation overlaps existing tool scenarios' + - text: 'An opt-in mode that fetches non-local `$ref`s SHOULD enforce an allowlist of hosts (or at minimum reject loopback, link-local, and private network addresses), apply timeouts and size limits, and log dereferenced URIs' + excluded: 'Applies only when the non-default opt-in network-$ref fetch mode is enabled; not observable in default conformance runs' + - text: 'Implementations SHOULD apply reasonable bounds — for example, a maximum schema depth, a cap on the total number of subschemas, or a per-validation time budget — to prevent a malicious tool definition from acting as a CPU DoS vector against the validator' + excluded: 'Internal validator resource limits (max schema depth, subschema cap, per-validation time budget); a defensive measure not observable on the wire' diff --git a/src/seps/sep-2164.yaml b/src/seps/sep-2164.yaml new file mode 100644 index 0000000..83c11ce --- /dev/null +++ b/src/seps/sep-2164.yaml @@ -0,0 +1,10 @@ +sep: 2164 +spec_url: https://modelcontextprotocol.io/specification/draft/server/resources#error-handling +requirements: + - check: sep-2164-no-empty-contents + text: 'Servers MUST NOT return an empty contents array for a non-existent resource' + - check: sep-2164-error-code + text: 'Servers SHOULD return standard JSON-RPC errors for common failure cases: Resource not found: -32602 (Invalid Params)' + + - text: 'clients SHOULD also accept -32002 as a resource not found error' + excluded: 'Client-side error handling is implementation-defined; not protocol-observable' diff --git a/src/seps/sep-2207.yaml b/src/seps/sep-2207.yaml new file mode 100644 index 0000000..97439df --- /dev/null +++ b/src/seps/sep-2207.yaml @@ -0,0 +1,13 @@ +sep: 2207 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-tokens +requirements: + - check: sep-2207-client-metadata-grant-types + text: 'MCP Clients that desire refresh tokens SHOULD include `refresh_token` in their `grant_types` client metadata' + + - text: 'MCP Servers (Protected Resources) SHOULD NOT include `offline_access` in `WWW-Authenticate` scope or Protected Resource Metadata `scopes_supported`, as refresh tokens are not a resource requirement' + excluded: 'The server suite does not yet exercise the SDK server as an OAuth protected resource (no Protected Resource Metadata or WWW-Authenticate probing); revisit once server-side authorization scenarios exist' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/116' + - text: 'MCP Clients that desire refresh tokens MUST keep refresh tokens confidential in transit and storage as specified in OAuth 2.1 Section 4.3' + excluded: 'Confidentiality of refresh tokens in storage is client-internal state, and in-transit (TLS) confidentiality is not exercised by the harness over localhost HTTP; not protocol-observable' + - text: 'MCP Clients that desire refresh tokens MUST NOT assume refresh tokens will be issued; the AS retains discretion' + excluded: 'A client "assuming" refresh tokens will be issued is mental-state; only manifests as general authorization-flow completion, which other checks already cover; not directly protocol-observable' diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml new file mode 100644 index 0000000..ddb06ff --- /dev/null +++ b/src/seps/sep-2243.yaml @@ -0,0 +1,57 @@ +sep: 2243 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers +requirements: + - check: sep-2243-client-includes-standard-headers + text: 'The client MUST include the standard MCP request headers on each POST request. These headers are REQUIRED for compliance.' + - check: sep-2243-header-name-case-insensitive + text: 'Clients and servers MUST use case-insensitive comparisons for header names.' + - check: sep-2243-server-reject-invalid-headers + text: 'Servers that process the request body MUST reject requests with mismatched or missing standard-header values, returning HTTP 400 Bad Request.' + - check: sep-2243-server-reject-error-code + text: 'When rejecting a request due to header validation failure, servers SHOULD include a JSON-RPC error response using error code -32020.' + - check: sep-2243-client-supports-custom-headers + text: 'MCP clients MUST support this feature [custom headers via x-mcp-header].' + - check: sep-2243-client-mirrors-designated-params + text: 'When a client invokes a tool whose definition includes such designations, conforming clients MUST mirror the designated parameter values into HTTP headers as described below.' + - check: sep-2243-x-mcp-header-not-empty + text: 'The x-mcp-header value MUST NOT be empty.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-charset + text: 'The x-mcp-header value MUST contain only ASCII characters (excluding space and `:`).' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-unique + text: 'The x-mcp-header value MUST be case-insensitively unique within a single tool definition.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-x-mcp-header-primitive-only + text: 'x-mcp-header MUST only be applied to parameters with primitive types (integer, string, boolean). Parameters with type `number` are not permitted.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-client-reject-invalid-tool + text: 'Clients MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the set of tools returned by tools/list.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers + - check: sep-2243-client-encode-values + text: 'Clients MUST encode parameter values before including them in HTTP headers: integer values MUST be converted to their decimal string representation; boolean values MUST be converted to the lowercase strings "true" or "false".' + - check: sep-2243-client-base64-unsafe + text: 'When a value cannot be safely represented as plain ASCII (e.g., contains non-ASCII characters, control characters, or leading/trailing whitespace), clients MUST use Base64 encoding of the UTF-8 representation, wrapped as =?base64?{encoded}?=.' + - check: sep-2243-server-decode-base64 + text: 'Servers and intermediaries that need to inspect these values MUST decode them accordingly.' + - check: sep-2243-client-omit-null + text: 'Parameter value is null or omitted: Client MUST omit the header.' + - check: sep-2243-server-not-expect-null + text: 'Parameter value is null or omitted: Server MUST NOT expect the header.' + - check: sep-2243-server-reject-missing-required + text: 'Required parameter is omitted: Server MUST reject with JSON-RPC error.' + - check: sep-2243-server-reject-invalid-param-chars + text: 'Servers MUST reject requests with a recognized Mcp-Param-{Name} header that contain invalid characters.' + - check: sep-2243-server-validate-param-match + text: 'Any server that processes the message body MUST validate that encoded header values, after decoding if Base64-encoded, match the corresponding parameter values in the body.' + - check: sep-2243-server-reject-param-mismatch + text: 'Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32020 if any validation fails.' + + - text: 'Clients SHOULD log a warning when rejecting a tool definition due to invalid x-mcp-header, including the tool name and the reason.' + excluded: 'Log output is not wire-observable.' + - text: 'Server developers SHOULD NOT mark sensitive parameters (such as passwords, API keys, tokens, or PII) with x-mcp-header.' + excluded: 'Design guidance to humans; not protocol-observable.' + - text: 'Intermediaries MUST return an appropriate HTTP error status for validation failures.' + excluded: 'Intermediary requirement; conformance harness tests clients and servers, not intermediaries.' + - text: 'Intermediate servers that do not recognize an Mcp-Param-{Name} header MUST forward it and otherwise ignore it.' + excluded: 'Intermediary requirement; conformance harness tests clients and servers, not intermediaries.' diff --git a/src/seps/sep-2260.yaml b/src/seps/sep-2260.yaml new file mode 100644 index 0000000..b5e0306 --- /dev/null +++ b/src/seps/sep-2260.yaml @@ -0,0 +1,38 @@ +sep: 2260 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/transports#streamable-http +requirements: + - text: 'roots/list, sampling/createMessage, and elicitation/create requests MUST NOT be sent on standalone streams.' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + - text: 'Clients MUST return standard JSON-RPC errors for common failure cases: Server sends an elicitation/create request with no associated client-to-server request: -32602 (Invalid params)' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/elicitation + - text: 'Clients SHOULD return standard JSON-RPC errors for common failure cases: Server sends a roots/list request with no associated client-to-server request: -32602 (Invalid params)' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/roots + - text: 'Clients SHOULD return errors for common failure cases: Sampling request not associated with a client-to-server request: -32602 (Invalid params)' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/sampling + + - text: 'These messages MUST relate to the originating client request.' + excluded: 'Semantic association ("relate to") is not protocol-observable. The harness cannot determine whether a server request conceptually relates to the client request without understanding application logic.' + - text: 'Implementations SHOULD prefer transport-level SSE keepalive mechanisms for idle-connection maintenance.' + excluded: 'Implementation preference for keepalive mechanism choice; not observable on the wire.' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/ping + - text: 'Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) only in association with an originating client request (e.g., during tools/call, resources/read, or prompts/get processing).' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/elicitation + - text: 'Standalone server-initiated requests of these types on independent communication streams (unrelated to any client request) are not supported and MUST NOT be implemented.' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/elicitation + - text: 'Servers MUST send sampling/createMessage requests only in association with an originating client request (e.g., during tools/call, resources/read, or prompts/get processing).' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/sampling + - text: 'Standalone server-initiated sampling on independent communication streams (unrelated to any client request) is not supported and MUST NOT be implemented.' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/sampling + - text: 'Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) only in association with an originating client request (e.g., during tools/call, resources/read, or prompts/get processing).' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/roots + - text: 'Standalone server-initiated requests of these types on independent communication streams (unrelated to any client request) are not supported and MUST NOT be implemented.' + excluded: 'No longer needed this behavior is enabled by default SEP-2322 MRTR.' + url: https://modelcontextprotocol.io/specification/draft/client/roots diff --git a/src/seps/sep-2322.yaml b/src/seps/sep-2322.yaml new file mode 100644 index 0000000..143a7ed --- /dev/null +++ b/src/seps/sep-2322.yaml @@ -0,0 +1,116 @@ +sep: 2322 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr +requirements: + # ── ResultType (basic/index.mdx) ─────────────────────────────────────────── + - check: sep-2322-result-type-included + text: 'The resultType field MUST be included to indicate the type of the result.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#resulttype + + - check: sep-2322-default-result-type-complete + text: 'If resultType is not specified, clients MUST assume a default value of "complete" for backwards compatibility.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#resulttype + + # ── Supported Requests ───────────────────────────────────────────────────── + - check: sep-2322-not-on-unsupported-requests + text: 'Servers MUST NOT send InputRequiredResult responses on any other client requests.' + + # ── Server Requirements (Basic Workflow) ─────────────────────────────────── + - check: sep-2322-elicitation-incomplete + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (elicitation variant)' + + - check: sep-2322-sampling-incomplete + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling variant)' + + - check: sep-2322-list-roots-incomplete + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots variant)' + + - check: sep-2322-reject-tampered-state + text: 'If requestState influences authorization, resource access, or business logic, servers MUST protect its integrity and MUST reject state that fails verification.' + + - check: sep-2322-request-state-incomplete + text: 'Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response.' + + - check: sep-2322-respect-client-capabilities + text: 'Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities.' + + # ── Client Requirements (Basic Workflow) ─────────────────────────────────── + - check: sep-2322-client-request-state-echoed + text: 'If an InputRequiredResult contains the requestState field, the client MUST echo back the exact value of that field when retrying the original request. Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents.' + + - check: sep-2322-client-no-state-omitted + text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry.' + + - check: sep-2322-client-jsonrpc-id-different + text: 'The JSON-RPC id MUST be different between the initial request and the retry, as they are independent requests.' + + - check: sep-2322-client-parallel-isolation + text: "Both the inputRequests and requestState fields affect only the client's retry of the original request. They MUST NOT be used for any other request that the client may be sending in parallel." + + # Scenario flow gates (sep-2322-*-complete, sep-2322-multi-round-r*, + # sep-2322-non-tool-*, sep-2322-multiple-inputs-incomplete) intentionally + # have no rows here: they verify the end-to-end MRTR flow rather than a + # specific RFC-2119 sentence, so they surface in the traceability manifest's + # `untracked` list instead of claiming requirement coverage. + + # ── Error Handling ───────────────────────────────────────────────────────── + - check: sep-2322-validate-input-responses + text: 'Servers SHOULD validate that the data provided by the client is a valid InputResponses object and that the information inside can be correctly parsed.' + + - check: sep-2322-error-on-protocol-error + text: 'Protocol errors (malformed JSON, invalid schema, internal server errors) SHOULD return a JSON-RPC error response with an appropriate error code and message.' + + - check: sep-2322-ignore-unexpected-params + text: 'If additional, unexpected parameters are provided in the InputResponses object, the server SHOULD ignore any information it does not recognize or need.' + + - check: sep-2322-missing-response-rerequests + text: 'If the client fails to send all the information requested in a previous InputRequests, and the missing information is necessary for the server to process the request, the server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error.' + + # ── Excluded requirements ────────────────────────────────────────────────── + + - text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request.' + excluded: 'inputRequests is a JSON object; duplicate keys are collapsed by JSON parsing before the harness can observe them, so key uniqueness is not testable at the protocol level' + + - text: 'Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern.' + excluded: 'Architectural migration statement; tested indirectly through all MRTR scenarios' + + - text: 'servers MUST treat requestState as an attacker-controlled input' + excluded: 'Internal security posture; not observable at protocol level' + + - text: 'servers MUST protect its integrity (e.g. HMAC or AEAD)' + excluded: 'Internal implementation choice about encryption/signing; not observable at protocol level' + + - text: 'servers SHOULD include the authenticated principal, a short expiry (TTL), and an identifier for the originating request inside the integrity-protected requestState payload and verify each on receipt' + excluded: 'Internal requestState format; not observable at protocol level' + + - text: 'Servers for which a given requestState must be consumed at most once MUST enforce that invariant server-side' + excluded: 'Internal enforcement policy; conformance harness cannot determine which servers require single-use semantics' + + - text: 'Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request' + excluded: 'Server-internal robustness assumption; not observable at protocol level' + + - text: 'Servers MUST validate request state as described in the server requirements above.' + excluded: 'Duplicates integrity-protection requirements above; internal security detail' + + - text: 'Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'When tasks/get shows status input_required, clients MUST call tasks/result to get the inputRequests and optional requestState.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'Clients SHOULD construct the results of those requests and call tasks/input_response with the inputResponses & requestState (if present).' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'Receivers MUST reject tasks/input_response requests for tasks that are not in input_required status with error code -32602 (Invalid params).' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'When a receiver receives a tasks/result request for a task in working status, it MUST block the response until the task reaches a terminal status or input_required status.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'When a receiver receives a tasks/result request for a task in input_required status, it MUST return an InputRequiredResult containing the inputRequests that the requestor must fulfill.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'After sending tasks/input_response, the requestor SHOULD resume polling via tasks/get.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' diff --git a/src/seps/sep-2350.yaml b/src/seps/sep-2350.yaml new file mode 100644 index 0000000..5f3ebfb --- /dev/null +++ b/src/seps/sep-2350.yaml @@ -0,0 +1,11 @@ +sep: 2350 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#runtime-insufficient-scope-errors +requirements: + - check: sep-2350-scope-union-on-reauth + text: 'When re-authorizing, clients SHOULD include these scopes alongside any previously granted scopes to avoid losing permissions needed for other operations. / Clients SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization.' + url: https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements + - text: 'Regardless of the approach chosen, servers SHOULD include all scopes required for the current operation in a single challenge.' + excluded: '"All scopes required for the current operation" has no harness-observable ground truth; the challenge is the only place the server declares its requirements. Detecting the negative (incremental challenging) would need server-side auth scenarios that do not exist yet and would test the example app''s scope config rather than SDK behavior; the spec also permits dynamic per-request scope determination, so a second challenge is not conclusively non-conformant.' + + - text: 'When responding with insufficient scope errors, servers SHOULD include the scopes needed to satisfy the current operation in the scope parameter, consistent with RFC 6750 Section 3.1.' + excluded: 'reword of pre-existing requirement (request->operation, +RFC6750 cite); no normative delta; harness already emits scope= in WWW-Authenticate' diff --git a/src/seps/sep-2352.yaml b/src/seps/sep-2352.yaml new file mode 100644 index 0000000..5f02511 --- /dev/null +++ b/src/seps/sep-2352.yaml @@ -0,0 +1,17 @@ +sep: 2352 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-binding +requirements: + - check: sep-2352-no-cross-as-credential-reuse + text: 'Clients MUST NOT assume that credentials valid for one authorization server will be accepted by another.' + url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-location + - check: sep-2352-no-reuse-on-as-change + text: 'When the authorization server changes (detected via updated protected resource metadata), clients MUST NOT reuse client credentials from a different authorization server.' + - check: sep-2352-reregister-on-as-change + text: 'When the authorization server changes (detected via updated protected resource metadata), clients MUST re-register with the new authorization server.' + + - text: 'Clients MUST maintain separate registration state (client credentials, tokens) per authorization server.' + excluded: 'internal storage requirement; not directly observable on the wire' + - text: 'Clients that use pre-registered credentials, or persist client credentials obtained via Dynamic Client Registration, MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server issuer identifier.' + excluded: 'internal state-keying requirement; not protocol-observable' + - text: 'If the authorization server indicated by protected resource metadata no longer matches the one the credentials were registered with, clients SHOULD surface an error rather than silently attempting to use mismatched credentials.' + excluded: 'UI behavior; the negative half (do not send mismatched credentials) is covered by sep-2352-no-reuse-on-as-change' diff --git a/src/seps/sep-2468.yaml b/src/seps/sep-2468.yaml new file mode 100644 index 0000000..969f4a4 --- /dev/null +++ b/src/seps/sep-2468.yaml @@ -0,0 +1,25 @@ +sep: 2468 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-validation +requirements: + - check: sep-2468-client-validate-metadata-issuer + text: 'After retrieving a metadata document, MCP clients MUST validate it as required by RFC8414 Section 3.3 or OpenID Connect Discovery Section 4.3: the issuer value in the document MUST be identical to the issuer identifier used to construct the well-known URL. If they differ, the client MUST NOT use the metadata.' + url: https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery + - check: sep-2468-client-compare-iss-supported + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported true, iss present -> Compare to the recorded issuer using simple string comparison / Before redirecting the user-agent, the client MUST record the issuer value from the selected authorization server validated metadata document and associate it with the same per-request record used to store the PKCE code verifier.' + - check: sep-2468-client-reject-missing-iss + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported true, iss absent -> Reject the response.' + - check: sep-2468-client-compare-iss-unadvertised + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported false or absent, iss present -> Compare to the recorded issuer using simple string comparison.' + - check: sep-2468-client-proceed-no-iss + text: 'On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported false or absent, iss absent -> Proceed.' + - check: sep-2468-client-no-normalization + text: 'After decoding the iss value from the application/x-www-form-urlencoded response per RFC 9207 Section 2.4, clients MUST NOT apply scheme or host case folding, default-port elision, trailing-slash, or percent-encoding normalization (RFC 3986 Sections 6.2.2-6.2.3) before comparison.' + + - text: 'MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.' + excluded: 'Targets the authorization server under test; observing iss in an authorization response requires driving an Authorization Code Grant against the AS, which the authorization-server suite does not implement yet (it only probes the metadata endpoint).' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/208' + - text: 'Authorization servers that include the iss parameter MUST advertise this by setting authorization_response_iss_parameter_supported to true in their metadata (RFC9207 Section 2.3).' + excluded: 'Conditional on the AS actually including iss in an authorization response, which requires driving an Authorization Code Grant against the AS under test; the authorization-server suite does not implement that yet.' + issue: 'https://github.com/modelcontextprotocol/conformance/issues/208' + - text: 'This validation applies equally to error responses - on mismatch the client MUST NOT act on or display error, error_description, or error_uri.' + excluded: 'display is UI-facing; act-on has no protocol-observable signal beyond the existing reject-on-mismatch checks' diff --git a/src/seps/sep-2549.yaml b/src/seps/sep-2549.yaml new file mode 100644 index 0000000..4a5c1a5 --- /dev/null +++ b/src/seps/sep-2549.yaml @@ -0,0 +1,48 @@ +sep: 2549 +spec_url: https://modelcontextprotocol.io/specification/draft/server/utilities/caching +requirements: + - check: sep-2549-tools-list-caching-hints + text: 'Servers MUST include caching hints on results with resultType: "complete" returned by tools/list' + - check: sep-2549-prompts-list-caching-hints + text: 'Servers MUST include caching hints on results with resultType: "complete" returned by prompts/list' + - check: sep-2549-resources-list-caching-hints + text: 'Servers MUST include caching hints on results with resultType: "complete" returned by resources/list' + - check: sep-2549-resources-templates-list-caching-hints + text: 'Servers MUST include caching hints on results with resultType: "complete" returned by resources/templates/list' + - check: sep-2549-resources-read-caching-hints + text: 'Servers MUST include caching hints on results with resultType: "complete" returned by resources/read' + - check: sep-2549-ttl-non-negative + text: 'Servers MUST provide a ttlMs value that is >= 0' + - check: sep-2549-cache-scope-valid + text: 'cacheScope indicates the intended scope of the cached response, either "public" or "private"' + + - text: 'If ttlMs is 0, the response SHOULD be considered immediately stale.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'If ttlMs is positive, the client SHOULD consider the result fresh for that many milliseconds after receiving the response.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'If ttlMs is absent, clients SHOULD assume a default of 0 (immediately stale) and rely on their own caching heuristics or notifications.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'If ttlMs is negative, clients SHOULD ignore it and treat it as 0.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'Once the TTL expires, the response is stale and the client SHOULD re-fetch on next access.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'Clients SHOULD NOT treat TTL as a polling interval that triggers automatic background refetches.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'Implementations that do choose to poll MUST apply jitter and backoff.' + excluded: 'Client-side polling behavior; not observable at the protocol level' + - text: 'Cached responses MAY be reused for the same authorization context. Caches MUST NOT be shared across authorization contexts (e.g. a different access token requires a different cache).' + excluded: 'Client/cache-side behavior; not observable at the protocol level' + - text: 'When a cached page expires, the client SHOULD re-fetch that page using its cursor.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'Clients that require a consistent snapshot of the full list SHOULD re-fetch from the beginning (without a cursor).' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'If a cursor becomes invalid (e.g., the server returns an error for a previously valid cursor), the client SHOULD discard all cached pages and re-fetch from the beginning.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'Servers MUST be aware that responses with a "public" cacheScope may be shared between callers even if the Result is coming from an authenticated endpoint.' + excluded: 'Server-side awareness requirement; implementation guidance not testable via protocol messages' + - text: 'Server implementors MUST apply appropriate per-primitive access controls, and MUST NOT rely on cacheScope alone to prevent unauthorized access to primitives.' + excluded: 'Server-side access control; implementation guidance not testable via protocol messages' + - text: 'Clients MUST NOT serve a cached response for a request whose method or parameters differ from the request that produced it.' + excluded: 'Client-side caching behavior; not observable at the protocol level' + - text: 'Results produced by retrying a request through the multi round-trip requests mechanism (requests carrying inputResponses or requestState) MUST NOT be cached.' + excluded: 'Client-side caching behavior; not observable at the protocol level' diff --git a/src/seps/sep-2575.yaml b/src/seps/sep-2575.yaml new file mode 100644 index 0000000..057355d --- /dev/null +++ b/src/seps/sep-2575.yaml @@ -0,0 +1,104 @@ +sep: 2575 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle +requirements: + - check: sep-2575-client-populates-meta + text: 'Every client request MUST include the following io.modelcontextprotocol/* fields in _meta: protocolVersion, clientInfo, clientCapabilities.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-server-rejects-undeclared-capability + text: 'A server MUST NOT rely on capabilities the client has not declared. If processing a request requires a capability the client did not include in io.modelcontextprotocol/clientCapabilities, the server MUST return a MissingRequiredClientCapabilityError (-32021).' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-missing-capability-http-400 + text: 'On HTTP, the response status MUST be 400 Bad Request [for MissingRequiredClientCapabilityError].' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-server-tags-subscription-id + text: 'On notifications delivered via a subscriptions/listen stream, the server MUST include io.modelcontextprotocol/subscriptionId in _meta so the client can correlate the notification with the originating subscription request.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#meta + - check: sep-2575-server-unsupported-version-error + text: 'If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support.' + url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation + - check: sep-2575-client-retry-supported-version + text: 'The client SHOULD select a mutually supported version from the supported list and retry the request, or surface an error to the user if no compatible version exists.' + url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation + - check: sep-2575-server-implements-discover + text: 'Servers MUST implement server/discover.' + url: https://modelcontextprotocol.io/specification/draft/server/discover + - check: sep-2575-http-server-no-independent-requests-on-stream + text: 'The server MUST NOT send independent JSON-RPC requests on this stream. Server-to-client interactions are embedded as input requests inside an IncompleteResult.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#receiving-messages-1 + - check: sep-2575-http-client-sends-version-header + text: 'Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-version-header-matches-meta + text: 'The header value MUST match the io.modelcontextprotocol/protocolVersion field carried in the request body _meta.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-server-header-mismatch-400 + text: 'If the values do not match, the server MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-server-unsupported-version-400 + text: 'If the server does not implement the requested protocol version, it MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError listing its supported versions.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-http-server-method-not-found-404 + text: 'If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header + - check: sep-2575-server-honors-notification-filter + text: 'The server MUST NOT send notification types the client has not explicitly requested.' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#opening-a-stream + - check: sep-2575-server-sends-subscription-ack + text: 'The server MUST send notifications/subscriptions/acknowledged as the first message on the stream.' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#acknowledgment + - check: sep-2575-client-declares-elicitation-capability + text: 'Clients that support elicitation MUST declare the elicitation capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.' + url: https://modelcontextprotocol.io/specification/draft/client/elicitation#capabilities + - check: sep-2575-client-declares-roots-capability + text: 'Clients that support roots MUST declare the roots capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.' + url: https://modelcontextprotocol.io/specification/draft/client/roots#capabilities + - check: sep-2575-client-declares-sampling-capability + text: 'Clients that support sampling MUST declare the sampling capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.' + url: https://modelcontextprotocol.io/specification/draft/client/sampling#capabilities + - check: sep-2575-server-declares-prompts-in-discover + text: 'Servers that support prompts MUST declare the prompts capability in their DiscoverResult.' + url: https://modelcontextprotocol.io/specification/draft/server/prompts#capabilities + - check: sep-2575-server-sends-prompts-list-changed-on-subscription + text: '[A server with the listChanged] capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with promptsListChanged: true.' + url: https://modelcontextprotocol.io/specification/draft/server/prompts#list-changed-notification + - check: sep-2575-server-sends-tools-list-changed-on-subscription + text: '[A server with the listChanged] capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with toolsListChanged: true.' + url: https://modelcontextprotocol.io/specification/draft/server/tools#list-changed-notification + - check: sep-2575-server-no-log-without-loglevel + text: 'The server MUST NOT emit notifications/message for a request that does not include [io.modelcontextprotocol/logLevel in _meta].' + url: https://modelcontextprotocol.io/specification/draft/server/utilities/logging#per-request-log-level + + - text: 'A server MUST NOT treat connection or process identity as a proxy for conversation or session continuity. / Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity).' + excluded: 'internal server state, not directly wire-observable; the observable consequence (rejecting requests with incomplete _meta rather than falling back to remembered state) is covered by sep-2575-request-meta-invalid-* — see https://github.com/modelcontextprotocol/conformance/issues/296' + - text: 'Servers MUST NOT require that a client reuse the same connection to perform related operations.' + excluded: 'not observable from a black-box harness; every harness request already arrives on an independent connection — see https://github.com/modelcontextprotocol/conformance/issues/296' + - text: 'Closing the SSE response stream MUST be treated by the server as cancellation of that request.' + excluded: '"treated as cancellation" is internal server state; once the stream is closed there is no channel left on which to observe the effect — see https://github.com/modelcontextprotocol/conformance/issues/296' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1 + - text: 'The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].' + excluded: '"stop work as soon as practical" is unobservable from a black-box harness, and "no further messages" cannot be verified once the response stream is closed — see https://github.com/modelcontextprotocol/conformance/issues/296' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1 + - text: 'State that needs to span multiple requests (e.g., long-running tasks, application-level handles) MUST be referenced by an explicit identifier the client passes on each request.' + excluded: 'architectural guidance, observable only via subscriptionId/task-id rows already listed' + - text: 'To distinguish notifications belonging to different concurrent subscriptions, clients MUST correlate notifications using the io.modelcontextprotocol/subscriptionId field carried in _meta.' + excluded: 'client-internal demux; not observable on the wire from the harness' + - text: 'The client SHOULD check the acknowledged filter against what it requested and handle any unsupported types gracefully.' + excluded: 'internal comparison; "gracefully" has no wire-observable definition' + - text: 'Because there is no per-request status code to drive fallback, a client that supports both eras SHOULD probe with server/discover first [stdio backward compatibility].' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/lifecycle#backward-compatibility-with-initialization-based-versions + - text: 'To cancel an in-flight request [on stdio], the client MUST send a notifications/cancelled notification referencing the request ID.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation + - text: 'Servers SHOULD stop work on a cancelled request as soon as practical and MUST NOT send any further messages for it [stdio].' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation + - text: 'If the server process exits unexpectedly, the client SHOULD restart it.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#unexpected-termination + - text: 'If the server returns UnsupportedProtocolVersionError, [the stdio client] SHOULD retry using one of the advertised supportedVersions rather than falling back to initialize.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#backward-compatibility + - text: 'On stdio, if the connection is terminated and then re-established, the client MUST re-send subscriptions/listen to re-establish its subscriptions.' + excluded: 'stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258' + url: https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#cancellation diff --git a/src/seps/sep-2640.yaml b/src/seps/sep-2640.yaml new file mode 100644 index 0000000..ed82fdf --- /dev/null +++ b/src/seps/sep-2640.yaml @@ -0,0 +1,308 @@ +# spec_source: modelcontextprotocol/modelcontextprotocol@sep/skills-extension seps/2640-skills-extension.md +# extracted: 2026-08-28 +# +# provenance: re-extracted against the 2026-08-21 revision, which the core +# maintainers produced after their second review round and which went back up +# for CM vote on 2026-08-25. The prior extraction held at 556154c (2026-06-05) +# by an explicit decision to wait for mcpkit#780; that hold expired badly. +# The 08-21 revision: +# - removed the `skill://index.json` well-known resource entirely (it now +# appears nowhere in the SEP) and replaced it with the `skills/list` and +# `skills/get` methods, both mandatory for a declaring server; +# - deferred archive distribution to an appendix ("Appendix: Deferred +# Features"), retiring the `type` enum and every archive requirement; +# - reversed the nesting rule: a `SKILL.md` MAY now appear in a descendant +# directory, where the June text forbade it; +# - reshaped the entry to `{uri, frontmatter, resources}`, where `resources` +# is a complete `{uri, digest, size}` array or the string `"dynamic"`; +# - fixed per-skill limits of 512 resource entries and 16 MiB; +# - added host obligations around lazy retrieval, content-bound approval and +# frontmatter re-verification. +# Eight rows from the prior extraction were removed as no longer normative +# (server-expose-index, index-entry-type-enum, no-nested-skills, and the five +# archive rows) and four were reworded. Because the SEP is mid-vote, expect one +# more pass at the CM-stamped head. +# +# coverage: extracted against branch head a3e147ca2710 (2026-08-25), the commit +# the CM vote is running on. A keyword sweep of the source finds 97 sentences +# carrying an RFC 2119 term; this file declares 89 checks plus 7 excluded rows. +# The residual handful are restatements that fold into a declared row rather +# than standing alone. Of the 89 declared checks, 40 are exercised on the wire +# by the three server scenarios; the other 49 are host obligations (retrieval +# policy, approval binding, cache isolation, context handling) that a +# server-side harness cannot observe and that would need a host-side scenario +# set to test. +# +# deliberately not declared here: `resultType`, which appears in all three +# result examples in this SEP, is a base-protocol field on the common `Result` +# interface (schema/2026-07-28/schema.ts), not a SEP-2640 requirement. Servers +# MUST include it and clients treat an absent value as "complete". Its +# caching-hint obligations belong to sep-2549.yaml. Declaring it here would +# double-count a base-protocol rule against this extension. +# +# backing_scenarios: three server ClientScenarios under +# src/scenarios/server/skills/ emit the check IDs below (a row is "tested" +# once a scenario emits its check ID; see src/traceability/): +# enumeration.ts (sep-2640-skills-enumeration) — the capability rows, the +# sep-2640-skills-list-* rows, the entry-schema and resources rows, the two +# limit rows, and the sep-2640-skills-get-* rows. Replaces the retired +# index.ts, which tested `skill://index.json`. +# manifest.ts (sep-2640-skills-manifest) — sep-2640-skillmd-mimetype, +# sep-2640-skillmd-metadata-name, sep-2640-skillmd-metadata-description, +# sep-2640-final-segment-equals-name, sep-2640-meta-prefix. +# directory.ts (sep-2640-skills-directory) — +# sep-2640-capability-directory-read-flag and the +# sep-2640-directory-read-* rows. +# The remaining rows are host-internal or off-wire (host retrieval policy, +# digest and frontmatter verification, approval binding, cache isolation) and +# stay traceability-only for this server-scenario set. A host-side scenario +# set would be needed to exercise them. +sep: 2640 +spec_url: https://modelcontextprotocol.io/seps/2640-skills-extension#specification +requirements: + # === Skill Format === + - check: sep-2640-skillmd-required + text: 'Every skill MUST contain a `SKILL.md` file at its root.' + - check: sep-2640-skillmd-frontmatter + text: '`SKILL.md` MUST begin with YAML frontmatter containing at minimum the `name` and `description` fields as defined by the Agent Skills specification.' + + # === Resource Mapping === + - check: sep-2640-skill-uri-scheme + text: 'Each file within a skill directory is exposed as an MCP resource. Servers SHOULD use the `skill://` URI scheme, under which the resource URI has the form: `skill:///`' + - check: sep-2640-final-segment-equals-name + text: "The final segment of `` MUST equal the skill's `name` as declared in its `SKILL.md` frontmatter." + - check: sep-2640-name-naming-rules + text: "The final `` segment, being the skill `name`, MUST satisfy the Agent Skills specification's naming rules." + - check: sep-2640-authority-reg-name + text: 'The first `` segment occupies the authority component and SHOULD be a valid `reg-name` per RFC 3986; any other prefix segments SHOULD be valid URI path segments; no further constraints are imposed on them.' + # Reverses the retired sep-2640-no-nested-skills row: the June text said a + # SKILL.md MUST NOT appear in any descendant directory. + - check: sep-2640-nested-skills-permitted + text: 'A `SKILL.md` MAY appear in a descendant directory of a skill — skills can nest.' + - check: sep-2640-nested-skillmd-not-acted-on + text: "From the enclosing skill's perspective, a nested skill's directory and files are ordinary supporting files, and reading them is ordinary reading. A nested `SKILL.md` read this way is ordinary markdown: hosts MUST NOT act on its frontmatter." + - check: sep-2640-nested-publication-flat + text: "A nested skill is published like any other: through its own `skills/list` entry, or by explicit reference. The listing remains flat — an entry for a nested skill is an ordinary entry whose `uri` happens to share a path prefix with the enclosing skill's, and nothing in the listing marks nesting." + + # === Resource Metadata === + - check: sep-2640-skillmd-mimetype + text: 'For each `skill:///SKILL.md` resource: `mimeType` SHOULD be `text/markdown`.' + - check: sep-2640-skillmd-metadata-name + text: 'For each `skill:///SKILL.md` resource: `name` SHOULD be set from the `name` field of the `SKILL.md` YAML frontmatter. By the path constraint above, this will equal the final segment of ``.' + - check: sep-2640-skillmd-metadata-description + text: 'For each `skill:///SKILL.md` resource: `description` SHOULD be set from the `description` field of the `SKILL.md` YAML frontmatter.' + - check: sep-2640-meta-prefix + text: 'When `_meta` keys are used for skill resources, implementations SHOULD use the `io.modelcontextprotocol.skills/` reverse-domain prefix.' + + # === Capability Declaration === + # The inline-settings row is the check that would have caught panyam/mcpkit#1334. + # SEP-2133 (status Final) defines `extensions` as "a map of extension + # identifiers to per-extension settings objects" and defines no envelope; the + # SEP-2640 capability block matches it. + - check: sep-2640-capability-declaration-inline + text: 'Per SEP-2133 extension negotiation, servers declare support for this extension in their `initialize` response, mapping the extension identifier directly to its per-extension settings object: `{"capabilities": {"extensions": {"io.modelcontextprotocol/skills": {"directoryRead": true}}}}`.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#capability-declaration + - check: sep-2640-capability-commits-to-methods + text: 'Declaring the extension itself commits the server to `skills/list` and `skills/get`.' + - check: sep-2640-capability-empty-object + text: 'An empty object indicates support for the extension with no optional features.' + - check: sep-2640-capability-requires-resources + text: "This extension depends only on the base protocol's Resources primitive. Skill files are served through `resources/read`, and the base Resources specification requires any server that supports resources to declare the `resources` capability. A server declaring this extension therefore **MUST** also declare the `resources` capability." + - check: sep-2640-capability-directory-read-flag + text: 'Clients MUST NOT call `resources/directory/read` against a server that has not declared `directoryRead: true`.' + + # === Discovery: enumeration via skills/list === + - check: sep-2640-skills-list-implemented + text: 'A server declaring the `io.modelcontextprotocol/skills` extension MUST implement the `skills/list` method, which returns the skills it serves. The result MAY be empty.' + - check: sep-2640-skills-list-pagination + text: "Pagination mirrors the base protocol's list methods: the request accepts an optional `cursor`, and when the result includes `nextCursor` the client passes it back to retrieve the next page." + - check: sep-2640-skills-list-entry-atomic + text: "An entry is atomic — a skill's `resources` set is never split across pages." + - check: sep-2640-skills-list-cache-attributes + text: "In protocol versions 2026-07-28 and later, the result also carries the base protocol's list-caching attributes — `ttlMs` and `cacheScope`, as defined for `tools/list` and `resources/list` (SEP-2549) — with the same semantics: a freshness hint for the listing and a cache-scope marker, not an integrity property." + - check: sep-2640-skills-list-may-be-partial + text: 'A server whose skill catalog is large, generated on demand, or otherwise unenumerable MAY return an empty or partial listing.' + - check: sep-2640-host-no-empty-listing-assumption + text: 'Hosts MUST NOT treat an empty or partial listing as proof that a server has no skills.' + - check: sep-2640-enumeration-scheme-uniform + text: "The method serves entries for a server's skills whatever URI scheme they use — enumeration is uniform across schemes." + + # === The skill entry (shared by skills/list and skills/get) === + - check: sep-2640-entry-uri-required + text: "`skills[].uri` (Yes): Resource URI of the skill's `SKILL.md`." + - check: sep-2640-entry-frontmatter-required + text: "`frontmatter` is the skill's `SKILL.md` YAML frontmatter rendered verbatim as a JSON object — every field the author wrote, not a curated subset. Because the Agent Skills specification requires `name` and `description`, those fields are always present." + - check: sep-2640-entry-frontmatter-identical + text: 'The `frontmatter` object MUST be identical in content to the frontmatter of the `SKILL.md` it describes.' + - check: sep-2640-entry-uri-matches-frontmatter-name + text: "The final `` segment of the entry's `uri` MUST equal `frontmatter.name`, per Resource Mapping." + - check: sep-2640-metadata-reserved-prefix + text: 'Within the frontmatter `metadata` object, keys prefixed with `io.modelcontextprotocol/` are reserved for metadata defined by MCP extensions. This extension currently defines no such keys. Implementations SHOULD ignore keys under this prefix that they do not recognize.' + - check: sep-2640-names-should-be-unique + text: "Within a server's listing, names SHOULD be unique, but they are not guaranteed to be." + - check: sep-2640-host-disambiguate-listing-collision + text: 'When two entries in one listing collide on `name`, hosts MUST disambiguate them — for example by their distinguishing path segments — rather than silently discarding or preferring one.' + - check: sep-2640-names-not-unique + text: "A skill's `name` is a label, not an identifier. Within a server's listing, names SHOULD be unique, but they are not guaranteed to be. Hosts MUST NOT assume name uniqueness. When two entries in one listing collide on `name`, hosts MUST disambiguate them — for example by their distinguishing path segments — rather than silently discarding or preferring one." + + # === resources === + - check: sep-2640-entry-resources-required + text: '`resources` is REQUIRED on every skill entry and takes one of two forms: an array enumerating the skill''s files — `SKILL.md` and every supporting file — as `{uri, digest, size}` triples, or the string `"dynamic"`.' + - check: sep-2640-resources-complete + text: "When present, `resources` MUST be complete: it lists every file of the skill, each exactly once, including an entry matching the skill's top-level `uri` — that entry carries the digest and size of `SKILL.md` itself." + - check: sep-2640-resources-uri-within-skill + text: "Each `uri` MUST be the skill's `SKILL.md` or a file within the skill's directory." + - check: sep-2640-resources-digest-format + text: "Digests are SHA-256 hashes of an artifact's raw bytes, formatted as `sha256:{hex}` where `{hex}` is 64 lowercase hexadecimal characters. Each entry in a skill's `resources` carries the digest of the file at its `uri`." + - check: sep-2640-resources-size-required + text: "Each entry MUST carry `size`: the length in bytes of the file's raw content — the same bytes the `digest` covers." + - check: sep-2640-resources-dynamic-marker + text: 'When a skill''s content is generated dynamically, such that stable digests cannot be published, the server MUST set `"resources": "dynamic"` instead of an array.' + - check: sep-2640-resources-invalid-entry + text: 'An entry with no `resources` at all, or with any value other than an array or `"dynamic"`, is invalid, and hosts MUST NOT load it.' + - check: sep-2640-resources-nested-completeness + text: "Completeness extends to nested skills: from the enclosing skill's perspective their files are supporting files, so the enclosing skill's `resources` lists them too, and the same file may appear in both the enclosing and the nested skill's entries." + + # === Limits === + - check: sep-2640-limit-resources-per-skill + text: "Resources per skill: 512 entries, counted over the entries of the skill's `resources`, `SKILL.md` included." + - check: sep-2640-limit-total-size + text: "Total file size per skill: 16 MiB (16,777,216 bytes), counted over the sum of `size` over the skill's `resources`." + - check: sep-2640-limit-host-support + text: 'Hosts MUST support skills up to and including these limits, and MAY support larger ones. Servers SHOULD NOT serve a skill that exceeds either limit; a skill that does is not guaranteed to be loadable by any conforming host.' + + # === Retrieval via skills/get === + - check: sep-2640-skills-get-implemented + text: 'A server declaring the `io.modelcontextprotocol/skills` extension MUST also implement the `skills/get` method, which returns the entry for a single skill named by its URI.' + - check: sep-2640-skills-get-entry-shape + text: 'The `skill` object is a skill entry, identical in shape and meaning to an entry of `skills/list` — the same `uri`, `frontmatter`, and `resources` fields, under the same rules.' + - check: sep-2640-skills-get-unknown-uri-invalid-params + text: 'If the URI does not identify a skill the server serves, the server MUST return error `-32602` (Invalid params) — the same code `resources/read` uses for unknown resources.' + - check: sep-2640-skills-get-answers-unlisted + text: 'A server MUST answer for every skill it serves, whether or not that skill appears in its `skills/list` result. A skill absent from a partial listing is still retrievable by URI.' + - check: sep-2640-skills-get-cache-attributes + text: "`GetSkillResult` extends `CacheableResult`, so `ttlMs` and `cacheScope` are **REQUIRED**, as they are on `resources/read`, and carry the same semantics. `ttlMs` is the server's hint for how long a host may treat the entry as current before re-calling `skills/get`. Neither field is an integrity property." + - check: sep-2640-skills-get-no-cursor + text: 'The result carries no pagination cursor: a single entry is not a list.' + + # === Reading / integrity (host obligations) === + - check: sep-2640-host-load-by-uri + text: 'hosts MUST support loading a skill given only its URI' + - check: sep-2640-read-is-not-a-load + text: "Hosts MUST NOT treat a `resources/read` of a `SKILL.md` that arrives by any other route as a load: it grants no approval, opens no window, and confers no standing on the skill's supporting files." + - check: sep-2640-host-verify-digest + text: "When a host retrieves a file listed in a skill's `resources`, it MUST verify the content against that entry's digest. Whatever the cause, hosts MUST NOT use the unverified content." + - check: sep-2640-host-size-mismatch-failure + text: "A read whose byte length differs from the entry's `size` is a verification failure equivalent to a digest mismatch, whether or not the host goes on to compute the digest." + - check: sep-2640-host-no-prefetch + text: "Hosts MUST NOT retrieve a skill's files ahead of need — not on connection, not on listing, and not at approval. A `SKILL.md` is fetched when the skill is loaded, and a supporting file when it is read." + - check: sep-2640-host-frontmatter-comparison + text: "After fetching a `SKILL.md` for which the host holds an entry, hosts MUST parse its YAML frontmatter and compare it field-by-field against the entry's `frontmatter`. Any discrepancy MUST be treated as a verification failure equivalent to a digest mismatch, and the skill MUST NOT be loaded." + - check: sep-2640-host-digest-not-security-boundary + text: 'Digests are unsigned and supplied by the same server that supplies the content. A match proves the two are consistent, not that either is trustworthy. Hosts MUST NOT treat a digest match as a security boundary.' + - check: sep-2640-host-skill-identity-pair + text: "The identity of an MCP-served skill is the pair of the host's identity for the originating server and the skill's `uri`. Hosts MUST preserve both halves wherever a skill is recorded or addressed — the registry, persisted approvals, the cache, and any tool or path through which the model reaches the skill — and MUST NOT key any of these on the `uri` alone." + - check: sep-2640-host-not-skill-by-scheme + text: 'A host MUST NOT conclude that a resource is a skill merely because its URI carries a particular scheme.' + - check: sep-2640-host-cross-origin-no-shadow + text: 'When skills from different origins collide on `name`, hosts MUST resolve the name within a per-origin namespace, identifying servers by a host-assigned label; an MCP-served skill MUST NOT silently shadow, or be silently substituted for, a same-named skill from any other origin.' + - check: sep-2640-host-content-bound-approval + text: "When a host persists any per-skill user approval, it MUST be bound to the entry's `resources` set — every `uri` and `digest` — observed at the moment of approval. If a subsequent entry for that skill advertises a different set, the host MUST treat the prior approval as revoked and re-prompt before loading or executing." + - check: sep-2640-host-dynamic-not-content-bound + text: 'A skill whose `resources` is `"dynamic"` cannot be content-bound: hosts MAY decline to load it, and MUST NOT treat a persisted approval as covering whatever content the server currently serves.' + - check: sep-2640-host-nested-fresh-consent + text: 'Activating a nested skill — loading it as a skill in its own right — requires fresh, explicit user consent; approval of the enclosing skill does not substitute for it. A nested `SKILL.md` read as supporting content is ordinary markdown: hosts MUST NOT act on its frontmatter.' + + - check: sep-2640-host-origin-tag-visible + text: 'Hosts MUST tag MCP-served skill content with its originating server identity at the point it enters model context, and MUST NOT present it as indistinguishable from a local filesystem skill.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-untrusted-input + text: 'Hosts MUST treat MCP-served skill content as untrusted model input, subject to the same prompt-injection defenses applied to any server-provided text. A server being connected does not make its skill content authoritative.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-skills-are-data-not-directives + text: 'Hosts MUST NOT treat skill resources as higher-authority than other context. Explicit user policy governs whether a skill is loaded at all.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-cache-path-encodes-origin + text: 'Any path at which a host materializes skill content, whether a cache directory or a virtual mount, MUST encode the server identity as well as the `uri`, so that same-URI skills from different servers land at distinct paths and the originating server is recoverable from the path.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-higher-risk-surface + text: 'Hosts MUST treat MCP-served skills as a higher-risk surface than remote tool invocation.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-no-implicit-local-execution + text: 'Hosts MUST NOT allow MCP-served skill content to cause host-side code execution without explicit per-skill user approval.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-exec-gate-while-acting + text: 'Hosts MUST apply the same approval gate to code-execution tool calls issued while the model is acting on an MCP-served skill.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-reads-bound-to-origin + text: "Hosts MUST bind such reads to the skill's originating server: a skill served by server A MUST NOT cause a `resources/read` against server B." + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-cross-origin-read-approval + text: 'Any cross-origin read MUST be gated behind explicit per-call user approval naming both servers.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-label-not-serverinfo-name + text: "Hosts MUST identify servers by a host-assigned label, not the server's self-reported `serverInfo.name`." + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-no-implicit-permission-grants + text: "Hosts MUST NOT honor frontmatter fields that widen the model's tool or filesystem permissions when the skill arrives over MCP." + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-ignore-allowed-tools + text: 'The Agent Skills `allowed-tools` field, which a filesystem-sourced skill uses to declare the tools available while it runs, MUST be ignored for MCP-origin skills.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-intermediary-meta-prefix + text: 'Intermediaries MAY attach provenance or verification annotations via `_meta` under their own reverse-domain prefix — not the `io.modelcontextprotocol.skills/` prefix reserved for this extension.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-cache-write-isolation + text: 'Hosts that cache skill content on disk MUST do one of the following for every file served from the cache: keep the cache where nothing but the host can write to it, or re-hash the file against the entry on every read.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-cache-excluded-from-discovery + text: 'Hosts that cache MCP-served skill content on disk MUST also do so in a location excluded from every filesystem-skill discovery path, and MUST treat content loaded from there as MCP-origin.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - check: sep-2640-host-cache-removal-on-server-removal + text: "Hosts SHOULD remove a server's cached skill content when the user removes that server." + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + + # === Implementation Guidelines (host + SDK) === + - check: sep-2640-host-registry-no-fetch + text: 'Assembling the registry reads only the listing: the host MUST NOT fetch `SKILL.md` or any supporting file at this stage.' + - check: sep-2640-host-virtual-mount-lazy + text: "A virtual mount resolves reads on access; it MUST NOT be populated by fetching the skill's files in advance." + - check: sep-2640-host-surface-directory-read + text: 'When the originating server declares `directoryRead`, the host SHOULD surface this capability to the model.' + - check: sep-2640-sdk-convenience-wrappers + text: "SDK maintainers SHOULD provide affordances that wrap the underlying resource operations in skill-specific terms. The SDK handles: reading `SKILL.md` frontmatter to populate resource metadata, serving file content on `resources/read`, and answering `skills/get` — and, where the server's skill set is bounded, `skills/list` — computing entry digests and sizes from the registered files, and warning when a registered skill exceeds the Limits." + + # === Directory Listing (resources/directory/read) === + - check: sep-2640-directory-read-method-registered + text: 'A server that declares `directoryRead` MUST support the method for every directory within the skill namespaces it serves as individual files.' + - check: sep-2640-directory-read-subdir-mimetype + text: 'A _directory resource_ is a resource whose `mimeType` is `inode/directory`.' + - check: sep-2640-directory-read-result-resources-shape + text: 'The result contains every direct child of the directory: files with their ordinary resource metadata, subdirectories listed as directory resources (`mimeType: "inode/directory"`). The listing is not recursive; clients descend by calling the method again on a child directory.' + - check: sep-2640-directory-read-invalid-params + text: 'The method applies only to directory resources. If the URI does not exist, or exists but is not a directory resource, the server MUST return error `-32602` (Invalid params) — the same code `resources/read` uses for unknown resources.' + - check: sep-2640-directory-read-pagination + text: 'Pagination mirrors `resources/list`: when the result includes `nextCursor`, the client passes it back as `cursor` to retrieve the next page.' + - check: sep-2640-directory-read-empty-dir + text: 'An empty directory yields an empty `resources` array.' + - check: sep-2640-directory-read-not-manifest-extension + text: 'Hosts MUST NOT treat the directory result as extending the manifest. While acting on the skill under the held entry, the host MUST NOT read a newly listed child — an unlisted file is a verification failure, exactly as a digest mismatch is — and MUST NOT surface it to the model as a file of the skill.' + + # === Excluded: not observable on the MCP wire === + - text: 'Per RFC 3986, the first segment of `` occupies the authority component. This carries no special semantics under this convention and clients MUST NOT attempt DNS or network resolution of it.' + excluded: 'DNS and network resolution sit below the MCP wire layer; the harness cannot observe whether the client performed name lookups on URI authority components.' + - text: "Hosts SHOULD indicate which server a skill originates from when presenting it, SHOULD let users inspect a skill's content before it is loaded into model context" + excluded: 'UI presentation requirements (origin indicator, pre-load inspection); the harness cannot observe what the host displays to users.' + url: https://modelcontextprotocol.io/seps/2640-skills-extension#security-implications + - text: "A host is _acting on_ a skill from the moment it loads the skill's `SKILL.md` into the model's context until, at the earliest, that `SKILL.md` leaves context; hosts MAY hold the window open longer, never shorter." + excluded: "The window is defined by what is in the model's context, which is host-internal state the harness cannot observe. It scopes several wire-observable rules but is not itself checkable." + - text: "While acting on a skill, a host MUST resolve reads of the skill's files only to URIs listed in that entry's `resources`, and MUST treat a read of an unlisted file within the skill as a verification failure equivalent to a digest mismatch." + excluded: 'Scoped by the acting-on window above, which the harness cannot observe. As built it was also the one rule where the harness had nothing adversarial to do: the listing it served was entirely honest, so the only thing under test was whether the driver asked for a URI it should not have, which grades the driver rather than the client. Retired 2026-09-10 after review. Excluded by construction, not in principle: a listing whose `SKILL.md` body links to a file absent from `resources` would make the harness adversarial again, since a host that follows the link fetches an unlisted file on its own initiative. If it returns, it returns under the same scenario name `sep-2640-client-verify-unlisted` and the same check id `sep-2640-host-unlisted-read-failure`, so reports and baselines line up across the gap.' + - text: 'Hosts SHOULD instead cache what they do retrieve, and digests make that cache cheap to validate: a cached file whose digest matches the current entry can be served without fetching it again.' + excluded: 'Cache hits are the absence of a request. A harness cannot distinguish a compliant cache from a host that simply did not need the file again.' + - text: 'A host that declines a skill on this basis SHOULD tell the user why rather than fail silently on a later read.' + excluded: 'User-facing messaging; not protocol-observable.' + - text: 'For a skill whose `resources` is `"dynamic"`, the entry offers nothing to count. A host that chooses to load such a skill applies the total-size limit to what it actually retrieves and MAY stop loading the skill once that limit is reached.' + excluded: 'A host stopping mid-load is indistinguishable on the wire from a host that needed no further files.' + - text: 'Hosts SHOULD expect this sequence and present it as such — a skill that has changed and needs re-approval — rather than as a read error.' + excluded: 'User-facing presentation of the stale-entry recovery path; not protocol-observable.' diff --git a/src/seps/sep-2663.yaml b/src/seps/sep-2663.yaml new file mode 100644 index 0000000..dec9dd9 --- /dev/null +++ b/src/seps/sep-2663.yaml @@ -0,0 +1,200 @@ +sep: 2663 +spec_url: https://modelcontextprotocol.io/seps/2663-tasks-extension +requirements: + # ── Capability Negotiation ──────────────────────────────────────────────── + - check: sep-2663-server-rejects-undeclared-client + text: 'A server MUST NOT return CreateTaskResult to a client that did not include the extension capability on its request, regardless of prior declarations.' + + - check: sep-2663-client-handles-polymorphic-result + text: 'A client that has negotiated this extension MUST be prepared to handle either CallToolResult or CreateTaskResult in response to any supported request it issues.' + + - check: sep-2663-client-rejects-task-result-on-unsupported + text: 'A client that receives CreateTaskResult in response to an unsupported request type MUST interpret this as an invalid response to the request.' + + # The spec sentence "MUST return an error with the code -32021 ..., + # indicating the required extension in the error response" couples two + # observable requirements: the JSON-RPC error code, and the data-shape + # that names the missing extension. Split into two YAML entries so each + # is verifiable independently — Check 1 fires on code mismatch, Check 2 + # fires on payload shape. + - check: sep-2663-server-returns-missing-capability-when-required + text: 'If a server is unable to service a request to a client that does not declare this extension capability without returning CreateTaskResult, the server MUST return an error with the code -32021 (Missing Required Client Capability).' + + - check: sep-2663-server-returns-missing-capability-data-shape + text: 'The -32021 error response MUST indicate the required extension in the error response payload (the spec example shows a `data.requiredCapabilities.extensions["io.modelcontextprotocol/tasks"]` shape).' + + # ── Polymorphic Results ─────────────────────────────────────────────────── + - check: sep-2663-result-type-task-on-create + text: 'Servers MUST set resultType to "task" when returning a CreateTaskResult so that clients can distinguish it from a standard result.' + + - check: sep-2663-no-task-result-type-on-others + text: 'Servers MUST NOT set resultType to "task" on result types other than CreateTaskResult.' + + # ── Task Status ─────────────────────────────────────────────────────────── + - check: sep-2663-failed-status-jsonrpc-only + text: 'failed: The request failed due to a JSON-RPC error during execution. The task will include the error field with the JSON-RPC error details. This status MUST NOT be used for non-JSON-RPC errors.' + + # ── Task Creation ───────────────────────────────────────────────────────── + - check: sep-2663-durable-create-strong-consistency + text: 'A server MUST NOT return CreateTaskResult until the task is durably created — that is, until a tasks/get for the returned taskId would resolve. In eventually-consistent environments, the server MUST wait for consistency before responding.' + + - check: sep-2663-mrtr-synchronous-before-task-creation + text: 'Server implementations that use multi round-trip requests in conjunction with task creation (for example, a tool that requires elicitation over InputRequiredResult before creating a task) SHOULD resolve all MRTR exchanges synchronously before responding with a CreateTaskResult.' + + # ── Task Polling (tasks/get response per status) ────────────────────────── + - check: sep-2663-tasks-get-status-working + text: 'If the status is working, the server MUST return a a Task object with status working.' + + - check: sep-2663-tasks-get-status-input-required + text: 'If the status is input_required, the server MUST return a Task object with status input_required and an inputRequests field defined in Multi Round-Trip Requests. The inputRequests field MUST contain all outstanding requests from the server to the client that need to be fulfilled before the task can proceed.' + + - check: sep-2663-tasks-get-status-completed + text: 'If the status is completed, the server MUST return a Task object with status completed and a result field containing the final result of the task.' + + - check: sep-2663-tasks-get-status-cancelled + text: 'If the status is cancelled, the server MUST return a Task object with status cancelled.' + + - check: sep-2663-tasks-get-status-failed + text: 'If the status is failed, the server MUST return a Task object with status failed and the error that occurred during execution.' + + - check: sep-2663-result-type-complete-on-tasks-get + text: 'The resultType field MUST be set to "complete" on this object as it is the standard result shape for the tasks/get request.' + + # ── Task Update Requests ────────────────────────────────────────────────── + - check: sep-2663-input-request-key-unique-per-task + text: 'Each request key in inputRequests MUST be unique over the lifetime of a single task.' + + - check: sep-2663-no-key-reuse-after-response + text: "A server MUST NOT reuse a key for a subsequent server-to-client request after a response for that key has been delivered, and MUST NOT use the same key to refer to two distinct requests over a task's lifetime." + + - check: sep-2663-update-ack-empty-result + text: 'On success, the server MUST acknowledge the request with an empty result.' + + - check: sep-2663-update-error-unknown-task-id + text: 'Servers SHOULD return a JSON-RPC error if the taskId does not correspond to a known task.' + + - check: sep-2663-update-ignore-unknown-keys + text: 'A server SHOULD ignore any inputResponses responses mapped to a key that is not currently outstanding for the task — including keys that were never issued, keys that have already been answered, and keys whose corresponding request has been superseded.' + + - check: sep-2663-result-type-complete-on-tasks-update + text: 'The resultType field MUST be set to "complete" on UpdateTaskResult as it is the standard result shape for the tasks/update request.' + + # ── Task Cancellation ───────────────────────────────────────────────────── + - check: sep-2663-cancel-not-via-cancelled-notification + text: 'The notifications/cancelled notification MUST NOT be used for task cancellation.' + + - check: sep-2663-cancel-ack-empty-result + text: 'The server MUST acknowledge the request with an empty result.' + + - check: sep-2663-cancel-error-unknown-task-id + text: 'Servers SHOULD return a JSON-RPC error if the taskId does not correspond to a known task.' + + - check: sep-2663-result-type-complete-on-tasks-cancel + text: 'The resultType field MUST be set to "complete" on CancelTaskResult as it is the standard result shape for the tasks/cancel request.' + + # ── Task Status Notifications ───────────────────────────────────────────── + - check: sep-2663-subscribe-missing-capability-non-declaring + text: 'If a client requests task status notifications but does not declare the io.modelcontextprotocol/tasks extension capability, the server MUST return a JSON-RPC error specifying the missing capabilities:' + + - check: sep-2663-no-progress-or-message-on-task-stream + text: 'notifications/progress and notifications/message notifications MUST NOT be sent on the subscriptions/listen stream for a task, and are not supported on tasks in general in this specification.' + + # ── Streamable HTTP: Routing Headers ────────────────────────────────────── + - check: sep-2663-client-emits-mcp-name-on-tasks-methods + text: 'When tasks/get, tasks/update, or tasks/cancel is sent over the Streamable HTTP transport, the client MUST set the Mcp-Name header (defined by SEP-2243) to the value of params.taskId.' + + # Positive-path counterpart to the rejection requirement below: + # SEP-2663 routing headers, when set correctly, MUST NOT break the + # tasks dispatch path — matched Mcp-Method + Mcp-Name on tasks/get + # MUST resolve normally to the task identified by params.taskId. + - check: sep-2663-routing-headers-accepted-on-tasks-get + text: 'When the Mcp-Method and Mcp-Name request headers match the body method and params.taskId on tasks/get, tasks/update, and tasks/cancel, the server MUST dispatch the request normally and return the same result as if the headers were absent.' + + # Server-side counterpart to the client-MUST above. Derived from + # SEP-2243 §"Server Behavior" applied to the tasks-namespace methods + # listed in SEP-2663 §"Streamable HTTP: Routing Headers". + - check: sep-2663-server-rejects-mismatched-mcp-name-on-tasks-get + text: 'Servers that process the request body MUST reject requests where the values specified in the headers do not match the values in the request body.' + url: https://modelcontextprotocol.io/seps/2243-http-standardization#header-processing + + # ── Error Handling — Protocol Errors ────────────────────────────────────── + - check: sep-2663-tasks-get-invalid-task-id-32602 + text: 'Servers MUST return this error for tasks/get.' + + - check: sep-2663-tasks-update-cancel-invalid-task-id-32602 + text: 'Servers SHOULD return this error for tasks/update and tasks/cancel.' + + - check: sep-2663-tasks-methods-non-declaring + text: 'Servers MUST return this error for non-declaring clients issuing tasks/get, tasks/update, and tasks/cancel requests.' + + # ── Task Execution Errors ───────────────────────────────────────────────── + - check: sep-2663-failed-includes-statusmessage + text: 'The tasks/get response SHOULD include a statusMessage field with diagnostic information about the failure, and MUST include the error field with the JSON-RPC error.' + + - check: sep-2663-tool-error-uses-completed-status + text: 'The failed status MUST NOT be used to represent non-JSON-RPC errors, such as a tool result that completed with isError: true. Errors within the context of a protocol method result MUST use the completed status with the error details in the result field.' + + # ── Backwards Compatibility (2026-06-30) ────────────────────────────────── + - check: sep-2663-tasks-result-removed-method-not-found + text: 'tasks/result is removed; clients calling it MUST receive -32601 (Method Not Found).' + + - check: sep-2663-legacy-task-param-ignored + text: 'The task parameter on CallToolRequest is removed; servers MUST ignore it (treat the field as unknown) rather than using it as an opt-in.' + + - check: sep-2663-migrate-from-legacy-capabilities + text: 'Servers that previously advertised these MUST migrate to declaring io.modelcontextprotocol/tasks, and MUST NOT continue to advertise the legacy capabilities under any protocol version that includes this extension.' + + - check: sep-2663-legacy-capability-treated-as-non-declaring + text: 'Servers MUST treat clients declaring only the legacy capability as non-declaring with respect to this extension.' + + # Scenario flow-gate checks emit IDs without an RFC-2119 sentence to + # anchor to (tasks-extension-advertised, tasks-sync-tool-call, + # tasks-immediate-result-shortcut, tasks-wire-field-renames, + # tasks-no-early-ttl-expiry, tasks-no-related-task-meta-on-inlined-result, + # tasks-per-request-meta-opt-in, tasks-headers-*, + # tasks-cancel-terminal-idempotent-ack, tasks-mrtr-tasks-update-resumes, + # tasks-mrtr-partial-fulfillment, tasks-server-directed-creation-no-hint, + # tasks-result-type-complete-on-non-task-responses, tasks-removed-tasks-list, + # plus the ephemeral mrtr-* flow-gates in src/scenarios/server/mrtr/). + # These verify end-to-end flow invariants rather than a specific normative + # sentence, so they surface in the traceability manifest's `untracked` + # list instead of claiming requirement coverage. Same convention as + # sep-2322.yaml's flow-gate comment block. + + # ── Excluded requirements ───────────────────────────────────────────────── + + - text: 'implementations SHOULD be designed to accommodate additional request types in future revisions of this specification.' + excluded: 'Architectural design guidance, not observable at the protocol level' + + - text: 'Clients SHOULD respect the pollIntervalMs provided in responses when determining polling frequency.' + excluded: 'Client-internal polling cadence; the rate at which a client polls is not a wire-level conformance signal' + + - text: 'Clients SHOULD continue polling until the task reaches a terminal status or until invoking tasks/cancel.' + excluded: 'Internal client behavior with no observable violation at the protocol level' + + - text: 'Clients SHOULD persist task IDs to durable storage so that polling can resume after a crash or restart.' + excluded: 'Internal storage decision; not observable at the protocol level' + + - text: 'The client MUST inspect this field and SHOULD provide responses via the inputResponses field in subsequent tasks/update requests.' + excluded: 'Client-internal inspection action; not observable at the protocol level (the downstream tasks/update emission is covered by the MRTR scenarios)' + + - text: 'Clients MUST treat each entry in inputRequests as they would the equivalent standalone server-to-client request — for example, an elicitation request surfaced via inputRequests is subject to the same trust model and user-facing behavior as a direct elicitation/create request.' + excluded: 'Host-internal trust model; not observable at the protocol level' + + - text: 'Clients SHOULD deduplicate inputRequests keys across consecutive polls to avoid presenting the same request to the user or model more than once.' + excluded: 'Client UX / internal deduplication; not observable at the protocol level' + + - text: 'Clients SHOULD track inputRequests keys to avoid responding to requests more than once.' + excluded: 'Internal client state; not observable at the protocol level' + + - text: 'Servers SHOULD provide informative error messages to describe the cause of errors.' + excluded: 'Subjective; "informative" has no observable threshold at the protocol level' + + - text: 'Hosts MUST apply the same trust model to these payloads as they would to standard elicitation/sampling requests. A task is not a higher-trust channel.' + excluded: 'Host-internal trust model; not observable at the protocol level' + + - text: 'Servers MUST generate them with sufficient entropy that a third party cannot enumerate or guess them.' + excluded: '"Sufficient entropy" is a statistical property without a protocol-level threshold; cannot be asserted at the wire layer' + + - text: 'Servers MUST perform authentication and authorization checks on each task-related request to ensure that the client has permission to access a task.' + excluded: 'Host-internal authentication/authorization policy; not observable at the protocol level without coupling to a specific auth scheme' diff --git a/src/seps/sep-837.yaml b/src/seps/sep-837.yaml new file mode 100644 index 0000000..d97d00a --- /dev/null +++ b/src/seps/sep-837.yaml @@ -0,0 +1,14 @@ +sep: 837 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorization#application-type-and-redirect-uri-constraints +requirements: + - check: sep-837-application-type-present + text: 'MCP clients MUST specify an appropriate application_type during Dynamic Client Registration.' + + - text: 'Native applications (desktop applications, mobile apps, CLI tools, and locally-hosted web applications accessed via localhost) SHOULD use application_type: "native".' + excluded: 'harness cannot determine the client-under-test application class (native vs web) out-of-band; only presence and value validity are wire-observable' + - text: 'Web applications (remote browser-based applications served from a non-local host) SHOULD use application_type: "web".' + excluded: 'harness cannot determine the client-under-test application class (native vs web) out-of-band; only presence and value validity are wire-observable' + - text: 'MCP clients MUST be prepared to handle registration failures due to redirect URI constraints when authorization servers implement OIDC.' + excluded: 'robustness requirement with no defined wire-level success criterion' + - text: 'When a registration request is rejected, clients SHOULD surface a meaningful error to the user or developer.' + excluded: 'UI/DX behavior, not protocol-observable' diff --git a/src/seps/traceability.json b/src/seps/traceability.json new file mode 100644 index 0000000..674f2e9 --- /dev/null +++ b/src/seps/traceability.json @@ -0,0 +1,991 @@ +{ + "schemaVersion": 1, + "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", + "source": "typescript-sdk@258f1a04a5d3", + "seps": { + "837": { + "yaml": "src/seps/sep-837.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/authorization#application-type-and-redirect-uri-constraints", + "requirements": [ + { + "check": "sep-837-application-type-present", + "status": "tested", + "text": "MCP clients MUST specify an appropriate application_type during Dynamic Client Registration." + } + ], + "excluded": [ + { + "text": "Native applications (desktop applications, mobile apps, CLI tools, and locally-hosted web applications accessed via localhost) SHOULD use application_type: \"native\".", + "reason": "harness cannot determine the client-under-test application class (native vs web) out-of-band; only presence and value validity are wire-observable" + }, + { + "text": "Web applications (remote browser-based applications served from a non-local host) SHOULD use application_type: \"web\".", + "reason": "harness cannot determine the client-under-test application class (native vs web) out-of-band; only presence and value validity are wire-observable" + }, + { + "text": "MCP clients MUST be prepared to handle registration failures due to redirect URI constraints when authorization servers implement OIDC.", + "reason": "robustness requirement with no defined wire-level success criterion" + }, + { + "text": "When a registration request is rejected, clients SHOULD surface a meaningful error to the user or developer.", + "reason": "UI/DX behavior, not protocol-observable" + } + ], + "unkeyed": [], + "untracked": [], + "summary": { + "tested": 1, + "untested": 0, + "excluded": 4, + "untracked": 0, + "unkeyed": 0 + } + }, + "2106": { + "yaml": "src/seps/sep-2106.yaml", + "specUrl": "https://modelcontextprotocol.io/seps/2106-json-schema-2020-12#security-implications", + "requirements": [ + { + "check": "sep-2106-no-network-ref-deref", + "status": "tested", + "text": "Implementations MUST NOT automatically dereference `$ref` values that resolve to a network URI (i.e. anything that is not a same-document JSON Pointer such as `#/$defs/Foo` or an internal `$anchor`); an opt-in mode that fetches non-local `$ref`s MUST be disabled by default / Schemas that fail to validate due to an unresolved external `$ref` SHOULD be rejected rather than silently treated as permissive" + } + ], + "excluded": [ + { + "text": "SDK maintainers SHOULD: Document the migration in SDK release notes; Where ergonomic, provide typed helpers (e.g. generics over a tool's `outputSchema`) so consumers do not need to write narrowing guards by hand", + "reason": "Migration/deprecation guidance for SDK maintainers; about SDK source code, not protocol-observable wire behavior" + }, + { + "text": "JSON Schema validation already handles type checking, value constraints, and required field validation, and implementations MUST continue to validate all inputs and outputs against declared schemas", + "reason": "Restates pre-existing schema-validation behavior and is too broad to attribute to a specific observable SEP-2106 check; input/output validation overlaps existing tool scenarios" + }, + { + "text": "An opt-in mode that fetches non-local `$ref`s SHOULD enforce an allowlist of hosts (or at minimum reject loopback, link-local, and private network addresses), apply timeouts and size limits, and log dereferenced URIs", + "reason": "Applies only when the non-default opt-in network-$ref fetch mode is enabled; not observable in default conformance runs" + }, + { + "text": "Implementations SHOULD apply reasonable bounds — for example, a maximum schema depth, a cap on the total number of subschemas, or a per-validation time budget — to prevent a malicious tool definition from acting as a CPU DoS vector against the validator", + "reason": "Internal validator resource limits (max schema depth, subschema cap, per-validation time budget); a defensive measure not observable on the wire" + } + ], + "unkeyed": [], + "untracked": [ + "sep-2106-anchor-keyword-preserved", + "sep-2106-composition-keywords-preserved", + "sep-2106-conditional-keywords-preserved" + ], + "summary": { + "tested": 1, + "untested": 0, + "excluded": 4, + "untracked": 3, + "unkeyed": 0 + } + }, + "2164": { + "yaml": "src/seps/sep-2164.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/server/resources#error-handling", + "requirements": [ + { + "check": "sep-2164-no-empty-contents", + "status": "tested", + "text": "Servers MUST NOT return an empty contents array for a non-existent resource" + }, + { + "check": "sep-2164-error-code", + "status": "tested", + "text": "Servers SHOULD return standard JSON-RPC errors for common failure cases: Resource not found: -32602 (Invalid Params)" + } + ], + "excluded": [ + { + "text": "clients SHOULD also accept -32002 as a resource not found error", + "reason": "Client-side error handling is implementation-defined; not protocol-observable" + } + ], + "unkeyed": [], + "untracked": [ + "sep-2164-data-uri" + ], + "summary": { + "tested": 2, + "untested": 0, + "excluded": 1, + "untracked": 1, + "unkeyed": 0 + } + }, + "2207": { + "yaml": "src/seps/sep-2207.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-tokens", + "requirements": [ + { + "check": "sep-2207-client-metadata-grant-types", + "status": "tested", + "text": "MCP Clients that desire refresh tokens SHOULD include `refresh_token` in their `grant_types` client metadata" + } + ], + "excluded": [ + { + "text": "MCP Servers (Protected Resources) SHOULD NOT include `offline_access` in `WWW-Authenticate` scope or Protected Resource Metadata `scopes_supported`, as refresh tokens are not a resource requirement", + "reason": "The server suite does not yet exercise the SDK server as an OAuth protected resource (no Protected Resource Metadata or WWW-Authenticate probing); revisit once server-side authorization scenarios exist", + "issue": "https://github.com/modelcontextprotocol/conformance/issues/116" + }, + { + "text": "MCP Clients that desire refresh tokens MUST keep refresh tokens confidential in transit and storage as specified in OAuth 2.1 Section 4.3", + "reason": "Confidentiality of refresh tokens in storage is client-internal state, and in-transit (TLS) confidentiality is not exercised by the harness over localhost HTTP; not protocol-observable" + }, + { + "text": "MCP Clients that desire refresh tokens MUST NOT assume refresh tokens will be issued; the AS retains discretion", + "reason": "A client \"assuming\" refresh tokens will be issued is mental-state; only manifests as general authorization-flow completion, which other checks already cover; not directly protocol-observable" + } + ], + "unkeyed": [], + "untracked": [ + "sep-2207-offline-access-not-requested", + "sep-2207-offline-access-requested" + ], + "summary": { + "tested": 1, + "untested": 0, + "excluded": 3, + "untracked": 2, + "unkeyed": 0 + } + }, + "2243": { + "yaml": "src/seps/sep-2243.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers", + "requirements": [ + { + "check": "sep-2243-client-includes-standard-headers", + "status": "tested", + "text": "The client MUST include the standard MCP request headers on each POST request. These headers are REQUIRED for compliance." + }, + { + "check": "sep-2243-header-name-case-insensitive", + "status": "tested", + "text": "Clients and servers MUST use case-insensitive comparisons for header names." + }, + { + "check": "sep-2243-server-reject-invalid-headers", + "status": "tested", + "text": "Servers that process the request body MUST reject requests with mismatched or missing standard-header values, returning HTTP 400 Bad Request." + }, + { + "check": "sep-2243-server-reject-error-code", + "status": "tested", + "text": "When rejecting a request due to header validation failure, servers SHOULD include a JSON-RPC error response using error code -32001." + }, + { + "check": "sep-2243-client-supports-custom-headers", + "status": "tested", + "text": "MCP clients MUST support this feature [custom headers via x-mcp-header]." + }, + { + "check": "sep-2243-client-mirrors-designated-params", + "status": "tested", + "text": "When a client invokes a tool whose definition includes such designations, conforming clients MUST mirror the designated parameter values into HTTP headers as described below." + }, + { + "check": "sep-2243-x-mcp-header-not-empty", + "status": "tested", + "text": "The x-mcp-header value MUST NOT be empty.", + "url": "https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers" + }, + { + "check": "sep-2243-x-mcp-header-charset", + "status": "tested", + "text": "The x-mcp-header value MUST contain only ASCII characters (excluding space and `:`).", + "url": "https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers" + }, + { + "check": "sep-2243-x-mcp-header-unique", + "status": "tested", + "text": "The x-mcp-header value MUST be case-insensitively unique within a single tool definition.", + "url": "https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers" + }, + { + "check": "sep-2243-x-mcp-header-primitive-only", + "status": "tested", + "text": "x-mcp-header MUST only be applied to parameters with primitive types (integer, string, boolean). Parameters with type `number` are not permitted.", + "url": "https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers" + }, + { + "check": "sep-2243-client-reject-invalid-tool", + "status": "tested", + "text": "Clients MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the set of tools returned by tools/list.", + "url": "https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers" + }, + { + "check": "sep-2243-client-encode-values", + "status": "tested", + "text": "Clients MUST encode parameter values before including them in HTTP headers: integer values MUST be converted to their decimal string representation; boolean values MUST be converted to the lowercase strings \"true\" or \"false\"." + }, + { + "check": "sep-2243-client-base64-unsafe", + "status": "tested", + "text": "When a value cannot be safely represented as plain ASCII (e.g., contains non-ASCII characters, control characters, or leading/trailing whitespace), clients MUST use Base64 encoding of the UTF-8 representation, wrapped as =?base64?{encoded}?=." + }, + { + "check": "sep-2243-server-decode-base64", + "status": "tested", + "text": "Servers and intermediaries that need to inspect these values MUST decode them accordingly." + }, + { + "check": "sep-2243-client-omit-null", + "status": "tested", + "text": "Parameter value is null or omitted: Client MUST omit the header." + }, + { + "check": "sep-2243-server-not-expect-null", + "status": "untested", + "text": "Parameter value is null or omitted: Server MUST NOT expect the header." + }, + { + "check": "sep-2243-server-reject-missing-required", + "status": "untested", + "text": "Required parameter is omitted: Server MUST reject with JSON-RPC error." + }, + { + "check": "sep-2243-server-reject-invalid-param-chars", + "status": "tested", + "text": "Servers MUST reject requests with a recognized Mcp-Param-{Name} header that contain invalid characters." + }, + { + "check": "sep-2243-server-validate-param-match", + "status": "tested", + "text": "Any server that processes the message body MUST validate that encoded header values, after decoding if Base64-encoded, match the corresponding parameter values in the body." + }, + { + "check": "sep-2243-server-reject-param-mismatch", + "status": "tested", + "text": "Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32001 if any validation fails." + } + ], + "excluded": [ + { + "text": "Clients SHOULD log a warning when rejecting a tool definition due to invalid x-mcp-header, including the tool name and the reason.", + "reason": "Log output is not wire-observable." + }, + { + "text": "Server developers SHOULD NOT mark sensitive parameters (such as passwords, API keys, tokens, or PII) with x-mcp-header.", + "reason": "Design guidance to humans; not protocol-observable." + }, + { + "text": "Intermediaries MUST return an appropriate HTTP error status for validation failures.", + "reason": "Intermediary requirement; conformance harness tests clients and servers, not intermediaries." + }, + { + "text": "Intermediate servers that do not recognize an Mcp-Param-{Name} header MUST forward it and otherwise ignore it.", + "reason": "Intermediary requirement; conformance harness tests clients and servers, not intermediaries." + } + ], + "unkeyed": [], + "untracked": [ + "sep-2243-invalid-tool-tools-list-gate", + "sep-2243-server-accepts-whitespace-header-value", + "sep-2243-server-no-xmcp-tool" + ], + "summary": { + "tested": 18, + "untested": 2, + "excluded": 4, + "untracked": 3, + "unkeyed": 0 + } + }, + "2260": { + "yaml": "src/seps/sep-2260.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/transports#streamable-http", + "requirements": [], + "excluded": [ + { + "text": "roots/list, sampling/createMessage, and elicitation/create requests MUST NOT be sent on standalone streams.", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Clients MUST return standard JSON-RPC errors for common failure cases: Server sends an elicitation/create request with no associated client-to-server request: -32602 (Invalid params)", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Clients SHOULD return standard JSON-RPC errors for common failure cases: Server sends a roots/list request with no associated client-to-server request: -32602 (Invalid params)", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Clients SHOULD return errors for common failure cases: Sampling request not associated with a client-to-server request: -32602 (Invalid params)", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "These messages MUST relate to the originating client request.", + "reason": "Semantic association (\"relate to\") is not protocol-observable. The harness cannot determine whether a server request conceptually relates to the client request without understanding application logic." + }, + { + "text": "Implementations SHOULD prefer transport-level SSE keepalive mechanisms for idle-connection maintenance.", + "reason": "Implementation preference for keepalive mechanism choice; not observable on the wire." + }, + { + "text": "Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) only in association with an originating client request (e.g., during tools/call, resources/read, or prompts/get processing).", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Standalone server-initiated requests of these types on independent communication streams (unrelated to any client request) are not supported and MUST NOT be implemented.", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Servers MUST send sampling/createMessage requests only in association with an originating client request (e.g., during tools/call, resources/read, or prompts/get processing).", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Standalone server-initiated sampling on independent communication streams (unrelated to any client request) is not supported and MUST NOT be implemented.", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) only in association with an originating client request (e.g., during tools/call, resources/read, or prompts/get processing).", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + }, + { + "text": "Standalone server-initiated requests of these types on independent communication streams (unrelated to any client request) are not supported and MUST NOT be implemented.", + "reason": "No longer needed this behavior is enabled by default SEP-2322 MRTR." + } + ], + "unkeyed": [], + "untracked": [], + "summary": { + "tested": 0, + "untested": 0, + "excluded": 12, + "untracked": 0, + "unkeyed": 0 + } + }, + "2322": { + "yaml": "src/seps/sep-2322.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr", + "requirements": [ + { + "check": "sep-2322-result-type-included", + "status": "tested", + "text": "The resultType field MUST be included to indicate the type of the result.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/index#resulttype" + }, + { + "check": "sep-2322-default-result-type-complete", + "status": "tested", + "text": "If resultType is not specified, clients MUST assume a default value of \"complete\" for backwards compatibility.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/index#resulttype" + }, + { + "check": "sep-2322-not-on-unsupported-requests", + "status": "tested", + "text": "Servers MUST NOT send InputRequiredResult responses on any other client requests." + }, + { + "check": "sep-2322-elicitation-incomplete", + "status": "tested", + "text": "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (elicitation variant)" + }, + { + "check": "sep-2322-sampling-incomplete", + "status": "tested", + "text": "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling variant)" + }, + { + "check": "sep-2322-list-roots-incomplete", + "status": "tested", + "text": "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots variant)" + }, + { + "check": "sep-2322-reject-tampered-state", + "status": "tested", + "text": "If requestState influences authorization, resource access, or business logic, servers MUST protect its integrity and MUST reject state that fails verification." + }, + { + "check": "sep-2322-request-state-incomplete", + "status": "tested", + "text": "Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response." + }, + { + "check": "sep-2322-respect-client-capabilities", + "status": "tested", + "text": "Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities." + }, + { + "check": "sep-2322-client-request-state-echoed", + "status": "tested", + "text": "If an InputRequiredResult contains the requestState field, the client MUST echo back the exact value of that field when retrying the original request. Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents." + }, + { + "check": "sep-2322-client-no-state-omitted", + "status": "tested", + "text": "If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry." + }, + { + "check": "sep-2322-client-jsonrpc-id-different", + "status": "tested", + "text": "The JSON-RPC id MUST be different between the initial request and the retry, as they are independent requests." + }, + { + "check": "sep-2322-client-parallel-isolation", + "status": "tested", + "text": "Both the inputRequests and requestState fields affect only the client's retry of the original request. They MUST NOT be used for any other request that the client may be sending in parallel." + }, + { + "check": "sep-2322-validate-input-responses", + "status": "tested", + "text": "Servers SHOULD validate that the data provided by the client is a valid InputResponses object and that the information inside can be correctly parsed." + }, + { + "check": "sep-2322-error-on-protocol-error", + "status": "tested", + "text": "Protocol errors (malformed JSON, invalid schema, internal server errors) SHOULD return a JSON-RPC error response with an appropriate error code and message." + }, + { + "check": "sep-2322-ignore-unexpected-params", + "status": "tested", + "text": "If additional, unexpected parameters are provided in the InputResponses object, the server SHOULD ignore any information it does not recognize or need." + }, + { + "check": "sep-2322-missing-response-rerequests", + "status": "tested", + "text": "If the client fails to send all the information requested in a previous InputRequests, and the missing information is necessary for the server to process the request, the server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error." + } + ], + "excluded": [ + { + "text": "inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request.", + "reason": "inputRequests is a JSON object; duplicate keys are collapsed by JSON parsing before the harness can observe them, so key uniqueness is not testable at the protocol level" + }, + { + "text": "Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern.", + "reason": "Architectural migration statement; tested indirectly through all MRTR scenarios" + }, + { + "text": "servers MUST treat requestState as an attacker-controlled input", + "reason": "Internal security posture; not observable at protocol level" + }, + { + "text": "servers MUST protect its integrity (e.g. HMAC or AEAD)", + "reason": "Internal implementation choice about encryption/signing; not observable at protocol level" + }, + { + "text": "servers SHOULD include the authenticated principal, a short expiry (TTL), and an identifier for the originating request inside the integrity-protected requestState payload and verify each on receipt", + "reason": "Internal requestState format; not observable at protocol level" + }, + { + "text": "Servers for which a given requestState must be consumed at most once MUST enforce that invariant server-side", + "reason": "Internal enforcement policy; conformance harness cannot determine which servers require single-use semantics" + }, + { + "text": "Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request", + "reason": "Server-internal robustness assumption; not observable at protocol level" + }, + { + "text": "Servers MUST validate request state as described in the server requirements above.", + "reason": "Duplicates integrity-protection requirements above; internal security detail" + }, + { + "text": "Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required.", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task.", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "When tasks/get shows status input_required, clients MUST call tasks/result to get the inputRequests and optional requestState.", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "Clients SHOULD construct the results of those requests and call tasks/input_response with the inputResponses & requestState (if present).", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "Receivers MUST reject tasks/input_response requests for tasks that are not in input_required status with error code -32602 (Invalid params).", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "When a receiver receives a tasks/result request for a task in working status, it MUST block the response until the task reaches a terminal status or input_required status.", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "When a receiver receives a tasks/result request for a task in input_required status, it MUST return an InputRequiredResult containing the inputRequests that the requestor must fulfill.", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + }, + { + "text": "After sending tasks/input_response, the requestor SHOULD resume polling via tasks/get.", + "reason": "Tasks moved to an extension as of SEP-2663; no longer part of core conformance" + } + ], + "unkeyed": [], + "untracked": [ + "sep-2322-multi-round-r1", + "sep-2322-multiple-inputs-incomplete", + "sep-2322-non-tool-incomplete" + ], + "summary": { + "tested": 17, + "untested": 0, + "excluded": 16, + "untracked": 3, + "unkeyed": 0 + } + }, + "2350": { + "yaml": "src/seps/sep-2350.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/authorization#runtime-insufficient-scope-errors", + "requirements": [ + { + "check": "sep-2350-scope-union-on-reauth", + "status": "tested", + "text": "When re-authorizing, clients SHOULD include these scopes alongside any previously granted scopes to avoid losing permissions needed for other operations. / Clients SHOULD compute the union of previously requested scopes and newly challenged scopes when initiating re-authorization.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements" + } + ], + "excluded": [ + { + "text": "Regardless of the approach chosen, servers SHOULD include all scopes required for the current operation in a single challenge.", + "reason": "\"All scopes required for the current operation\" has no harness-observable ground truth; the challenge is the only place the server declares its requirements. Detecting the negative (incremental challenging) would need server-side auth scenarios that do not exist yet and would test the example app's scope config rather than SDK behavior; the spec also permits dynamic per-request scope determination, so a second challenge is not conclusively non-conformant." + }, + { + "text": "When responding with insufficient scope errors, servers SHOULD include the scopes needed to satisfy the current operation in the scope parameter, consistent with RFC 6750 Section 3.1.", + "reason": "reword of pre-existing requirement (request->operation, +RFC6750 cite); no normative delta; harness already emits scope= in WWW-Authenticate" + } + ], + "unkeyed": [], + "untracked": [], + "summary": { + "tested": 1, + "untested": 0, + "excluded": 2, + "untracked": 0, + "unkeyed": 0 + } + }, + "2352": { + "yaml": "src/seps/sep-2352.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-binding", + "requirements": [ + { + "check": "sep-2352-no-cross-as-credential-reuse", + "status": "tested", + "text": "Clients MUST NOT assume that credentials valid for one authorization server will be accepted by another.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-location" + }, + { + "check": "sep-2352-no-reuse-on-as-change", + "status": "tested", + "text": "When the authorization server changes (detected via updated protected resource metadata), clients MUST NOT reuse client credentials from a different authorization server." + }, + { + "check": "sep-2352-reregister-on-as-change", + "status": "tested", + "text": "When the authorization server changes (detected via updated protected resource metadata), clients MUST re-register with the new authorization server." + } + ], + "excluded": [ + { + "text": "Clients MUST maintain separate registration state (client credentials, tokens) per authorization server.", + "reason": "internal storage requirement; not directly observable on the wire" + }, + { + "text": "Clients that use pre-registered credentials, or persist client credentials obtained via Dynamic Client Registration, MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server issuer identifier.", + "reason": "internal state-keying requirement; not protocol-observable" + }, + { + "text": "If the authorization server indicated by protected resource metadata no longer matches the one the credentials were registered with, clients SHOULD surface an error rather than silently attempting to use mismatched credentials.", + "reason": "UI behavior; the negative half (do not send mismatched credentials) is covered by sep-2352-no-reuse-on-as-change" + } + ], + "unkeyed": [], + "untracked": [], + "summary": { + "tested": 3, + "untested": 0, + "excluded": 3, + "untracked": 0, + "unkeyed": 0 + } + }, + "2468": { + "yaml": "src/seps/sep-2468.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-response-validation", + "requirements": [ + { + "check": "sep-2468-client-validate-metadata-issuer", + "status": "tested", + "text": "After retrieving a metadata document, MCP clients MUST validate it as required by RFC8414 Section 3.3 or OpenID Connect Discovery Section 4.3: the issuer value in the document MUST be identical to the issuer identifier used to construct the well-known URL. If they differ, the client MUST NOT use the metadata.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery" + }, + { + "check": "sep-2468-client-compare-iss-supported", + "status": "tested", + "text": "On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported true, iss present -> Compare to the recorded issuer using simple string comparison / Before redirecting the user-agent, the client MUST record the issuer value from the selected authorization server validated metadata document and associate it with the same per-request record used to store the PKCE code verifier." + }, + { + "check": "sep-2468-client-reject-missing-iss", + "status": "tested", + "text": "On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported true, iss absent -> Reject the response." + }, + { + "check": "sep-2468-client-compare-iss-unadvertised", + "status": "tested", + "text": "On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported false or absent, iss present -> Compare to the recorded issuer using simple string comparison." + }, + { + "check": "sep-2468-client-proceed-no-iss", + "status": "tested", + "text": "On receiving the authorization response, MCP clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code to any token endpoint: authorization_response_iss_parameter_supported false or absent, iss absent -> Proceed." + }, + { + "check": "sep-2468-client-no-normalization", + "status": "tested", + "text": "After decoding the iss value from the application/x-www-form-urlencoded response per RFC 9207 Section 2.4, clients MUST NOT apply scheme or host case folding, default-port elision, trailing-slash, or percent-encoding normalization (RFC 3986 Sections 6.2.2-6.2.3) before comparison." + } + ], + "excluded": [ + { + "text": "MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.", + "reason": "Targets the authorization server under test; observing iss in an authorization response requires driving an Authorization Code Grant against the AS, which the authorization-server suite does not implement yet (it only probes the metadata endpoint).", + "issue": "https://github.com/modelcontextprotocol/conformance/issues/208" + }, + { + "text": "Authorization servers that include the iss parameter MUST advertise this by setting authorization_response_iss_parameter_supported to true in their metadata (RFC9207 Section 2.3).", + "reason": "Conditional on the AS actually including iss in an authorization response, which requires driving an Authorization Code Grant against the AS under test; the authorization-server suite does not implement that yet.", + "issue": "https://github.com/modelcontextprotocol/conformance/issues/208" + }, + { + "text": "This validation applies equally to error responses - on mismatch the client MUST NOT act on or display error, error_description, or error_uri.", + "reason": "display is UI-facing; act-on has no protocol-observable signal beyond the existing reject-on-mismatch checks" + } + ], + "unkeyed": [], + "untracked": [], + "summary": { + "tested": 6, + "untested": 0, + "excluded": 3, + "untracked": 0, + "unkeyed": 0 + } + }, + "2549": { + "yaml": "src/seps/sep-2549.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/server/utilities/caching", + "requirements": [ + { + "check": "sep-2549-tools-list-caching-hints", + "status": "tested", + "text": "Servers MUST include caching hints on results returned by tools/list" + }, + { + "check": "sep-2549-prompts-list-caching-hints", + "status": "tested", + "text": "Servers MUST include caching hints on results returned by prompts/list" + }, + { + "check": "sep-2549-resources-list-caching-hints", + "status": "tested", + "text": "Servers MUST include caching hints on results returned by resources/list" + }, + { + "check": "sep-2549-resources-templates-list-caching-hints", + "status": "tested", + "text": "Servers MUST include caching hints on results returned by resources/templates/list" + }, + { + "check": "sep-2549-resources-read-caching-hints", + "status": "tested", + "text": "Servers MUST include caching hints on results returned by resources/read" + }, + { + "check": "sep-2549-ttl-non-negative", + "status": "tested", + "text": "Servers MUST provide a ttlMs value that is >= 0" + }, + { + "check": "sep-2549-cache-scope-valid", + "status": "tested", + "text": "cacheScope indicates the intended scope of the cached response, either \"public\" or \"private\"" + } + ], + "excluded": [ + { + "text": "If ttlMs is 0, the response SHOULD be considered immediately stale.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "If ttlMs is positive, the client SHOULD consider the result fresh for that many milliseconds after receiving the response.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "If ttlMs is absent, clients SHOULD assume a default of 0 (immediately stale) and rely on their own caching heuristics or notifications.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "If ttlMs is negative, clients SHOULD ignore it and treat it as 0.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "Once the TTL expires, the response is stale and the client SHOULD re-fetch on next access.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "Clients SHOULD NOT treat TTL as a polling interval that triggers automatic background refetches.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "Implementations that do choose to poll MUST apply jitter and backoff.", + "reason": "Client-side polling behavior; not observable at the protocol level" + }, + { + "text": "Cached responses MAY be reused for the same authorization context. Caches MUST NOT be shared across authorization contexts (e.g. a different access token requires a different cache).", + "reason": "Client/cache-side behavior; not observable at the protocol level" + }, + { + "text": "When a cached page expires, the client SHOULD re-fetch that page using its cursor.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "Clients that require a consistent snapshot of the full list SHOULD re-fetch from the beginning (without a cursor).", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "If a cursor becomes invalid (e.g., the server returns an error for a previously valid cursor), the client SHOULD discard all cached pages and re-fetch from the beginning.", + "reason": "Client-side caching behavior; not observable at the protocol level" + }, + { + "text": "Servers MUST be aware that responses with a \"public\" cacheScope may be shared between callers even if the Result is coming from an authenticated endpoint.", + "reason": "Server-side awareness requirement; implementation guidance not testable via protocol messages" + }, + { + "text": "Server implementors MUST apply appropriate per-primitive access controls, and MUST NOT rely on cacheScope alone to prevent unauthorized access to primitives.", + "reason": "Server-side access control; implementation guidance not testable via protocol messages" + } + ], + "unkeyed": [], + "untracked": [], + "summary": { + "tested": 7, + "untested": 0, + "excluded": 13, + "untracked": 0, + "unkeyed": 0 + } + }, + "2575": { + "yaml": "src/seps/sep-2575.yaml", + "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/lifecycle", + "requirements": [ + { + "check": "sep-2575-client-populates-meta", + "status": "tested", + "text": "Every client request MUST include the following io.modelcontextprotocol/* fields in _meta: protocolVersion, clientInfo, clientCapabilities.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/index#meta" + }, + { + "check": "sep-2575-server-rejects-undeclared-capability", + "status": "tested", + "text": "A server MUST NOT rely on capabilities the client has not declared. If processing a request requires a capability the client did not include in io.modelcontextprotocol/clientCapabilities, the server MUST return a MissingRequiredClientCapabilityError (-32003).", + "url": "https://modelcontextprotocol.io/specification/draft/basic/index#meta" + }, + { + "check": "sep-2575-missing-capability-http-400", + "status": "tested", + "text": "On HTTP, the response status MUST be 400 Bad Request [for MissingRequiredClientCapabilityError].", + "url": "https://modelcontextprotocol.io/specification/draft/basic/index#meta" + }, + { + "check": "sep-2575-server-tags-subscription-id", + "status": "tested", + "text": "On notifications delivered via a subscriptions/listen stream, the server MUST include io.modelcontextprotocol/subscriptionId in _meta so the client can correlate the notification with the originating subscription request.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/index#meta" + }, + { + "check": "sep-2575-server-unsupported-version-error", + "status": "tested", + "text": "If the server does not implement the requested version (whether the version is unknown to the server, or is a known version the server has chosen not to support), it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation" + }, + { + "check": "sep-2575-client-retry-supported-version", + "status": "tested", + "text": "The client SHOULD select a mutually supported version from the supported list and retry the request, or surface an error to the user if no compatible version exists.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation" + }, + { + "check": "sep-2575-server-implements-discover", + "status": "tested", + "text": "Servers MUST implement server/discover.", + "url": "https://modelcontextprotocol.io/specification/draft/server/discover" + }, + { + "check": "sep-2575-http-server-no-independent-requests-on-stream", + "status": "tested", + "text": "The server MUST NOT send independent JSON-RPC requests on this stream. Server-to-client interactions are embedded as input requests inside an IncompleteResult.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#receiving-messages-1" + }, + { + "check": "sep-2575-http-client-sends-version-header", + "status": "tested", + "text": "Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header" + }, + { + "check": "sep-2575-http-version-header-matches-meta", + "status": "tested", + "text": "The header value MUST match the io.modelcontextprotocol/protocolVersion field carried in the request body _meta.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header" + }, + { + "check": "sep-2575-http-server-header-mismatch-400", + "status": "tested", + "text": "If the values do not match, the server MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header" + }, + { + "check": "sep-2575-http-server-unsupported-version-400", + "status": "tested", + "text": "If the server does not implement the requested protocol version, it MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError listing its supported versions.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header" + }, + { + "check": "sep-2575-http-server-method-not-found-404", + "status": "tested", + "text": "If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601 (Method not found).", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header" + }, + { + "check": "sep-2575-server-honors-notification-filter", + "status": "tested", + "text": "The server MUST NOT send notification types the client has not explicitly requested.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#opening-a-stream" + }, + { + "check": "sep-2575-server-sends-subscription-ack", + "status": "tested", + "text": "The server MUST send notifications/subscriptions/acknowledged as the first message on the stream.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions#acknowledgment" + }, + { + "check": "sep-2575-client-declares-elicitation-capability", + "status": "tested", + "text": "Clients that support elicitation MUST declare the elicitation capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.", + "url": "https://modelcontextprotocol.io/specification/draft/client/elicitation#capabilities" + }, + { + "check": "sep-2575-client-declares-roots-capability", + "status": "tested", + "text": "Clients that support roots MUST declare the roots capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.", + "url": "https://modelcontextprotocol.io/specification/draft/client/roots#capabilities" + }, + { + "check": "sep-2575-client-declares-sampling-capability", + "status": "tested", + "text": "Clients that support sampling MUST declare the sampling capability in _meta.io.modelcontextprotocol/clientCapabilities on each request.", + "url": "https://modelcontextprotocol.io/specification/draft/client/sampling#capabilities" + }, + { + "check": "sep-2575-server-declares-prompts-in-discover", + "status": "tested", + "text": "Servers that support prompts MUST declare the prompts capability in their DiscoverResult.", + "url": "https://modelcontextprotocol.io/specification/draft/server/prompts#capabilities" + }, + { + "check": "sep-2575-server-sends-prompts-list-changed-on-subscription", + "status": "tested", + "text": "[A server with the listChanged] capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with promptsListChanged: true.", + "url": "https://modelcontextprotocol.io/specification/draft/server/prompts#list-changed-notification" + }, + { + "check": "sep-2575-server-sends-tools-list-changed-on-subscription", + "status": "tested", + "text": "[A server with the listChanged] capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with toolsListChanged: true.", + "url": "https://modelcontextprotocol.io/specification/draft/server/tools#list-changed-notification" + }, + { + "check": "sep-2575-server-no-log-without-loglevel", + "status": "tested", + "text": "The server MUST NOT emit notifications/message for a request that does not include [io.modelcontextprotocol/logLevel in _meta].", + "url": "https://modelcontextprotocol.io/specification/draft/server/utilities/logging#per-request-log-level" + } + ], + "excluded": [ + { + "text": "A server MUST NOT treat connection or process identity as a proxy for conversation or session continuity. / Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity).", + "reason": "internal server state, not directly wire-observable; the observable consequence (rejecting requests with incomplete _meta rather than falling back to remembered state) is covered by sep-2575-request-meta-invalid-* — see https://github.com/modelcontextprotocol/conformance/issues/296" + }, + { + "text": "Servers MUST NOT require that a client reuse the same connection to perform related operations.", + "reason": "not observable from a black-box harness; every harness request already arrives on an independent connection — see https://github.com/modelcontextprotocol/conformance/issues/296" + }, + { + "text": "Closing the SSE response stream MUST be treated by the server as cancellation of that request.", + "reason": "\"treated as cancellation\" is internal server state; once the stream is closed there is no channel left on which to observe the effect — see https://github.com/modelcontextprotocol/conformance/issues/296" + }, + { + "text": "The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].", + "reason": "\"stop work as soon as practical\" is unobservable from a black-box harness, and \"no further messages\" cannot be verified once the response stream is closed — see https://github.com/modelcontextprotocol/conformance/issues/296" + }, + { + "text": "State that needs to span multiple requests (e.g., long-running tasks, application-level handles) MUST be referenced by an explicit identifier the client passes on each request.", + "reason": "architectural guidance, observable only via subscriptionId/task-id rows already listed" + }, + { + "text": "To distinguish notifications belonging to different concurrent subscriptions, clients MUST correlate notifications using the io.modelcontextprotocol/subscriptionId field carried in _meta.", + "reason": "client-internal demux; not observable on the wire from the harness" + }, + { + "text": "The client SHOULD check the acknowledged filter against what it requested and handle any unsupported types gracefully.", + "reason": "internal comparison; \"gracefully\" has no wire-observable definition" + }, + { + "text": "Because there is no per-request status code to drive fallback, a client that supports both eras SHOULD probe with server/discover first [stdio backward compatibility].", + "reason": "stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258" + }, + { + "text": "To cancel an in-flight request [on stdio], the client MUST send a notifications/cancelled notification referencing the request ID.", + "reason": "stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258" + }, + { + "text": "Servers SHOULD stop work on a cancelled request as soon as practical and MUST NOT send any further messages for it [stdio].", + "reason": "stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258" + }, + { + "text": "If the server process exits unexpectedly, the client SHOULD restart it.", + "reason": "stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258" + }, + { + "text": "If the server returns UnsupportedProtocolVersionError, [the stdio client] SHOULD retry using one of the advertised supportedVersions rather than falling back to initialize.", + "reason": "stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258" + }, + { + "text": "On stdio, if the connection is terminated and then re-established, the client MUST re-send subscriptions/listen to re-establish its subscriptions.", + "reason": "stdio client harness not implemented — see https://github.com/modelcontextprotocol/conformance/issues/258" + } + ], + "unkeyed": [], + "untracked": [ + "sep-2575-discover-capabilities-match-handlers", + "sep-2575-http-server-error-jsonrpc-id", + "sep-2575-http-server-method-not-found-404-initialize", + "sep-2575-http-server-method-not-found-404-logging-setlevel", + "sep-2575-http-server-method-not-found-404-ping", + "sep-2575-http-server-method-not-found-404-resources-subscribe", + "sep-2575-http-server-method-not-found-404-resources-unsubscribe", + "sep-2575-request-meta-invalid-missing-client-capabilities", + "sep-2575-request-meta-invalid-missing-client-info", + "sep-2575-request-meta-invalid-missing-meta", + "sep-2575-request-meta-invalid-missing-protocol-version" + ], + "summary": { + "tested": 22, + "untested": 0, + "excluded": 13, + "untracked": 11, + "unkeyed": 0 + } + } + } +} diff --git a/src/spec-types/2025-03-26.schema.json b/src/spec-types/2025-03-26.schema.json new file mode 100644 index 0000000..a1e3f26 --- /dev/null +++ b/src/spec-types/2025-03-26.schema.json @@ -0,0 +1,2139 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended customer of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/definitions/Role" + }, + "type": "array" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "properties": { + "arguments": { + "additionalProperties": {}, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "content": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + }, + { + "$ref": "#/definitions/EmbeddedResource" + } + ] + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).", + "type": "boolean" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.", + "properties": { + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "properties": { + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/definitions/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." + } + }, + "required": [ + "requestId" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "additionalProperties": true, + "description": "Present if the client supports sampling from an LLM.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/definitions/CancelledNotification" + }, + { + "$ref": "#/definitions/InitializedNotification" + }, + { + "$ref": "#/definitions/ProgressNotification" + }, + { + "$ref": "#/definitions/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeRequest" + }, + { + "$ref": "#/definitions/PingRequest" + }, + { + "$ref": "#/definitions/ListResourcesRequest" + }, + { + "$ref": "#/definitions/ListResourceTemplatesRequest" + }, + { + "$ref": "#/definitions/ReadResourceRequest" + }, + { + "$ref": "#/definitions/SubscribeRequest" + }, + { + "$ref": "#/definitions/UnsubscribeRequest" + }, + { + "$ref": "#/definitions/ListPromptsRequest" + }, + { + "$ref": "#/definitions/GetPromptRequest" + }, + { + "$ref": "#/definitions/ListToolsRequest" + }, + { + "$ref": "#/definitions/CallToolRequest" + }, + { + "$ref": "#/definitions/SetLevelRequest" + }, + { + "$ref": "#/definitions/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/definitions/Result" + }, + { + "$ref": "#/definitions/CreateMessageResult" + }, + { + "$ref": "#/definitions/ListRootsResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "properties": { + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/definitions/PromptReference" + }, + { + "$ref": "#/definitions/ResourceReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/definitions/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/definitions/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/definitions/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/definitions/TextResourceContents" + }, + { + "$ref": "#/definitions/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/definitions/Result" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the name and version of an MCP implementation.", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "properties": { + "capabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "capabilities": { + "$ref": "#/definitions/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/definitions/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "JSONRPCBatchRequest": { + "description": "A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + } + ] + }, + "type": "array" + }, + "JSONRPCBatchResponse": { + "description": "A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + } + ] + }, + "type": "array" + }, + "JSONRPCError": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "id", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + }, + { + "description": "A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + } + ] + }, + "type": "array" + }, + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + }, + { + "description": "A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + } + ] + }, + "type": "array" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/definitions/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/definitions/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/definitions/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/definitions/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "properties": { + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/definitions/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/definitions/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "properties": { + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/definitions/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "The name of the argument.", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + }, + { + "$ref": "#/definitions/EmbeddedResource" + } + ] + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "The name of the prompt or prompt template", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TextResourceContents" + }, + { + "$ref": "#/definitions/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "A human-readable name for this resource.\n\nThis can be used by clients to populate UI elements.", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ResourceReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "A human-readable name for the type of resource this template refers to.\n\nThis can be used by clients to populate UI elements.", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + } + ] + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/definitions/CancelledNotification" + }, + { + "$ref": "#/definitions/ProgressNotification" + }, + { + "$ref": "#/definitions/ResourceListChangedNotification" + }, + { + "$ref": "#/definitions/ResourceUpdatedNotification" + }, + { + "$ref": "#/definitions/PromptListChangedNotification" + }, + { + "$ref": "#/definitions/ToolListChangedNotification" + }, + { + "$ref": "#/definitions/LoggingMessageNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/definitions/PingRequest" + }, + { + "$ref": "#/definitions/CreateMessageRequest" + }, + { + "$ref": "#/definitions/ListRootsRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/definitions/Result" + }, + { + "$ref": "#/definitions/InitializeResult" + }, + { + "$ref": "#/definitions/ListResourcesResult" + }, + { + "$ref": "#/definitions/ListResourceTemplatesResult" + }, + { + "$ref": "#/definitions/ReadResourceResult" + }, + { + "$ref": "#/definitions/ListPromptsResult" + }, + { + "$ref": "#/definitions/GetPromptResult" + }, + { + "$ref": "#/definitions/ListToolsResult" + }, + { + "$ref": "#/definitions/CallToolResult" + }, + { + "$ref": "#/definitions/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "properties": { + "level": { + "$ref": "#/definitions/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "annotations": { + "$ref": "#/definitions/ToolAnnotations", + "description": "Optional additional tool information." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to unsubscribe from.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + } + } +} + diff --git a/src/spec-types/2025-03-26.ts b/src/spec-types/2025-03-26.ts new file mode 100644 index 0000000..6da0f59 --- /dev/null +++ b/src/spec-types/2025-03-26.ts @@ -0,0 +1,1258 @@ +/* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + */ +export type JSONRPCMessage = + | JSONRPCRequest + | JSONRPCNotification + | JSONRPCBatchRequest + | JSONRPCResponse + | JSONRPCError + | JSONRPCBatchResponse; + +/** + * A JSON-RPC batch request, as described in https://www.jsonrpc.org/specification#batch. + */ +export type JSONRPCBatchRequest = (JSONRPCRequest | JSONRPCNotification)[]; + +/** + * A JSON-RPC batch response, as described in https://www.jsonrpc.org/specification#batch. + */ +export type JSONRPCBatchResponse = (JSONRPCResponse | JSONRPCError)[]; + +export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export const JSONRPC_VERSION = "2.0"; + +/** + * A progress token, used to associate progress notifications with the original request. + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + */ +export type Cursor = string; + +export interface Request { + method: string; + params?: { + _meta?: { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + }; + [key: string]: unknown; + }; +} + +export interface Notification { + method: string; + params?: { + /** + * This parameter name is reserved by MCP to allow clients and servers to attach additional metadata to their notifications. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; + }; +} + +export interface Result { + /** + * This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + */ +export interface JSONRPCResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +/** + * A response to a request that indicates an error occurred. + */ +export interface JSONRPCError { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + error: { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + */ +export interface CancelledNotification extends Notification { + method: "notifications/cancelled"; + params: { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + */ + requestId: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; + }; +} + +/* Initialization */ +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + */ +export interface InitializeRequest extends Request { + method: "initialize"; + params: { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; + }; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + */ +export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + */ +export interface InitializedNotification extends Notification { + method: "notifications/initialized"; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: object; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; +} + +/** + * Describes the name and version of an MCP implementation. + */ +export interface Implementation { + name: string; + version: string; +} + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + */ +export interface PingRequest extends Request { + method: "ping"; +} + +/* Progress notifications */ +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + */ +export interface ProgressNotification extends Notification { + method: "notifications/progress"; + params: { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; + }; +} + +/* Pagination */ +export interface PaginatedRequest extends Request { + params?: { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; + }; +} + +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; +} + +/** + * The server's response to a resources/list request from the client. + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; +} + +/** + * The server's response to a resources/templates/list request from the client. + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Sent from the client to the server, to read a specific resource URI. + */ +export interface ReadResourceRequest extends Request { + method: "resources/read"; + params: { + /** + * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; + }; +} + +/** + * The server's response to a resources/read request from the client. + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + */ +export interface ResourceListChangedNotification extends Notification { + method: "notifications/resources/list_changed"; +} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + */ +export interface SubscribeRequest extends Request { + method: "resources/subscribe"; + params: { + /** + * The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; + }; +} + +/** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + */ +export interface UnsubscribeRequest extends Request { + method: "resources/unsubscribe"; + params: { + /** + * The URI of the resource to unsubscribe from. + * + * @format uri + */ + uri: string; + }; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + */ +export interface ResourceUpdatedNotification extends Notification { + method: "notifications/resources/updated"; + params: { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; + }; +} + +/** + * A known resource that the server is capable of reading. + */ +export interface Resource { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A human-readable name for this resource. + * + * This can be used by clients to populate UI elements. + */ + name: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; +} + +/** + * A template description for resources available on the server. + */ +export interface ResourceTemplate { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A human-readable name for the type of resource this template refers to. + * + * This can be used by clients to populate UI elements. + */ + name: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; +} + +/** + * The contents of a specific resource or sub-resource. + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; +} + +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; +} + +/** + * The server's response to a prompts/list request from the client. + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Used by the client to get a prompt provided by the server. + */ +export interface GetPromptRequest extends Request { + method: "prompts/get"; + params: { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * The server's response to a prompts/get request from the client. + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A prompt or prompt template that the server offers. + */ +export interface Prompt { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * An optional description of what this prompt provides + */ + description?: string; + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; +} + +/** + * Describes an argument that a prompt can accept. + */ +export interface PromptArgument { + /** + * The name of the argument. + */ + name: string; + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + */ +export type Role = "user" | "assistant"; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + */ +export interface PromptMessage { + role: Role; + content: TextContent | ImageContent | AudioContent | EmbeddedResource; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + */ +export interface EmbeddedResource { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; +} + +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + */ +export interface PromptListChangedNotification extends Notification { + method: "notifications/prompts/list_changed"; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + */ +export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; +} + +/** + * The server's response to a tools/list request from the client. + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ +export interface CallToolResult extends Result { + content: (TextContent | ImageContent | AudioContent | EmbeddedResource)[]; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + */ + isError?: boolean; +} + +/** + * Used by the client to invoke a tool provided by the server. + */ +export interface CallToolRequest extends Request { + method: "tools/call"; + params: { + name: string; + arguments?: { [key: string]: unknown }; + }; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + */ +export interface ToolListChangedNotification extends Notification { + method: "notifications/tools/list_changed"; +} + +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on the its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Definition for a tool the client can call. + */ +export interface Tool { + /** + * The name of the tool. + */ + name: string; + + /** + * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + */ + annotations?: ToolAnnotations; +} + +/* Logging */ +/** + * A request from the client to the server, to enable or adjust logging. + */ +export interface SetLevelRequest extends Request { + method: "logging/setLevel"; + params: { + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + */ + level: LoggingLevel; + }; +} + +/** + * Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + */ +export interface LoggingMessageNotification extends Notification { + method: "notifications/message"; + params: { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; + }; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + */ +export type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + +/* Sampling */ +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + */ +export interface CreateMessageRequest extends Request { + method: "sampling/createMessage"; + params: { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request. + */ + includeContext?: "none" | "thisServer" | "allServers"; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The maximum number of tokens to sample, as requested by the server. The client MAY choose to sample fewer tokens than requested. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: object; + }; +} + +/** + * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. + */ +export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + /** + * The reason why sampling stopped, if known. + */ + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | string; +} + +/** + * Describes a message issued to or received from an LLM API. + */ +export interface SamplingMessage { + role: Role; + content: TextContent | ImageContent | AudioContent; +} + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + */ +export interface Annotations { + /** + * Describes who the intended customer of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; +} + +/** + * Text provided to or from an LLM. + */ +export interface TextContent { + type: "text"; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; +} + +/** + * An image provided to or from an LLM. + */ +export interface ImageContent { + type: "image"; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; +} + +/** + * Audio provided to or from an LLM. + */ +export interface AudioContent { + type: "audio"; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + */ +export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; +} + +/* Autocomplete */ +/** + * A request from the client to the server, to ask for completion options. + */ +export interface CompleteRequest extends Request { + method: "completion/complete"; + params: { + ref: PromptReference | ResourceReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + }; +} + +/** + * The server's response to a completion/complete request + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A reference to a resource or resource template definition. + */ +export interface ResourceReference { + type: "ref/resource"; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + */ +export interface PromptReference { + type: "ref/prompt"; + /** + * The name of the prompt or prompt template + */ + name: string; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + */ +export interface ListRootsRequest extends Request { + method: "roots/list"; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + */ +export interface Root { + /** + * The URI identifying the root. This *must* start with file:// for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + */ +export interface RootsListChangedNotification extends Notification { + method: "notifications/roots/list_changed"; +} + +/* Client messages */ +export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest; + +export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification; + +export type ClientResult = EmptyResult | CreateMessageResult | ListRootsResult; + +/* Server messages */ +export type ServerRequest = + PingRequest | CreateMessageRequest | ListRootsRequest; + +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification; + +export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult; diff --git a/src/spec-types/2025-06-18.schema.json b/src/spec-types/2025-06-18.schema.json new file mode 100644 index 0000000..775dc99 --- /dev/null +++ b/src/spec-types/2025-06-18.schema.json @@ -0,0 +1,2517 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended customer of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/definitions/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "properties": { + "arguments": { + "additionalProperties": {}, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/definitions/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional JSON object that represents the structured result of the tool call.", + "type": "object" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.", + "properties": { + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "properties": { + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/definitions/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction." + } + }, + "required": [ + "requestId" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "additionalProperties": true, + "description": "Present if the client supports elicitation from the server.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "additionalProperties": true, + "description": "Present if the client supports sampling from an LLM.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/definitions/CancelledNotification" + }, + { + "$ref": "#/definitions/InitializedNotification" + }, + { + "$ref": "#/definitions/ProgressNotification" + }, + { + "$ref": "#/definitions/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeRequest" + }, + { + "$ref": "#/definitions/PingRequest" + }, + { + "$ref": "#/definitions/ListResourcesRequest" + }, + { + "$ref": "#/definitions/ListResourceTemplatesRequest" + }, + { + "$ref": "#/definitions/ReadResourceRequest" + }, + { + "$ref": "#/definitions/SubscribeRequest" + }, + { + "$ref": "#/definitions/UnsubscribeRequest" + }, + { + "$ref": "#/definitions/ListPromptsRequest" + }, + { + "$ref": "#/definitions/GetPromptRequest" + }, + { + "$ref": "#/definitions/ListToolsRequest" + }, + { + "$ref": "#/definitions/CallToolRequest" + }, + { + "$ref": "#/definitions/SetLevelRequest" + }, + { + "$ref": "#/definitions/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/definitions/Result" + }, + { + "$ref": "#/definitions/CreateMessageResult" + }, + { + "$ref": "#/definitions/ListRootsResult" + }, + { + "$ref": "#/definitions/ElicitResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "properties": { + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/definitions/PromptReference" + }, + { + "$ref": "#/definitions/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + }, + { + "$ref": "#/definitions/ResourceLink" + }, + { + "$ref": "#/definitions/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/definitions/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/definitions/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/definitions/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "properties": { + "message": { + "description": "The message to present to the user.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "properties": { + "additionalProperties": { + "$ref": "#/definitions/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The client's response to an elicitation request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "action": { + "description": "The user action in response to the elicitation.\n- \"accept\": User submitted the form/confirmed the action\n- \"decline\": User explicitly declined the action\n- \"cancel\": User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "type": [ + "string", + "integer", + "boolean" + ] + }, + "description": "The submitted form data, only present when action is \"accept\".\nContains values matching the requested schema.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/definitions/TextResourceContents" + }, + { + "$ref": "#/definitions/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/definitions/Result" + }, + "EnumSchema": { + "properties": { + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the name and version of an MCP implementation, with an optional title for UI representation.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "properties": { + "capabilities": { + "$ref": "#/definitions/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/definitions/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "capabilities": { + "$ref": "#/definitions/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/definitions/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "JSONRPCError": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "id", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/definitions/JSONRPCRequest" + }, + { + "$ref": "#/definitions/JSONRPCNotification" + }, + { + "$ref": "#/definitions/JSONRPCResponse" + }, + { + "$ref": "#/definitions/JSONRPCError" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/definitions/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/definitions/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/definitions/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/definitions/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "properties": { + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/definitions/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/definitions/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NumberSchema": { + "properties": { + "description": { + "type": "string" + }, + "maximum": { + "type": "integer" + }, + "minimum": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "properties": { + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/definitions/StringSchema" + }, + { + "$ref": "#/definitions/NumberSchema" + }, + { + "$ref": "#/definitions/BooleanSchema" + }, + { + "$ref": "#/definitions/EnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "properties": { + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/definitions/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/definitions/ContentBlock" + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/definitions/TextResourceContents" + }, + { + "$ref": "#/definitions/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/definitions/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "content": { + "anyOf": [ + { + "$ref": "#/definitions/TextContent" + }, + { + "$ref": "#/definitions/ImageContent" + }, + { + "$ref": "#/definitions/AudioContent" + } + ] + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/definitions/CancelledNotification" + }, + { + "$ref": "#/definitions/ProgressNotification" + }, + { + "$ref": "#/definitions/ResourceListChangedNotification" + }, + { + "$ref": "#/definitions/ResourceUpdatedNotification" + }, + { + "$ref": "#/definitions/PromptListChangedNotification" + }, + { + "$ref": "#/definitions/ToolListChangedNotification" + }, + { + "$ref": "#/definitions/LoggingMessageNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/definitions/PingRequest" + }, + { + "$ref": "#/definitions/CreateMessageRequest" + }, + { + "$ref": "#/definitions/ListRootsRequest" + }, + { + "$ref": "#/definitions/ElicitRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/definitions/Result" + }, + { + "$ref": "#/definitions/InitializeResult" + }, + { + "$ref": "#/definitions/ListResourcesResult" + }, + { + "$ref": "#/definitions/ListResourceTemplatesResult" + }, + { + "$ref": "#/definitions/ReadResourceResult" + }, + { + "$ref": "#/definitions/ListPromptsResult" + }, + { + "$ref": "#/definitions/GetPromptResult" + }, + { + "$ref": "#/definitions/ListToolsResult" + }, + { + "$ref": "#/definitions/CallToolResult" + }, + { + "$ref": "#/definitions/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "properties": { + "level": { + "$ref": "#/definitions/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "StringSchema": { + "properties": { + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/definitions/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: title, annotations.title, then name." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a CallToolResult.", + "properties": { + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on the its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "properties": { + "uri": { + "description": "The URI of the resource to unsubscribe from.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + } + } +} + diff --git a/src/spec-types/2025-06-18.ts b/src/spec-types/2025-06-18.ts new file mode 100644 index 0000000..0df081e --- /dev/null +++ b/src/spec-types/2025-06-18.ts @@ -0,0 +1,1607 @@ +/* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + * + * @category JSON-RPC + */ +export type JSONRPCMessage = + JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError; + +/** @internal */ +export const LATEST_PROTOCOL_VERSION = "2025-06-18"; +/** @internal */ +export const JSONRPC_VERSION = "2.0"; + +/** + * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + * + * @category Common Types + */ +export type Cursor = string; + +/** @internal */ +export interface Request { + method: string; + params?: { + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + [key: string]: unknown; + }; + [key: string]: unknown; + }; +} + +/** @internal */ +export interface Notification { + method: string; + params?: { + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; + }; +} + +/** + * @category Common Types + */ +export interface Result { + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + * + * @category JSON-RPC + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + * + * @category JSON-RPC + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + * + * @category JSON-RPC + */ +export interface JSONRPCResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +/** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ +export interface JSONRPCError { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + error: { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends Notification { + method: "notifications/cancelled"; + params: { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + */ + requestId: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; + }; +} + +/* Initialization */ +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + * + * @category `initialize` + */ +export interface InitializeRequest extends Request { + method: "initialize"; + params: { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; + }; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + * + * @category `initialize` + */ +export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @category `notifications/initialized` + */ +export interface InitializedNotification extends Notification { + method: "notifications/initialized"; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: object; + /** + * Present if the client supports elicitation from the server. + */ + elicitation?: object; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; +} + +/** + * Base interface for metadata with name (identifier) and title (display name) properties. + * + * @internal + */ +export interface BaseMetadata { + /** + * Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + */ + name: string; + + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title?: string; +} + +/** + * Describes the name and version of an MCP implementation, with an optional title for UI representation. + * + * @category `initialize` + */ +export interface Implementation extends BaseMetadata { + version: string; +} + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + * + * @category `ping` + */ +export interface PingRequest extends Request { + method: "ping"; +} + +/* Progress notifications */ +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends Notification { + method: "notifications/progress"; + params: { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; + }; +} + +/* Pagination */ +/** @internal */ +export interface PaginatedRequest extends Request { + params?: { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; + }; +} + +/** @internal */ +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; +} + +/** + * The server's response to a resources/list request from the client. + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; +} + +/** + * The server's response to a resources/templates/list request from the client. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Sent from the client to the server, to read a specific resource URI. + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends Request { + method: "resources/read"; + params: { + /** + * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; + }; +} + +/** + * The server's response to a resources/read request from the client. + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends Notification { + method: "notifications/resources/list_changed"; +} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends Request { + method: "resources/subscribe"; + params: { + /** + * The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; + }; +} + +/** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends Request { + method: "resources/unsubscribe"; + params: { + /** + * The URI of the resource to unsubscribe from. + * + * @format uri + */ + uri: string; + }; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotification extends Notification { + method: "notifications/resources/updated"; + params: { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; + }; +} + +/** + * A known resource that the server is capable of reading. + * + * @category `resources/list` + */ +export interface Resource extends BaseMetadata { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A template description for resources available on the server. + * + * @category `resources/templates/list` + */ +export interface ResourceTemplate extends BaseMetadata { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The contents of a specific resource or sub-resource. + * + * @internal + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @category Content + */ +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +/** + * @category Content + */ +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; +} + +/** + * The server's response to a prompts/list request from the client. + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Used by the client to get a prompt provided by the server. + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends Request { + method: "prompts/get"; + params: { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * The server's response to a prompts/get request from the client. + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A prompt or prompt template that the server offers. + * + * @category `prompts/list` + */ +export interface Prompt extends BaseMetadata { + /** + * An optional description of what this prompt provides + */ + description?: string; + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Describes an argument that a prompt can accept. + * + * @category `prompts/list` + */ +export interface PromptArgument extends BaseMetadata { + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + * + * @category Common Types + */ +export type Role = "user" | "assistant"; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + * + * @category `prompts/get` + */ +export interface PromptMessage { + role: Role; + content: ContentBlock; +} + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + * + * @category Content + */ +export interface ResourceLink extends Resource { + type: "resource_link"; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + * + * @category Content + */ +export interface EmbeddedResource { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/prompts/list_changed` + */ +export interface PromptListChangedNotification extends Notification { + method: "notifications/prompts/list_changed"; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; +} + +/** + * The server's response to a tools/list request from the client. + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * @category `tools/call` + */ +export interface CallToolResult extends Result { + /** + * A list of content objects that represent the unstructured result of the tool call. + */ + content: ContentBlock[]; + + /** + * An optional JSON object that represents the structured result of the tool call. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError?: boolean; +} + +/** + * Used by the client to invoke a tool provided by the server. + * + * @category `tools/call` + */ +export interface CallToolRequest extends Request { + method: "tools/call"; + params: { + name: string; + arguments?: { [key: string]: unknown }; + }; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends Notification { + method: "notifications/tools/list_changed"; +} + +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + * + * @category `tools/list` + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on the its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Definition for a tool the client can call. + * + * @category `tools/list` + */ +export interface Tool extends BaseMetadata { + /** + * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + */ + outputSchema?: { + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: title, annotations.title, then name. + */ + annotations?: ToolAnnotations; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/* Logging */ +/** + * A request from the client to the server, to enable or adjust logging. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends Request { + method: "logging/setLevel"; + params: { + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + */ + level: LoggingLevel; + }; +} + +/** + * Notification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotification extends Notification { + method: "notifications/message"; + params: { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; + }; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @category Common Types + */ +export type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + +/* Sampling */ +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends Request { + method: "sampling/createMessage"; + params: { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. The client MAY ignore this request. + */ + includeContext?: "none" | "thisServer" | "allServers"; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: object; + }; +} + +/** + * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + /** + * The reason why sampling stopped, if known. + */ + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | string; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: TextContent | ImageContent | AudioContent; +} + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types + */ +export interface Annotations { + /** + * Describes who the intended customer of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + */ + lastModified?: string; +} + +/** + * @category Content + */ +export type ContentBlock = + TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; + +/** + * Text provided to or from an LLM. + * + * @category Content + */ +export interface TextContent { + type: "text"; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * An image provided to or from an LLM. + * + * @category Content + */ +export interface ImageContent { + type: "image"; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Audio provided to or from an LLM. + * + * @category Content + */ +export interface AudioContent { + type: "audio"; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + * + * @category `sampling/createMessage` + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + * + * @category `sampling/createMessage` + */ +export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; +} + +/* Autocomplete */ +/** + * A request from the client to the server, to ask for completion options. + * + * @category `completion/complete` + */ +export interface CompleteRequest extends Request { + method: "completion/complete"; + params: { + ref: PromptReference | ResourceTemplateReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + + /** + * Additional, optional context for completions + */ + context?: { + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments?: { [key: string]: string }; + }; + }; +} + +/** + * The server's response to a completion/complete request + * + * @category `completion/complete` + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A reference to a resource or resource template definition. + * + * @category `completion/complete` + */ +export interface ResourceTemplateReference { + type: "ref/resource"; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + * + * @category `completion/complete` + */ +export interface PromptReference extends BaseMetadata { + type: "ref/prompt"; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + * + * @category `roots/list` + */ +export interface ListRootsRequest extends Request { + method: "roots/list"; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @category `roots/list` + */ +export interface Root { + /** + * The URI identifying the root. This *must* start with file:// for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; + + /** + * See [General fields: `_meta`](/specification/2025-06-18/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + * + * @category `notifications/roots/list_changed` + */ +export interface RootsListChangedNotification extends Notification { + method: "notifications/roots/list_changed"; +} + +/** + * A request from the server to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequest extends Request { + method: "elicitation/create"; + params: { + /** + * The message to present to the user. + */ + message: string; + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: { + type: "object"; + properties: { + [key: string]: PrimitiveSchemaDefinition; + }; + required?: string[]; + }; + }; +} + +/** + * Restricted schema definitions that only allow primitive types + * without nested objects or arrays. + * + * @category `elicitation/create` + */ +export type PrimitiveSchemaDefinition = + StringSchema | NumberSchema | BooleanSchema | EnumSchema; + +/** + * @category `elicitation/create` + */ +export interface StringSchema { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; +} + +/** + * @category `elicitation/create` + */ +export interface NumberSchema { + type: "number" | "integer"; + title?: string; + description?: string; + minimum?: number; + maximum?: number; +} + +/** + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; +} + +/** + * @category `elicitation/create` + */ +export interface EnumSchema { + type: "string"; + title?: string; + description?: string; + enum: string[]; + enumNames?: string[]; // Display names for enum values +} + +/** + * The client's response to an elicitation request. + * + * @category `elicitation/create` + */ +export interface ElicitResult extends Result { + /** + * The user action in response to the elicitation. + * - "accept": User submitted the form/confirmed the action + * - "decline": User explicitly declined the action + * - "cancel": User dismissed without making an explicit choice + */ + action: "accept" | "decline" | "cancel"; + + /** + * The submitted form data, only present when action is "accept". + * Contains values matching the requested schema. + */ + content?: { [key: string]: string | number | boolean }; +} + +/* Client messages */ +/** @internal */ +export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest; + +/** @internal */ +export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification; + +/** @internal */ +export type ClientResult = + EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult; + +/* Server messages */ +/** @internal */ +export type ServerRequest = + PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest; + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult; diff --git a/src/spec-types/2025-11-25.schema.json b/src/spec-types/2025-11-25.schema.json new file mode 100644 index 0000000..9d2e662 --- /dev/null +++ b/src/spec-types/2025-11-25.schema.json @@ -0,0 +1,4058 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional JSON object that represents the structured result of the tool call.", + "type": "object" + } + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelTaskRequest": { + "description": "A request to cancel a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/cancel", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to cancel.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/cancel request." + }, + "CancelledNotification": { + "description": "This notification can be sent by either side to indicate that it is cancelling a previously-issued request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.\n\nA client MUST NOT attempt to cancel its `initialize` request.\n\nFor task cancellation, use the `tasks/cancel` request instead of this notification.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request previously issued in the same direction.\nThis MUST be provided for cancelling non-task requests.\nThis MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead)." + } + }, + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "url": { + "additionalProperties": true, + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": { + "listChanged": { + "description": "Whether the client supports notifications for changes to the roots list.", + "type": "boolean" + } + }, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "additionalProperties": true, + "description": "Whether the client supports context inclusion via includeContext parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it).", + "properties": {}, + "type": "object" + }, + "tools": { + "additionalProperties": true, + "description": "Whether the client supports tool use via tools and toolChoice parameters.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the client supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this client supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this client supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "elicitation": { + "description": "Task support for elicitation-related requests.", + "properties": { + "create": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented elicitation/create requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + }, + "sampling": { + "description": "Task support for sampling-related requests.", + "properties": { + "createMessage": { + "additionalProperties": true, + "description": "Whether the client supports task-augmented sampling/createMessage requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/InitializedNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/RootsListChangedNotification" + } + ] + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/InitializeRequest" + }, + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscribeRequest" + }, + { + "$ref": "#/$defs/UnsubscribeRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/SetLevelRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The server's response to a completion/complete request", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + } + }, + "required": [ + "completion" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is \"none\". Values \"thisServer\" and \"allServers\" are soft-deprecated. Servers SHOULD only use these values if the client\ndeclares ClientCapabilities.sampling.context. These values may be removed in future spec releases.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific.", + "properties": {}, + "type": "object" + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The client's response to a sampling/createMessage request from the server.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- \"endTurn\": Natural end of the assistant's turn\n- \"stopSequence\": A stop sequence was encountered\n- \"maxTokens\": Maximum token limit was reached\n- \"toolUse\": The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "CreateTaskResult": { + "description": "A response to a task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "task": { + "$ref": "#/$defs/Task" + } + }, + "required": [ + "task" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + { + "$ref": "#/$defs/ElicitRequestFormParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "elicitationId": { + "description": "The ID of the elicitation, which must be unique within the context of the server.\nThe client MUST treat this ID as an opaque value.", + "type": "string" + }, + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "elicitationId", + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The client's response to an elicitation request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "action": { + "description": "The user action in response to the elicitation.\n- \"accept\": User submitted the form/confirmed the action\n- \"decline\": User explicitly decline the action\n- \"cancel\": User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is \"accept\" and mode was \"form\".\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "ElicitationCompleteNotification": { + "description": "An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/elicitation/complete", + "type": "string" + }, + "params": { + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "type": "string" + } + }, + "required": [ + "elicitationId" + ], + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result" + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The server's response to a prompts/get request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "GetTaskPayloadRequest": { + "description": "A request to retrieve the result of a completed task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/result", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to retrieve results for.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskPayloadResult": { + "additionalProperties": {}, + "description": "The response to a tasks/result request.\nThe structure matches the result type of the original request.\nFor example, a tools/call task would return the CallToolResult structure.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "GetTaskRequest": { + "description": "A request to retrieve the state of a task.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/get", + "type": "string" + }, + "params": { + "properties": { + "taskId": { + "description": "The task identifier to query.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetTaskResult": { + "allOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "The response to a tasks/get request." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD takes steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `light` indicates\nthe icon is designed to be used with a light background, and `dark` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InitializeRequest": { + "description": "This request is sent from the client to the server when it first connects, asking it to begin initialization.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "initialize", + "type": "string" + }, + "params": { + "$ref": "#/$defs/InitializeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "InitializeRequestParams": { + "description": "Parameters for an `initialize` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ClientCapabilities" + }, + "clientInfo": { + "$ref": "#/$defs/Implementation" + }, + "protocolVersion": { + "description": "The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.", + "type": "string" + } + }, + "required": [ + "capabilities", + "clientInfo", + "protocolVersion" + ], + "type": "object" + }, + "InitializeResult": { + "description": "After receiving an initialize request from the client, the server sends this response.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities" + }, + "instructions": { + "description": "Instructions describing how to use the server and its features.\n\nThis can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a \"hint\" to the model. For example, this information MAY be added to the system prompt.", + "type": "string" + }, + "protocolVersion": { + "description": "The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect.", + "type": "string" + }, + "serverInfo": { + "$ref": "#/$defs/Implementation" + } + }, + "required": [ + "capabilities", + "protocolVersion", + "serverInfo" + ], + "type": "object" + }, + "InitializedNotification": { + "description": "This notification is sent from the client to the server after initialization has finished.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/initialized", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LegacyTitledEnumSchema": { + "description": "Use TitledSingleSelectEnumSchema instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The server's response to a prompts/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + } + }, + "required": [ + "prompts" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The server's response to a resources/templates/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + } + }, + "required": [ + "resourceTemplates" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The server's response to a resources/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + } + }, + "required": [ + "resources" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The client's response to a roots/list request from the server.\nThis result contains an array of Root objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListTasksRequest": { + "description": "A request to retrieve a list of tasks.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tasks/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListTasksResult": { + "description": "The response to a tasks/list request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tasks": { + "items": { + "$ref": "#/$defs/Task" + }, + "type": "array" + } + }, + "required": [ + "tasks" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The server's response to a tools/list request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationParams": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "integer" + }, + "minimum": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common parameters for paginated requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + } + }, + "type": "object" + }, + "PingRequest": { + "description": "A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "ping", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a `notifications/progress` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to `SamplingMessage`, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The server's response to a resources/read request from the client.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + } + }, + "required": [ + "contents" + ], + "type": "object" + }, + "RelatedTaskMetadata": { + "description": "Metadata for associating messages with a task.\nInclude this in the `_meta` field under the key `io.modelcontextprotocol/related-task`.", + "properties": { + "taskId": { + "description": "The task identifier this message is associated with.", + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common parameters when working with resources.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + } + }, + "type": "object" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with file:// for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "RootsListChangedNotification": { + "description": "A notification from the client to the server, informing it that the list of roots has changed.\nThis notification should be sent whenever the client adds, removes, or modifies any root.\nThe server should then request an updated list of roots using the ListRootsRequest.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/roots/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "additionalProperties": true, + "description": "Present if the server supports argument autocompletion suggestions.", + "properties": {}, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "logging": { + "additionalProperties": true, + "description": "Present if the server supports sending log messages to the client.", + "properties": {}, + "type": "object" + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tasks": { + "description": "Present if the server supports task-augmented requests.", + "properties": { + "cancel": { + "additionalProperties": true, + "description": "Whether this server supports tasks/cancel.", + "properties": {}, + "type": "object" + }, + "list": { + "additionalProperties": true, + "description": "Whether this server supports tasks/list.", + "properties": {}, + "type": "object" + }, + "requests": { + "description": "Specifies which request types can be augmented with tasks.", + "properties": { + "tools": { + "description": "Task support for tool-related requests.", + "properties": { + "call": { + "additionalProperties": true, + "description": "Whether the server supports task-augmented tools/call requests.", + "properties": {}, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/TaskStatusNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + }, + { + "$ref": "#/$defs/ElicitationCompleteNotification" + } + ] + }, + "ServerRequest": { + "anyOf": [ + { + "$ref": "#/$defs/PingRequest" + }, + { + "$ref": "#/$defs/GetTaskRequest" + }, + { + "$ref": "#/$defs/GetTaskPayloadRequest" + }, + { + "$ref": "#/$defs/CancelTaskRequest" + }, + { + "$ref": "#/$defs/ListTasksRequest" + }, + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InitializeResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/GetTaskResult", + "description": "The response to a tasks/get request." + }, + { + "$ref": "#/$defs/GetTaskPayloadResult" + }, + { + "$ref": "#/$defs/CancelTaskResult", + "description": "The response to a tasks/cancel request." + }, + { + "$ref": "#/$defs/ListTasksResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SetLevelRequest": { + "description": "A request from the client to the server, to enable or adjust logging.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "logging/setLevel", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SetLevelRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SetLevelRequestParams": { + "description": "Parameters for a `logging/setLevel` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message." + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscribeRequest": { + "description": "Sent from the client to request resources/updated notifications from the server whenever a particular resource changes.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/subscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscribeRequestParams": { + "description": "Parameters for a `resources/subscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Task": { + "description": "Data associated with a task.", + "properties": { + "createdAt": { + "description": "ISO 8601 timestamp when the task was created.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO 8601 timestamp when the task was last updated.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval in milliseconds.", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/TaskStatus", + "description": "Current task state." + }, + "statusMessage": { + "description": "Optional human-readable message describing the current task state.\nThis can provide context for any status, including:\n- Reasons for \"cancelled\" status\n- Summaries for \"completed\" status\n- Diagnostic information for \"failed\" status (e.g., error details, what went wrong)", + "type": "string" + }, + "taskId": { + "description": "The task identifier.", + "type": "string" + }, + "ttl": { + "description": "Actual retention duration from creation in milliseconds, null for unlimited.", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "createdAt", + "lastUpdatedAt", + "status", + "taskId", + "ttl" + ], + "type": "object" + }, + "TaskAugmentedRequestParams": { + "description": "Common params for any task-augmented request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "task": { + "$ref": "#/$defs/TaskMetadata", + "description": "If specified, the caller is requesting task-augmented execution for this request.\nThe request will return a CreateTaskResult immediately, and the actual result can be\nretrieved later via tasks/result.\n\nTask augmentation is subject to capability negotiation - receivers MUST declare support\nfor task augmentation of specific request types in their capabilities." + } + }, + "type": "object" + }, + "TaskMetadata": { + "description": "Metadata for augmenting a request with task execution.\nInclude this in the `task` field of the request parameters.", + "properties": { + "ttl": { + "description": "Requested duration in milliseconds to retain task from creation.", + "type": "integer" + } + }, + "type": "object" + }, + "TaskStatus": { + "description": "The status of a task.", + "enum": [ + "cancelled", + "completed", + "failed", + "input_required", + "working" + ], + "type": "string" + }, + "TaskStatusNotification": { + "description": "An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tasks/status", + "type": "string" + }, + "params": { + "$ref": "#/$defs/TaskStatusNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "TaskStatusNotificationParams": { + "allOf": [ + { + "$ref": "#/$defs/NotificationParams" + }, + { + "$ref": "#/$defs/Task" + } + ], + "description": "Parameters for a `notifications/tasks/status` notification." + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: title, annotations.title, then name." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "execution": { + "$ref": "#/$defs/ToolExecution", + "description": "Execution-related properties for this tool." + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "description": "A JSON Schema object defining the expected parameters for the tool.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a CallToolResult.\n\nDefaults to JSON Schema 2020-12 when no explicit $schema is provided.\nCurrently restricted to type: \"object\" at the root level.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "additionalProperties": true, + "properties": {}, + "type": "object" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for Tool,\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a Tool to clients.\n\nNOTE: all properties in ToolAnnotations are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on ToolAnnotations\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- \"auto\": Model decides whether to use tools (default)\n- \"required\": Model MUST use at least one tool before completing\n- \"none\": Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolExecution": { + "description": "Execution-related properties for a tool.", + "properties": { + "taskSupport": { + "description": "Indicates whether this tool supports task-augmented execution.\nThis allows clients to handle long-running operations through polling\nthe task system.\n\n- \"forbidden\": Tool does not support task-augmented execution (default when absent)\n- \"optional\": Tool may support task-augmented execution\n- \"required\": Tool requires task-augmented execution\n\nDefault: \"forbidden\"", + "enum": [ + "forbidden", + "optional", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as CallToolResult.content and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "additionalProperties": {}, + "description": "An optional structured result object.\n\nIf the tool defined an outputSchema, this SHOULD conform to that schema.", + "type": "object" + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous ToolUseContent.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations.\n\nSee [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "type": "object" + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "URLElicitationRequiredError": { + "description": "An error response that indicates that the server requires the client to provide additional information via an elicitation request.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32042, + "type": "integer" + }, + "data": { + "additionalProperties": {}, + "properties": { + "elicitations": { + "items": { + "$ref": "#/$defs/ElicitRequestURLParams" + }, + "type": "array" + } + }, + "required": [ + "elicitations" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UnsubscribeRequest": { + "description": "Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/unsubscribe", + "type": "string" + }, + "params": { + "$ref": "#/$defs/UnsubscribeRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "UnsubscribeRequestParams": { + "description": "Parameters for a `resources/unsubscribe` request.", + "properties": { + "_meta": { + "additionalProperties": {}, + "description": "See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage.", + "properties": { + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "type": "object" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/src/spec-types/2025-11-25.ts b/src/spec-types/2025-11-25.ts new file mode 100644 index 0000000..b734ae3 --- /dev/null +++ b/src/spec-types/2025-11-25.ts @@ -0,0 +1,2573 @@ +/* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + * + * @category JSON-RPC + */ +export type JSONRPCMessage = + JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; + +/** @internal */ +export const LATEST_PROTOCOL_VERSION = "2025-11-25"; +/** @internal */ +export const JSONRPC_VERSION = "2.0"; + +/** + * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + * + * @category Common Types + */ +export type Cursor = string; + +/** + * Common params for any task-augmented request. + * + * @internal + */ +export interface TaskAugmentedRequestParams extends RequestParams { + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a CreateTaskResult immediately, and the actual result can be + * retrieved later via tasks/result. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task?: TaskMetadata; +} +/** + * Common params for any request. + * + * @internal + */ +export interface RequestParams { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + [key: string]: unknown; + }; +} + +/** @internal */ +export interface Request { + method: string; + // Allow unofficial extensions of `Request.params` without impacting `RequestParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** @internal */ +export interface NotificationParams { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** @internal */ +export interface Notification { + method: string; + // Allow unofficial extensions of `Notification.params` without impacting `NotificationParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * @category Common Types + */ +export interface Result { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * @category Common Types + */ +export interface Error { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + * + * @category JSON-RPC + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + * + * @category JSON-RPC + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + * + * @category JSON-RPC + */ +export interface JSONRPCResultResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +/** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ +export interface JSONRPCErrorResponse { + jsonrpc: typeof JSONRPC_VERSION; + id?: RequestId; + error: Error; +} + +/** + * A response to a request, containing either the result or error. + * + * @category JSON-RPC + */ +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +// Implementation-specific JSON-RPC error codes [-32000, -32099] +/** @internal */ +export const URL_ELICITATION_REQUIRED = -32042; + +/** + * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * + * @internal + */ +export interface URLElicitationRequiredError extends Omit< + JSONRPCErrorResponse, + "error" +> { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotificationParams extends NotificationParams { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + * This MUST be provided for cancelling non-task requests. + * This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). + */ + requestId?: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; +} + +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + * + * For task cancellation, use the `tasks/cancel` request instead of this notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: "notifications/cancelled"; + params: CancelledNotificationParams; +} + +/* Initialization */ +/** + * Parameters for an `initialize` request. + * + * @category `initialize` + */ +export interface InitializeRequestParams extends RequestParams { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; +} + +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + * + * @category `initialize` + */ +export interface InitializeRequest extends JSONRPCRequest { + method: "initialize"; + params: InitializeRequestParams; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + * + * @category `initialize` + */ +export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @category `notifications/initialized` + */ +export interface InitializedNotification extends JSONRPCNotification { + method: "notifications/initialized"; + params?: NotificationParams; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: { + /** + * Whether the client supports context inclusion via includeContext parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: object; + /** + * Whether the client supports tool use via tools and toolChoice parameters. + */ + tools?: object; + }; + /** + * Present if the client supports elicitation from the server. + */ + elicitation?: { form?: object; url?: object }; + + /** + * Present if the client supports task-augmented requests. + */ + tasks?: { + /** + * Whether this client supports tasks/list. + */ + list?: object; + /** + * Whether this client supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for sampling-related requests. + */ + sampling?: { + /** + * Whether the client supports task-augmented sampling/createMessage requests. + */ + createMessage?: object; + }; + /** + * Task support for elicitation-related requests. + */ + elicitation?: { + /** + * Whether the client supports task-augmented elicitation/create requests. + */ + create?: object; + }; + }; + }; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; + /** + * Present if the server supports task-augmented requests. + */ + tasks?: { + /** + * Whether this server supports tasks/list. + */ + list?: object; + /** + * Whether this server supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for tool-related requests. + */ + tools?: { + /** + * Whether the server supports task-augmented tools/call requests. + */ + call?: object; + }; + }; + }; +} + +/** + * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types + */ +export interface Icon { + /** + * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + * `data:` URI with Base64-encoded image data. + * + * Consumers SHOULD takes steps to ensure URLs serving icons are from the + * same domain as the client/server or a trusted domain. + * + * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + * executable JavaScript. + * + * @format uri + */ + src: string; + + /** + * Optional MIME type override if the source MIME type is missing or generic. + * For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + */ + mimeType?: string; + + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes?: string[]; + + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme?: "light" | "dark"; +} + +/** + * Base interface to add `icons` property. + * + * @internal + */ +export interface Icons { + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons?: Icon[]; +} + +/** + * Base interface for metadata with name (identifier) and title (display name) properties. + * + * @internal + */ +export interface BaseMetadata { + /** + * Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + */ + name: string; + + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title?: string; +} + +/** + * Describes the MCP implementation. + * + * @category `initialize` + */ +export interface Implementation extends BaseMetadata, Icons { + version: string; + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description?: string; + + /** + * An optional URL of the website for this implementation. + * + * @format uri + */ + websiteUrl?: string; +} + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + * + * @category `ping` + */ +export interface PingRequest extends JSONRPCRequest { + method: "ping"; + params?: RequestParams; +} + +/* Progress notifications */ + +/** + * Parameters for a `notifications/progress` notification. + * + * @category `notifications/progress` + */ +export interface ProgressNotificationParams extends NotificationParams { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; +} + +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: "notifications/progress"; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common parameters for paginated requests. + * + * @internal + */ +export interface PaginatedRequestParams extends RequestParams { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; +} + +/** @internal */ +export interface PaginatedRequest extends JSONRPCRequest { + params?: PaginatedRequestParams; +} + +/** @internal */ +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; +} + +/** + * The server's response to a resources/list request from the client. + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; +} + +/** + * The server's response to a resources/templates/list request from the client. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Common parameters when working with resources. + * + * @internal + */ +export interface ResourceRequestParams extends RequestParams { + /** + * The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; +} + +/** + * Parameters for a `resources/read` request. + * + * @category `resources/read` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ReadResourceRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to the server, to read a specific resource URI. + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: "resources/read"; + params: ReadResourceRequestParams; +} + +/** + * The server's response to a resources/read request from the client. + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: "notifications/resources/list_changed"; + params?: NotificationParams; +} + +/** + * Parameters for a `resources/subscribe` request. + * + * @category `resources/subscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface SubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends JSONRPCRequest { + method: "resources/subscribe"; + params: SubscribeRequestParams; +} + +/** + * Parameters for a `resources/unsubscribe` request. + * + * @category `resources/unsubscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UnsubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends JSONRPCRequest { + method: "resources/unsubscribe"; + params: UnsubscribeRequestParams; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotificationParams extends NotificationParams { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotification extends JSONRPCNotification { + method: "notifications/resources/updated"; + params: ResourceUpdatedNotificationParams; +} + +/** + * A known resource that the server is capable of reading. + * + * @category `resources/list` + */ +export interface Resource extends BaseMetadata, Icons { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A template description for resources available on the server. + * + * @category `resources/templates/list` + */ +export interface ResourceTemplate extends BaseMetadata, Icons { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The contents of a specific resource or sub-resource. + * + * @internal + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @category Content + */ +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +/** + * @category Content + */ +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; +} + +/** + * The server's response to a prompts/list request from the client. + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Parameters for a `prompts/get` request. + * + * @category `prompts/get` + */ +export interface GetPromptRequestParams extends RequestParams { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; +} + +/** + * Used by the client to get a prompt provided by the server. + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: "prompts/get"; + params: GetPromptRequestParams; +} + +/** + * The server's response to a prompts/get request from the client. + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A prompt or prompt template that the server offers. + * + * @category `prompts/list` + */ +export interface Prompt extends BaseMetadata, Icons { + /** + * An optional description of what this prompt provides + */ + description?: string; + + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Describes an argument that a prompt can accept. + * + * @category `prompts/list` + */ +export interface PromptArgument extends BaseMetadata { + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + * + * @category Common Types + */ +export type Role = "user" | "assistant"; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + * + * @category `prompts/get` + */ +export interface PromptMessage { + role: Role; + content: ContentBlock; +} + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + * + * @category Content + */ +export interface ResourceLink extends Resource { + type: "resource_link"; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + * + * @category Content + */ +export interface EmbeddedResource { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/prompts/list_changed` + */ +export interface PromptListChangedNotification extends JSONRPCNotification { + method: "notifications/prompts/list_changed"; + params?: NotificationParams; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; +} + +/** + * The server's response to a tools/list request from the client. + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * @category `tools/call` + */ +export interface CallToolResult extends Result { + /** + * A list of content objects that represent the unstructured result of the tool call. + */ + content: ContentBlock[]; + + /** + * An optional JSON object that represents the structured result of the tool call. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError?: boolean; +} + +/** + * Parameters for a `tools/call` request. + * + * @category `tools/call` + */ +export interface CallToolRequestParams extends TaskAugmentedRequestParams { + /** + * The name of the tool. + */ + name: string; + /** + * Arguments to use for the tool call. + */ + arguments?: { [key: string]: unknown }; +} + +/** + * Used by the client to invoke a tool provided by the server. + * + * @category `tools/call` + */ +export interface CallToolRequest extends JSONRPCRequest { + method: "tools/call"; + params: CallToolRequestParams; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: "notifications/tools/list_changed"; + params?: NotificationParams; +} + +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + * + * @category `tools/list` + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Execution-related properties for a tool. + * + * @category `tools/list` + */ +export interface ToolExecution { + /** + * Indicates whether this tool supports task-augmented execution. + * This allows clients to handle long-running operations through polling + * the task system. + * + * - "forbidden": Tool does not support task-augmented execution (default when absent) + * - "optional": Tool may support task-augmented execution + * - "required": Tool requires task-augmented execution + * + * Default: "forbidden" + */ + taskSupport?: "forbidden" | "optional" | "required"; +} + +/** + * Definition for a tool the client can call. + * + * @category `tools/list` + */ +export interface Tool extends BaseMetadata, Icons { + /** + * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + $schema?: string; + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Execution-related properties for this tool. + */ + execution?: ToolExecution; + + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + * + * Defaults to JSON Schema 2020-12 when no explicit $schema is provided. + * Currently restricted to type: "object" at the root level. + */ + outputSchema?: { + $schema?: string; + type: "object"; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: title, annotations.title, then name. + */ + annotations?: ToolAnnotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/* Tasks */ + +/** + * The status of a task. + * + * @category `tasks` + */ +export type TaskStatus = + | "working" // The request is currently being processed + | "input_required" // The task is waiting for input (e.g., elicitation or sampling) + | "completed" // The request completed successfully and results are available + | "failed" // The associated request did not complete successfully. For tool calls specifically, this includes cases where the tool call result has `isError` set to true. + | "cancelled"; // The request was cancelled before completion + +/** + * Metadata for augmenting a request with task execution. + * Include this in the `task` field of the request parameters. + * + * @category `tasks` + */ +export interface TaskMetadata { + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl?: number; +} + +/** + * Metadata for associating messages with a task. + * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + * + * @category `tasks` + */ +export interface RelatedTaskMetadata { + /** + * The task identifier this message is associated with. + */ + taskId: string; +} + +/** + * Data associated with a task. + * + * @category `tasks` + */ +export interface Task { + /** + * The task identifier. + */ + taskId: string; + + /** + * Current task state. + */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + */ + statusMessage?: string; + + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: string; + + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string; + + /** + * Actual retention duration from creation in milliseconds, null for unlimited. + * @nullable + */ + ttl: number | null; + + /** + * Suggested polling interval in milliseconds. + */ + pollInterval?: number; +} + +/** + * A response to a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResult extends Result { + task: Task; +} + +/** + * A request to retrieve the state of a task. + * + * @category `tasks/get` + */ +export interface GetTaskRequest extends JSONRPCRequest { + method: "tasks/get"; + params: { + /** + * The task identifier to query. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/get request. + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & Task; + +/** + * A request to retrieve the result of a completed task. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadRequest extends JSONRPCRequest { + method: "tasks/result"; + params: { + /** + * The task identifier to retrieve results for. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/result request. + * The structure matches the result type of the original request. + * For example, a tools/call task would return the CallToolResult structure. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResult extends Result { + [key: string]: unknown; +} + +/** + * A request to cancel a task. + * + * @category `tasks/cancel` + */ +export interface CancelTaskRequest extends JSONRPCRequest { + method: "tasks/cancel"; + params: { + /** + * The task identifier to cancel. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/cancel request. + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & Task; + +/** + * A request to retrieve a list of tasks. + * + * @category `tasks/list` + */ +export interface ListTasksRequest extends PaginatedRequest { + method: "tasks/list"; +} + +/** + * The response to a tasks/list request. + * + * @category `tasks/list` + */ +export interface ListTasksResult extends PaginatedResult { + tasks: Task[]; +} + +/** + * Parameters for a `notifications/tasks/status` notification. + * + * @category `notifications/tasks/status` + */ +export type TaskStatusNotificationParams = NotificationParams & Task; + +/** + * An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications. + * + * @category `notifications/tasks/status` + */ +export interface TaskStatusNotification extends JSONRPCNotification { + method: "notifications/tasks/status"; + params: TaskStatusNotificationParams; +} + +/* Logging */ + +/** + * Parameters for a `logging/setLevel` request. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequestParams extends RequestParams { + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + */ + level: LoggingLevel; +} + +/** + * A request from the client to the server, to enable or adjust logging. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends JSONRPCRequest { + method: "logging/setLevel"; + params: SetLevelRequestParams; +} + +/** + * Parameters for a `notifications/message` notification. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotificationParams extends NotificationParams { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; +} + +/** + * JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotification extends JSONRPCNotification { + method: "notifications/message"; + params: LoggingMessageNotificationParams; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @category Common Types + */ +export type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + +/* Sampling */ +/** + * Parameters for a `sampling/createMessage` request. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequestParams extends TaskAugmentedRequestParams { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client + * declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + */ + includeContext?: "none" | "thisServer" | "allServers"; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: object; + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + */ + tools?: Tool[]; + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice?: ToolChoice; +} + +/** + * Controls tool selection behavior for sampling requests. + * + * @category `sampling/createMessage` + */ +export interface ToolChoice { + /** + * Controls the tool use ability of the model: + * - "auto": Model decides whether to use tools (default) + * - "required": Model MUST use at least one tool before completing + * - "none": Model MUST NOT use any tools + */ + mode?: "auto" | "required" | "none"; +} + +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends JSONRPCRequest { + method: "sampling/createMessage"; + params: CreateMessageRequestParams; +} + +/** + * The client's response to a sampling/createMessage request from the server. + * The client should inform the user before returning the sampled message, to allow them + * to inspect the response (human in the loop) and decide whether to allow the server to see it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * - "toolUse": The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | "toolUse" | string; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @category `sampling/createMessage` + */ +export type SamplingMessageContentBlock = + | TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent; + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types + */ +export interface Annotations { + /** + * Describes who the intended audience of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + */ + lastModified?: string; +} + +/** + * @category Content + */ +export type ContentBlock = + TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; + +/** + * Text provided to or from an LLM. + * + * @category Content + */ +export interface TextContent { + type: "text"; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * An image provided to or from an LLM. + * + * @category Content + */ +export interface ImageContent { + type: "image"; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Audio provided to or from an LLM. + * + * @category Content + */ +export interface AudioContent { + type: "audio"; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A request from the assistant to call a tool. + * + * @category `sampling/createMessage` + */ +export interface ToolUseContent { + type: "tool_use"; + + /** + * A unique identifier for this tool use. + * + * This ID is used to match tool results to their corresponding tool uses. + */ + id: string; + + /** + * The name of the tool to call. + */ + name: string; + + /** + * The arguments to pass to the tool, conforming to the tool's input schema. + */ + input: { [key: string]: unknown }; + + /** + * Optional metadata about the tool use. Clients SHOULD preserve this field when + * including tool uses in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @category `sampling/createMessage` + */ +export interface ToolResultContent { + type: "tool_result"; + + /** + * The ID of the tool use this result corresponds to. + * + * This MUST match the ID from a previous ToolUseContent. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as CallToolResult.content and can include text, images, + * audio, resource links, and embedded resources. + */ + content: ContentBlock[]; + + /** + * An optional structured result object. + * + * If the tool defined an outputSchema, this SHOULD conform to that schema. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool use resulted in an error. + * + * If true, the content typically describes the error that occurred. + * Default: false + */ + isError?: boolean; + + /** + * Optional metadata about the tool result. Clients SHOULD preserve this field when + * including tool results in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + * + * @category `sampling/createMessage` + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + * + * @category `sampling/createMessage` + */ +export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; +} + +/* Autocomplete */ +/** + * Parameters for a `completion/complete` request. + * + * @category `completion/complete` + */ +export interface CompleteRequestParams extends RequestParams { + ref: PromptReference | ResourceTemplateReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + + /** + * Additional, optional context for completions + */ + context?: { + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * A request from the client to the server, to ask for completion options. + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: "completion/complete"; + params: CompleteRequestParams; +} + +/** + * The server's response to a completion/complete request + * + * @category `completion/complete` + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A reference to a resource or resource template definition. + * + * @category `completion/complete` + */ +export interface ResourceTemplateReference { + type: "ref/resource"; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + * + * @category `completion/complete` + */ +export interface PromptReference extends BaseMetadata { + type: "ref/prompt"; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + * + * @category `roots/list` + */ +export interface ListRootsRequest extends JSONRPCRequest { + method: "roots/list"; + params?: RequestParams; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @category `roots/list` + */ +export interface Root { + /** + * The URI identifying the root. This *must* start with file:// for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + * + * @category `notifications/roots/list_changed` + */ +export interface RootsListChangedNotification extends JSONRPCNotification { + method: "notifications/roots/list_changed"; + params?: NotificationParams; +} + +/** + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode?: "form"; + + /** + * The message to present to the user describing what information is being requested. + */ + message: string; + + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: { + $schema?: string; + type: "object"; + properties: { + [key: string]: PrimitiveSchemaDefinition; + }; + required?: string[]; + }; +} + +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode: "url"; + + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string; + + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: string; + + /** + * The URL that the user should navigate to. + * + * @format uri + */ + url: string; +} + +/** + * The parameters for a request to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export type ElicitRequestParams = + ElicitRequestFormParams | ElicitRequestURLParams; + +/** + * A request from the server to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequest extends JSONRPCRequest { + method: "elicitation/create"; + params: ElicitRequestParams; +} + +/** + * Restricted schema definitions that only allow primitive types + * without nested objects or arrays. + * + * @category `elicitation/create` + */ +export type PrimitiveSchemaDefinition = + StringSchema | NumberSchema | BooleanSchema | EnumSchema; + +/** + * @category `elicitation/create` + */ +export interface StringSchema { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; + default?: string; +} + +/** + * @category `elicitation/create` + */ +export interface NumberSchema { + type: "number" | "integer"; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; +} + +/** + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @category `elicitation/create` + */ +export interface UntitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum values to choose from. + */ + enum: string[]; + /** + * Optional default value. + */ + default?: string; +} + +/** + * Schema for single-selection enumeration with display titles for each option. + * + * @category `elicitation/create` + */ +export interface TitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum options with values and display labels. + */ + oneOf: Array<{ + /** + * The enum value. + */ + const: string; + /** + * Display label for this option. + */ + title: string; + }>; + /** + * Optional default value. + */ + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Combined single selection enumeration +export type SingleSelectEnumSchema = + UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema; + +/** + * Schema for multiple-selection enumeration without display titles for options. + * + * @category `elicitation/create` + */ +export interface UntitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for the array items. + */ + items: { + type: "string"; + /** + * Array of enum values to choose from. + */ + enum: string[]; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @category `elicitation/create` + */ +export interface TitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for array items with enum options and display labels. + */ + items: { + /** + * Array of enum options with values and display labels. + */ + anyOf: Array<{ + /** + * The constant enum value. + */ + const: string; + /** + * Display title for this option. + */ + title: string; + }>; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * @category `elicitation/create` + */ +// Combined multiple selection enumeration +export type MultiSelectEnumSchema = + UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema; + +/** + * Use TitledSingleSelectEnumSchema instead. + * This interface will be removed in a future version. + * + * @category `elicitation/create` + */ +export interface LegacyTitledEnumSchema { + type: "string"; + title?: string; + description?: string; + enum: string[]; + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + */ + enumNames?: string[]; + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Union type for all enum schemas +export type EnumSchema = + SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema; + +/** + * The client's response to an elicitation request. + * + * @category `elicitation/create` + */ +export interface ElicitResult extends Result { + /** + * The user action in response to the elicitation. + * - "accept": User submitted the form/confirmed the action + * - "decline": User explicitly decline the action + * - "cancel": User dismissed without making an explicit choice + */ + action: "accept" | "decline" | "cancel"; + + /** + * The submitted form data, only present when action is "accept" and mode was "form". + * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. + */ + content?: { [key: string]: string | number | boolean | string[] }; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotification extends JSONRPCNotification { + method: "notifications/elicitation/complete"; + params: { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; + }; +} + +/* Client messages */ +/** @internal */ +export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification + | TaskStatusNotification; + +/** @internal */ +export type ClientResult = + | EmptyResult + | CreateMessageResult + | ListRootsResult + | ElicitResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; + +/* Server messages */ +/** @internal */ +export type ServerRequest = + | PingRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitationCompleteNotification + | TaskStatusNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; diff --git a/src/spec-types/README.md b/src/spec-types/README.md new file mode 100644 index 0000000..f131874 --- /dev/null +++ b/src/spec-types/README.md @@ -0,0 +1,34 @@ +# spec-types + +Vendored copies of `schema/{version}/schema.ts` and +`schema/{version}/schema.json` from the +[modelcontextprotocol](https://github.com/modelcontextprotocol/modelcontextprotocol) +spec repository. + +The `.ts` files are the canonical TypeScript types for each protocol version. +The conformance suite imports types from here rather than from +`@modelcontextprotocol/sdk` so that it can test draft spec versions before any +SDK has implemented them. + +The `.schema.json` files are the matching JSON Schemas; `src/validation` +compiles them (per version) to validate every JSON-RPC message the harness +sends or receives at runtime. + +**Do not edit these files by hand.** To refresh: + +```sh +npm run sync-schema -- +``` + +The `SOURCE` file records the spec commit the current copies came from. + +## Import rule + +A scenario imports the schema matching its `source.introducedIn`: + +```ts +import type { ListToolsResult } from '../../spec-types/2025-06-18'; +``` + +`Connection` implementations import the version whose lifecycle they implement +(stateful → `2025-11-25`, stateless → `draft`). diff --git a/src/spec-types/SOURCE b/src/spec-types/SOURCE new file mode 100644 index 0000000..a4891c9 --- /dev/null +++ b/src/spec-types/SOURCE @@ -0,0 +1 @@ +modelcontextprotocol@71e306956a4959c9655e5036be215d41986596e6 diff --git a/src/spec-types/draft.schema.json b/src/spec-types/draft.schema.json new file mode 100644 index 0000000..cc44564 --- /dev/null +++ b/src/spec-types/draft.schema.json @@ -0,0 +1,3942 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "properties": { + "audience": { + "description": "Describes who the intended audience of this object or data is.\n\nIt can include multiple entries to indicate content useful for multiple audiences (e.g., `[\"user\", \"assistant\"]`).", + "items": { + "$ref": "#/$defs/Role" + }, + "type": "array" + }, + "lastModified": { + "description": "The moment the resource was last modified, as an ISO 8601 formatted string.\n\nShould be an ISO 8601 formatted string (e.g., \"2025-01-12T15:00:58Z\").\n\nExamples: last activity timestamp in an open file, timestamp when the resource\nwas attached, etc.", + "type": "string" + }, + "priority": { + "description": "Describes how important this data is for operating the server.\n\nA value of 1 means \"most important,\" and indicates that the data is\neffectively required, while 0 means \"least important,\" and indicates that\nthe data is entirely optional.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded audio data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the audio. Different providers may support different audio types.", + "type": "string" + }, + "type": { + "const": "audio", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "BaseMetadata": { + "description": "Base interface for metadata with name (identifier) and title (display name) properties.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "BlobResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "blob": { + "description": "A base64-encoded string representing the binary data of the item.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "blob", + "uri" + ], + "type": "object" + }, + "BooleanSchema": { + "properties": { + "default": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "CacheableResult": { + "description": "A result that supports a time-to-live (TTL) hint for client-side caching.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "CallToolRequest": { + "description": "Used by the client to invoke a tool provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/call", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CallToolRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CallToolRequestParams": { + "description": "Parameters for a `tools/call` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": {}, + "description": "Arguments to use for the tool call.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the tool.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The result returned by the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "content": { + "description": "A list of content objects that represent the unstructured result of the tool call.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool call ended in an error.\n\nIf not set, this is assumed to be false (the call was successful).\n\nAny errors that originate from the tool SHOULD be reported inside the result\nobject, with `isError` set to true, _not_ as an MCP protocol-level error\nresponse. Otherwise, the LLM would not be able to see that an error occurred\nand self-correct.\n\nHowever, any errors in _finding_ the tool, an error indicating that the\nserver does not support tool calls, or any other exceptional conditions,\nshould be reported as an MCP error response.", + "type": "boolean" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "structuredContent": { + "description": "An optional JSON value that represents the structured result of the tool call.\n\nThis can be any JSON value (object, array, string, number, boolean, or null)\nthat conforms to the tool's outputSchema if one is defined." + } + }, + "required": [ + "content", + "resultType" + ], + "type": "object" + }, + "CallToolResultResponse": { + "description": "A successful response from the server for a {@link CallToolRequesttools/call} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/CallToolResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "CancelledNotification": { + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CancelledNotificationParams": { + "description": "Parameters for a `notifications/cancelled` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "reason": { + "description": "An optional string describing the reason for the cancellation. This MAY be logged or presented to the user.", + "type": "string" + }, + "requestId": { + "$ref": "#/$defs/RequestId", + "description": "The ID of the request to cancel.\n\nThis MUST correspond to the ID of a request the client previously issued." + } + }, + "required": [ + "requestId" + ], + "type": "object" + }, + "ClientCapabilities": { + "description": "Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.", + "properties": { + "elicitation": { + "description": "Present if the client supports elicitation from the server.", + "properties": { + "form": { + "$ref": "#/$defs/JSONObject" + }, + "url": { + "$ref": "#/$defs/JSONObject" + } + }, + "type": "object" + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the client supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the client supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/oauth-client-credentials\"), and values are\nper-extension settings objects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "roots": { + "description": "Present if the client supports listing roots.", + "properties": {}, + "type": "object" + }, + "sampling": { + "description": "Present if the client supports sampling from an LLM.", + "properties": { + "context": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports context inclusion via `includeContext` parameter.\nIf not declared, servers SHOULD only use `includeContext: \"none\"` (or omit it)." + }, + "tools": { + "$ref": "#/$defs/JSONObject", + "description": "Whether the client supports tool use via `tools` and `toolChoice` parameters." + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ClientNotification": { + "description": "This notification is sent by the client to indicate that it is cancelling a request it previously issued.\n\nOn stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequestsubscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request.\n\nThe request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished.\n\nThis notification indicates that the result will be unused, so any associated processing SHOULD cease.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/cancelled", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CancelledNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ClientRequest": { + "anyOf": [ + { + "$ref": "#/$defs/DiscoverRequest" + }, + { + "$ref": "#/$defs/ListResourcesRequest" + }, + { + "$ref": "#/$defs/ListResourceTemplatesRequest" + }, + { + "$ref": "#/$defs/ReadResourceRequest" + }, + { + "$ref": "#/$defs/SubscriptionsListenRequest" + }, + { + "$ref": "#/$defs/ListPromptsRequest" + }, + { + "$ref": "#/$defs/GetPromptRequest" + }, + { + "$ref": "#/$defs/ListToolsRequest" + }, + { + "$ref": "#/$defs/CallToolRequest" + }, + { + "$ref": "#/$defs/CompleteRequest" + } + ] + }, + "ClientResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "CompleteRequest": { + "description": "A request from the client to the server, to ask for completion options.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "completion/complete", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CompleteRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "CompleteRequestParams": { + "description": "Parameters for a `completion/complete` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "argument": { + "description": "The argument's information", + "properties": { + "name": { + "description": "The name of the argument", + "type": "string" + }, + "value": { + "description": "The value of the argument to use for completion matching.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "context": { + "description": "Additional, optional context for completions", + "properties": { + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Previously-resolved variables in a URI template or prompt.", + "type": "object" + } + }, + "type": "object" + }, + "ref": { + "anyOf": [ + { + "$ref": "#/$defs/PromptReference" + }, + { + "$ref": "#/$defs/ResourceTemplateReference" + } + ] + } + }, + "required": [ + "_meta", + "argument", + "ref" + ], + "type": "object" + }, + "CompleteResult": { + "description": "The result returned by the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "completion": { + "properties": { + "hasMore": { + "description": "Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.", + "type": "boolean" + }, + "total": { + "description": "The total number of completion options available. This can exceed the number of values actually sent in the response.", + "type": "integer" + }, + "values": { + "description": "An array of completion values. Must not exceed 100 items.", + "items": { + "type": "string" + }, + "maxItems": 100, + "type": "array" + } + }, + "required": [ + "values" + ], + "type": "object" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "completion", + "resultType" + ], + "type": "object" + }, + "CompleteResultResponse": { + "description": "A successful response from the server for a {@link CompleteRequestcompletion/complete} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/CompleteResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ResourceLink" + }, + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + }, + "CreateMessageRequest": { + "description": "A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it.", + "properties": { + "method": { + "const": "sampling/createMessage", + "type": "string" + }, + "params": { + "$ref": "#/$defs/CreateMessageRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "CreateMessageRequestParams": { + "description": "Parameters for a `sampling/createMessage` request.", + "properties": { + "includeContext": { + "description": "A request to include context from one or more MCP servers (including the caller), to be attached to the prompt.\nThe client MAY ignore this request.\n\nDefault is `\"none\"`. The values `\"thisServer\"` and `\"allServers\"` are deprecated (SEP-2596): servers SHOULD\nomit this field or use `\"none\"`, and SHOULD only use the deprecated values if the client declares\n{@link ClientCapabilities.sampling.context}.", + "enum": [ + "allServers", + "none", + "thisServer" + ], + "type": "string" + }, + "maxTokens": { + "description": "The requested maximum number of tokens to sample (to prevent runaway completions).\n\nThe client MAY choose to sample fewer tokens than the requested maximum.", + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/SamplingMessage" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/JSONObject", + "description": "Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific." + }, + "modelPreferences": { + "$ref": "#/$defs/ModelPreferences", + "description": "The server's preferences for which model to select. The client MAY ignore these preferences." + }, + "stopSequences": { + "items": { + "type": "string" + }, + "type": "array" + }, + "systemPrompt": { + "description": "An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt.", + "type": "string" + }, + "temperature": { + "type": "number" + }, + "toolChoice": { + "$ref": "#/$defs/ToolChoice", + "description": "Controls how the model uses tools.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.\nDefault is `{ mode: \"auto\" }`." + }, + "tools": { + "description": "Tools that the model may use during generation.\nThe client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared.", + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + } + }, + "required": [ + "maxTokens", + "messages" + ], + "type": "object" + }, + "CreateMessageResult": { + "description": "The result returned by the client for a {@link CreateMessageRequestsampling/createMessage} request.\nThe client should inform the user before returning the sampled message, to allow them\nto inspect the response (human in the loop) and decide whether to allow the server to see it.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "model": { + "description": "The name of the model that generated the message.", + "type": "string" + }, + "role": { + "$ref": "#/$defs/Role" + }, + "stopReason": { + "description": "The reason why sampling stopped, if known.\n\nStandard values:\n- `\"endTurn\"`: Natural end of the assistant's turn\n- `\"stopSequence\"`: A stop sequence was encountered\n- `\"maxTokens\"`: Maximum token limit was reached\n- `\"toolUse\"`: The model wants to use one or more tools\n\nThis field is an open string to allow for provider-specific stop reasons.", + "type": "string" + } + }, + "required": [ + "content", + "model", + "role" + ], + "type": "object" + }, + "Cursor": { + "description": "An opaque token used to represent a cursor for pagination.", + "type": "string" + }, + "DiscoverRequest": { + "description": "A request from the client asking the server to advertise its supported\nprotocol versions, capabilities, and other metadata. Servers **MUST**\nimplement `server/discover`. Clients **MAY** call it but are not required\nto — version negotiation can also happen inline via per-request `_meta`.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "server/discover", + "type": "string" + }, + "params": { + "$ref": "#/$defs/RequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "DiscoverResult": { + "description": "The result returned by the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "capabilities": { + "$ref": "#/$defs/ServerCapabilities", + "description": "The capabilities of the server." + }, + "instructions": { + "description": "Natural-language guidance describing the server and its features.\n\nThis can be used by clients to improve an LLM's understanding of\navailable tools (e.g., by including it in a system prompt). It should\nfocus on information that helps the model use the server effectively\nand should not duplicate information already in tool descriptions.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "supportedVersions": { + "description": "MCP Protocol Versions this server supports. The client should choose a\nversion from this list for use in subsequent requests.", + "items": { + "type": "string" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "capabilities", + "resultType", + "supportedVersions", + "ttlMs" + ], + "type": "object" + }, + "DiscoverResultResponse": { + "description": "A successful response from the server for a {@link DiscoverRequestserver/discover} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/DiscoverResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ElicitRequest": { + "description": "A request from the server to elicit additional information from the user via the client.", + "properties": { + "method": { + "const": "elicitation/create", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ElicitRequestParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + "ElicitRequestFormParams": { + "description": "The parameters for a request to elicit non-sensitive information from the user via a form in the client.", + "properties": { + "message": { + "description": "The message to present to the user describing what information is being requested.", + "type": "string" + }, + "mode": { + "const": "form", + "description": "The elicitation mode.", + "type": "string" + }, + "requestedSchema": { + "description": "A restricted subset of JSON Schema.\nOnly top-level properties are allowed, without nesting.", + "properties": { + "$schema": { + "type": "string" + }, + "properties": { + "additionalProperties": { + "$ref": "#/$defs/PrimitiveSchemaDefinition" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "properties", + "type" + ], + "type": "object" + } + }, + "required": [ + "message", + "requestedSchema" + ], + "type": "object" + }, + "ElicitRequestParams": { + "anyOf": [ + { + "$ref": "#/$defs/ElicitRequestFormParams" + }, + { + "$ref": "#/$defs/ElicitRequestURLParams" + } + ], + "description": "The parameters for a request to elicit additional information from the user via the client." + }, + "ElicitRequestURLParams": { + "description": "The parameters for a request to elicit information from the user via a URL in the client.", + "properties": { + "message": { + "description": "The message to present to the user explaining why the interaction is needed.", + "type": "string" + }, + "mode": { + "const": "url", + "description": "The elicitation mode.", + "type": "string" + }, + "url": { + "description": "The URL that the user should navigate to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "message", + "mode", + "url" + ], + "type": "object" + }, + "ElicitResult": { + "description": "The result returned by the client for an {@link ElicitRequestelicitation/create} request.", + "properties": { + "action": { + "description": "The user action in response to the elicitation.\n- `\"accept\"`: User submitted the form/confirmed the action\n- `\"decline\"`: User explicitly declined the action\n- `\"cancel\"`: User dismissed without making an explicit choice", + "enum": [ + "accept", + "cancel", + "decline" + ], + "type": "string" + }, + "content": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "description": "The submitted form data, only present when action is `\"accept\"` and mode was `\"form\"`.\nContains values matching the requested schema.\nOmitted for out-of-band mode responses.", + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.\n\nIt is up to the client how best to render embedded resources for the benefit\nof the LLM and/or the user.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "resource": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "resource", + "type" + ], + "type": "object" + }, + "EmptyResult": { + "$ref": "#/$defs/Result", + "description": "Common result fields." + }, + "EnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ] + }, + "Error": { + "properties": { + "code": { + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "GetPromptRequest": { + "description": "Used by the client to get a prompt provided by the server.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/get", + "type": "string" + }, + "params": { + "$ref": "#/$defs/GetPromptRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "GetPromptRequestParams": { + "description": "Parameters for a `prompts/get` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "arguments": { + "additionalProperties": { + "type": "string" + }, + "description": "Arguments to use for templating the prompt.", + "type": "object" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "name": { + "description": "The name of the prompt or prompt template.", + "type": "string" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta", + "name" + ], + "type": "object" + }, + "GetPromptResult": { + "description": "The result returned by the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "description": { + "description": "An optional description for the prompt.", + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/$defs/PromptMessage" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "messages", + "resultType" + ], + "type": "object" + }, + "GetPromptResultResponse": { + "description": "A successful response from the server for a {@link GetPromptRequestprompts/get} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "HeaderMismatchError": { + "description": "Returned when a server rejects a request because the values in the HTTP\nheaders do not match the corresponding values in the request body, or\nbecause required headers are missing or malformed. For HTTP, the response\nstatus code MUST be `400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32020, + "type": "integer" + } + }, + "required": [ + "code" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": [ + "dark", + "light" + ], + "type": "string" + } + }, + "required": [ + "src" + ], + "type": "object" + }, + "Icons": { + "description": "Base interface to add `icons` property.", + "properties": { + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + } + }, + "type": "object" + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "data": { + "description": "The base64-encoded image data.", + "format": "byte", + "type": "string" + }, + "mimeType": { + "description": "The MIME type of the image. Different providers may support different image types.", + "type": "string" + }, + "type": { + "const": "image", + "type": "string" + } + }, + "required": [ + "data", + "mimeType", + "type" + ], + "type": "object" + }, + "Implementation": { + "description": "Describes the MCP implementation.", + "properties": { + "description": { + "description": "An optional human-readable description of what this implementation does.\n\nThis can be used by clients or servers to provide context about their purpose\nand capabilities. For example, a server might describe the types of resources\nor tools it provides, while a client might describe its intended use case.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "version": { + "description": "The version of this implementation.", + "type": "string" + }, + "websiteUrl": { + "description": "An optional URL of the website for this implementation.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "InputRequest": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageRequest" + }, + { + "$ref": "#/$defs/ListRootsRequest" + }, + { + "$ref": "#/$defs/ElicitRequest" + } + ] + }, + "InputRequests": { + "additionalProperties": { + "$ref": "#/$defs/InputRequest" + }, + "description": "A map of server-initiated requests that the client must fulfill.\nKeys are server-assigned identifiers; values are the request objects.", + "type": "object" + }, + "InputRequiredResult": { + "description": "An InputRequiredResult sent by the server to indicate that additional input is needed\nbefore the request can be completed.\n\nAt least one of `inputRequests` or `requestState` MUST be present.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "inputRequests": { + "$ref": "#/$defs/InputRequests" + }, + "requestState": { + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "InputResponse": { + "anyOf": [ + { + "$ref": "#/$defs/CreateMessageResult" + }, + { + "$ref": "#/$defs/ListRootsResult" + }, + { + "$ref": "#/$defs/ElicitResult" + } + ] + }, + "InputResponseRequestParams": { + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "InputResponses": { + "additionalProperties": { + "$ref": "#/$defs/InputResponse" + }, + "description": "A map of client responses to server-initiated requests.\nKeys correspond to the keys in the {@link InputRequests} map;\nvalues are the client's result for each request.", + "type": "object" + }, + "InternalError": { + "description": "A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request.", + "properties": { + "code": { + "const": -32603, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidParamsError": { + "description": "A JSON-RPC error indicating that the method parameters are invalid or malformed.\n\nIn MCP, this error is returned in various contexts when request parameters fail validation:\n\n- **Tools**: Unknown tool name or invalid tool arguments\n- **Prompts**: Unknown prompt name or missing required arguments\n- **Pagination**: Invalid or expired cursor values\n- **Logging**: Invalid log level\n- **Elicitation**: Server requests an elicitation mode not declared in client capabilities\n- **Sampling**: Missing tool result or tool results mixed with other content", + "properties": { + "code": { + "const": -32602, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "InvalidRequestError": { + "description": "A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields).", + "properties": { + "code": { + "const": -32600, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "JSONArray": { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + "JSONObject": { + "additionalProperties": { + "$ref": "#/$defs/JSONValue" + }, + "type": "object" + }, + "JSONRPCErrorResponse": { + "description": "A response to a request that indicates an error occurred.", + "properties": { + "error": { + "$ref": "#/$defs/Error" + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "JSONRPCMessage": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCRequest" + }, + { + "$ref": "#/$defs/JSONRPCNotification" + }, + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent." + }, + "JSONRPCNotification": { + "description": "A notification which does not expect a response.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCRequest": { + "description": "A request that expects a response.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "id", + "jsonrpc", + "method" + ], + "type": "object" + }, + "JSONRPCResponse": { + "anyOf": [ + { + "$ref": "#/$defs/JSONRPCResultResponse" + }, + { + "$ref": "#/$defs/JSONRPCErrorResponse" + } + ], + "description": "A response to a request, containing either the result or error." + }, + "JSONRPCResultResponse": { + "description": "A successful (non-error) response to a request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "JSONValue": { + "anyOf": [ + { + "$ref": "#/$defs/JSONObject" + }, + { + "items": { + "$ref": "#/$defs/JSONValue" + }, + "type": "array" + }, + { + "type": [ + "string", + "integer", + "boolean" + ] + } + ] + }, + "LegacyTitledEnumSchema": { + "description": "Use {@link TitledSingleSelectEnumSchema} instead.\nThis interface will be removed in a future version.", + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "description": "(Legacy) Display names for enum values.\nNon-standard according to JSON schema 2020-12.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "ListPromptsRequest": { + "description": "Sent from the client to request a list of prompts and prompt templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "prompts/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListPromptsResult": { + "description": "The result returned by the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "prompts": { + "items": { + "$ref": "#/$defs/Prompt" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "prompts", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListPromptsResultResponse": { + "description": "A successful response from the server for a {@link ListPromptsRequestprompts/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListPromptsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourceTemplatesRequest": { + "description": "Sent from the client to request a list of resource templates the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/templates/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourceTemplatesResult": { + "description": "The result returned by the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/$defs/ResourceTemplate" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resourceTemplates", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourceTemplatesResultResponse": { + "description": "A successful response from the server for a {@link ListResourceTemplatesRequestresources/templates/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourceTemplatesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListResourcesRequest": { + "description": "Sent from the client to request a list of resources the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListResourcesResult": { + "description": "The result returned by the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resources": { + "items": { + "$ref": "#/$defs/Resource" + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resources", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ListResourcesResultResponse": { + "description": "A successful response from the server for a {@link ListResourcesRequestresources/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListResourcesResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "ListRootsRequest": { + "description": "Sent from the server to request a list of root URIs from the client. Roots allow\nservers to ask for specific directories or files to operate on. A common example\nfor roots is providing a set of repositories or directories a server should operate\non.\n\nThis request is typically used when the server needs to understand the file system\nstructure or access specific locations that the client has permission to read from.", + "properties": { + "method": { + "const": "roots/list", + "type": "string" + }, + "params": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + } + }, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "ListRootsResult": { + "description": "The result returned by the client for a {@link ListRootsRequestroots/list} request.\nThis result contains an array of {@link Root} objects, each representing a root directory\nor file that the server can operate on.", + "properties": { + "roots": { + "items": { + "$ref": "#/$defs/Root" + }, + "type": "array" + } + }, + "required": [ + "roots" + ], + "type": "object" + }, + "ListToolsRequest": { + "description": "Sent from the client to request a list of tools the server has.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "tools/list", + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ListToolsResult": { + "description": "The result returned by the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "resultType", + "tools", + "ttlMs" + ], + "type": "object" + }, + "ListToolsResultResponse": { + "description": "A successful response from the server for a {@link ListToolsRequesttools/list} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/ListToolsResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "LoggingLevel": { + "description": "The severity of a log message.\n\nThese map to syslog message severities, as specified in RFC-5424:\nhttps://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1", + "enum": [ + "alert", + "critical", + "debug", + "emergency", + "error", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "LoggingMessageNotification": { + "description": "JSONRPCNotification of a log message passed from server to client. The client opts in by setting `\"io.modelcontextprotocol/logLevel\"` in a request's `_meta`.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/message", + "type": "string" + }, + "params": { + "$ref": "#/$defs/LoggingMessageNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "LoggingMessageNotificationParams": { + "description": "Parameters for a `notifications/message` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "data": { + "description": "The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here." + }, + "level": { + "$ref": "#/$defs/LoggingLevel", + "description": "The severity of this log message." + }, + "logger": { + "description": "An optional name of the logger issuing this message.", + "type": "string" + } + }, + "required": [ + "data", + "level" + ], + "type": "object" + }, + "MetaObject": { + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`).\n- Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "MethodNotFoundError": { + "description": "A JSON-RPC error indicating that the requested method does not exist or is not available.\n\nIn MCP, a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised).\n\nA request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32021`).", + "properties": { + "code": { + "const": -32601, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "MissingRequiredClientCapabilityError": { + "description": "Returned when processing a request requires a capability the client did not\ndeclare in `clientCapabilities`. For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32021, + "type": "integer" + }, + "data": { + "properties": { + "requiredCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The capabilities the server requires from the client to process this request." + } + }, + "required": [ + "requiredCapabilities" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "ModelHint": { + "description": "Hints to use for model selection.\n\nKeys not declared here are currently left unspecified by the spec and are up\nto the client to interpret.", + "properties": { + "name": { + "description": "A hint for a model name.\n\nThe client SHOULD treat this as a substring of a model name; for example:\n - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022`\n - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc.\n - `claude` should match any Claude model\n\nThe client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example:\n - `gemini-1.5-flash` could match `claude-3-haiku-20240307`", + "type": "string" + } + }, + "type": "object" + }, + "ModelPreferences": { + "description": "The server's preferences for model selection, requested of the client during sampling.\n\nBecause LLMs can vary along multiple dimensions, choosing the \"best\" model is\nrarely straightforward. Different models excel in different areas—some are\nfaster but less capable, others are more capable but more expensive, and so\non. This interface allows servers to express their priorities across multiple\ndimensions to help clients make an appropriate selection for their use case.\n\nThese preferences are always advisory. The client MAY ignore them. It is also\nup to the client to decide how to interpret these preferences and how to\nbalance them against other considerations.", + "properties": { + "costPriority": { + "description": "How much to prioritize cost when selecting a model. A value of 0 means cost\nis not important, while a value of 1 means cost is the most important\nfactor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "hints": { + "description": "Optional hints to use for model selection.\n\nIf multiple hints are specified, the client MUST evaluate them in order\n(such that the first match is taken).\n\nThe client SHOULD prioritize these hints over the numeric priorities, but\nMAY still use the priorities to select from ambiguous matches.", + "items": { + "$ref": "#/$defs/ModelHint" + }, + "type": "array" + }, + "intelligencePriority": { + "description": "How much to prioritize intelligence and capabilities when selecting a\nmodel. A value of 0 means intelligence is not important, while a value of 1\nmeans intelligence is the most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "speedPriority": { + "description": "How much to prioritize sampling speed (latency) when selecting a model. A\nvalue of 0 means speed is not important, while a value of 1 means speed is\nthe most important factor.", + "maximum": 1, + "minimum": 0, + "type": "number" + } + }, + "type": "object" + }, + "MultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + } + ] + }, + "Notification": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "NotificationMetaObject": { + "description": "Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream a notification was delivered on. The\nserver MUST include this key on every notification delivered via a\n{@link SubscriptionsListenRequestsubscriptions/listen} stream, so the\nclient can correlate the notification with the originating subscription.\nThe key is absent on notifications not delivered via a subscription\nstream (e.g. progress notifications for an in-flight request), which is\nwhy it is optional here.\n\nThe value is the JSON-RPC ID of the `subscriptions/listen` request that\nopened the stream." + } + }, + "type": "object" + }, + "NotificationParams": { + "description": "Common params for any notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + } + }, + "type": "object" + }, + "NumberSchema": { + "properties": { + "default": { + "type": "number" + }, + "description": { + "type": "string" + }, + "maximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "title": { + "type": "string" + }, + "type": { + "enum": [ + "integer", + "number" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "PaginatedRequest": { + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "$ref": "#/$defs/PaginatedRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "PaginatedRequestParams": { + "description": "Common params for paginated requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "cursor": { + "description": "An opaque token representing the current pagination position.\nIf provided, the server should return results starting after this cursor.", + "type": "string" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "PaginatedResult": { + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "nextCursor": { + "description": "An opaque token representing the pagination position after the last returned result.\nIf present, there may be more results available.", + "type": "string" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ParseError": { + "description": "A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message.", + "properties": { + "code": { + "const": -32700, + "description": "The error type that occurred.", + "type": "integer" + }, + "data": { + "description": "Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.)." + }, + "message": { + "description": "A short description of the error. The message SHOULD be limited to a concise single sentence.", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "PrimitiveSchemaDefinition": { + "anyOf": [ + { + "$ref": "#/$defs/StringSchema" + }, + { + "$ref": "#/$defs/NumberSchema" + }, + { + "$ref": "#/$defs/BooleanSchema" + }, + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/UntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledMultiSelectEnumSchema" + }, + { + "$ref": "#/$defs/LegacyTitledEnumSchema" + } + ], + "description": "Restricted schema definitions that only allow primitive types\nwithout nested objects or arrays." + }, + "ProgressNotification": { + "description": "An out-of-band notification used to inform the receiver of a progress update for a long-running request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/progress", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ProgressNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ProgressNotificationParams": { + "description": "Parameters for a {@link ProgressNotificationnotifications/progress} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "message": { + "description": "An optional message describing the current progress.", + "type": "string" + }, + "progress": { + "description": "The progress thus far. This should increase every time progress is made, even if the total is unknown.", + "type": "number" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "The progress token which was given in the initial request, used to associate this notification with the request that is proceeding." + }, + "total": { + "description": "Total number of items to process (or total progress required), if known.", + "type": "number" + } + }, + "required": [ + "progress", + "progressToken" + ], + "type": "object" + }, + "ProgressToken": { + "description": "A progress token, used to associate progress notifications with the original request.", + "type": [ + "string", + "integer" + ] + }, + "Prompt": { + "description": "A prompt or prompt template that the server offers.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "arguments": { + "description": "A list of arguments to use for templating the prompt.", + "items": { + "$ref": "#/$defs/PromptArgument" + }, + "type": "array" + }, + "description": { + "description": "An optional description of what this prompt provides", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptArgument": { + "description": "Describes an argument that a prompt can accept.", + "properties": { + "description": { + "description": "A human-readable description of the argument.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "required": { + "description": "Whether this argument must be provided.", + "type": "boolean" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "PromptListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/prompts/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "PromptMessage": { + "description": "Describes a message returned as part of a prompt.\n\nThis is similar to {@link SamplingMessage}, but also supports the embedding of\nresources from the MCP server.", + "properties": { + "content": { + "$ref": "#/$defs/ContentBlock" + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "PromptReference": { + "description": "Identifies a prompt.", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "ref/prompt", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ReadResourceRequest": { + "description": "Sent from the client to the server, to read a specific resource URI.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "resources/read", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ReadResourceRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ReadResourceRequestParams": { + "description": "Parameters for a `resources/read` request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "inputResponses": { + "$ref": "#/$defs/InputResponses" + }, + "requestState": { + "type": "string" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ReadResourceResult": { + "description": "The result returned by the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "cacheScope": { + "description": "Indicates the intended scope of the cached response, analogous to HTTP\n`Cache-Control: public` vs `Cache-Control: private`.\n\n- `\"public\"`: The response does not contain user-specific data. Any\n client or intermediary (e.g., shared gateway, caching proxy) MAY cache\n the response and serve it across authorization contexts.\n- `\"private\"`: The response MAY be cached and reused only within the\n same authorization context. Caches MUST NOT be shared across\n authorization contexts (e.g., a different access token requires a\n different cache).", + "enum": [ + "private", + "public" + ], + "type": "string" + }, + "contents": { + "items": { + "anyOf": [ + { + "$ref": "#/$defs/TextResourceContents" + }, + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + }, + "type": "array" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + }, + "ttlMs": { + "description": "A hint from the server indicating how long (in milliseconds) the\nclient MAY cache this response before re-fetching. Semantics are\nanalogous to HTTP Cache-Control max-age.\n\n- If 0, The response SHOULD be considered immediately stale,\n The client MAY re-fetch every time the result is needed.\n- If positive, the client SHOULD consider the result fresh for this many\n milliseconds after receiving the response.", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cacheScope", + "contents", + "resultType", + "ttlMs" + ], + "type": "object" + }, + "ReadResourceResultResponse": { + "description": "A successful response from the server for a {@link ReadResourceRequestresources/read} request.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + } + ] + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, + "Request": { + "properties": { + "method": { + "type": "string" + }, + "params": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "RequestId": { + "description": "A uniquely identifying ID for a request in JSON-RPC.", + "type": [ + "string", + "integer" + ] + }, + "RequestMetaObject": { + "description": "Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/clientCapabilities": { + "$ref": "#/$defs/ClientCapabilities", + "description": "The client's capabilities for this specific request. Required.\n\nCapabilities are declared per-request rather than once at initialization;\nan empty object means the client supports no optional capabilities.\nServers MUST NOT infer capabilities from prior requests." + }, + "io.modelcontextprotocol/clientInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the client software making the request. Clients SHOULD\ninclude this field on every request unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the client and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Servers\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + }, + "io.modelcontextprotocol/logLevel": { + "$ref": "#/$defs/LoggingLevel", + "description": "The desired log level for this request. Optional.\n\nIf absent, the server MUST NOT send any {@link LoggingMessageNotificationnotifications/message}\nnotifications for this request. The client opts in to log messages by\nexplicitly setting a level. Replaces the former `logging/setLevel` RPC." + }, + "io.modelcontextprotocol/protocolVersion": { + "description": "The MCP Protocol Version being used for this request. Required.\n\nFor the HTTP transport, this value MUST match the `MCP-Protocol-Version`\nheader; otherwise the server MUST return a `400 Bad Request`. If the\nserver does not support the requested version, it MUST return an\n{@link UnsupportedProtocolVersionError}.", + "type": "string" + }, + "progressToken": { + "$ref": "#/$defs/ProgressToken", + "description": "If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotificationnotifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications." + } + }, + "required": [ + "io.modelcontextprotocol/clientCapabilities", + "io.modelcontextprotocol/protocolVersion" + ], + "type": "object" + }, + "RequestParams": { + "description": "Common params for any request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + } + }, + "required": [ + "_meta" + ], + "type": "object" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceContents": { + "description": "The contents of a specific resource or sub-resource.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.\n\nNote: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequestresources/list} requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this resource represents.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "size": { + "description": "The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.\n\nThis can be used by Hosts to display file sizes and estimate context window usage.", + "type": "integer" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "name", + "type", + "uri" + ], + "type": "object" + }, + "ResourceListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ResourceRequestParams": { + "description": "Common params for resource-related requests.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "uri": { + "description": "The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "_meta", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "description": { + "description": "A description of what this template is for.\n\nThis can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "mimeType": { + "description": "The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type.", + "type": "string" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + }, + "uriTemplate": { + "description": "A URI template (according to RFC 6570) that can be used to construct resource URIs.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResourceTemplateReference": { + "description": "A reference to a resource or resource template definition.", + "properties": { + "type": { + "const": "ref/resource", + "type": "string" + }, + "uri": { + "description": "The URI or URI template of the resource.", + "format": "uri-template", + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "type": "object" + }, + "ResourceUpdatedNotification": { + "description": "A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/resources/updated", + "type": "string" + }, + "params": { + "$ref": "#/$defs/ResourceUpdatedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "ResourceUpdatedNotificationParams": { + "description": "Parameters for a `notifications/resources/updated` notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "uri": { + "description": "The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "Result": { + "additionalProperties": {}, + "description": "Common result fields.", + "properties": { + "_meta": { + "$ref": "#/$defs/ResultMetaObject" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "resultType" + ], + "type": "object" + }, + "ResultMetaObject": { + "description": "Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + } + }, + "type": "object" + }, + "ResultType": { + "description": "Indicates the type of a {@link Result} object, allowing the client to\ndetermine how to parse the response.\n\ncomplete - the request completed successfully and the result contains the final content.\ninput_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request.", + "type": "string" + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "enum": [ + "assistant", + "user" + ], + "type": "string" + }, + "Root": { + "description": "Represents a root directory or file that the server can operate on.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "name": { + "description": "An optional name for the root. This can be used to provide a human-readable\nidentifier for the root, which may be useful for display purposes or for\nreferencing the root in other parts of the application.", + "type": "string" + }, + "uri": { + "description": "The URI identifying the root. This *must* start with `file://` for now.\nThis restriction may be relaxed in future versions of the protocol to allow\nother URI schemes.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "SamplingMessage": { + "description": "Describes a message issued to or received from an LLM API.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "content": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + }, + { + "items": { + "$ref": "#/$defs/SamplingMessageContentBlock" + }, + "type": "array" + } + ] + }, + "role": { + "$ref": "#/$defs/Role" + } + }, + "required": [ + "content", + "role" + ], + "type": "object" + }, + "SamplingMessageContentBlock": { + "anyOf": [ + { + "$ref": "#/$defs/TextContent" + }, + { + "$ref": "#/$defs/ImageContent" + }, + { + "$ref": "#/$defs/AudioContent" + }, + { + "$ref": "#/$defs/ToolUseContent" + }, + { + "$ref": "#/$defs/ToolResultContent" + } + ] + }, + "ServerCapabilities": { + "description": "Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities.", + "properties": { + "completions": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports argument autocompletion suggestions." + }, + "experimental": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Experimental, non-standard capabilities that the server supports.", + "type": "object" + }, + "extensions": { + "additionalProperties": { + "$ref": "#/$defs/JSONObject" + }, + "description": "Optional MCP extensions that the server supports. Keys are extension identifiers\n(e.g., \"io.modelcontextprotocol/tasks\"), and values are per-extension settings\nobjects. An empty object indicates support with no settings.\n\nKeys MUST follow the {@link MetaObject`_meta` key naming rules}, with a\nmandatory prefix.", + "type": "object" + }, + "logging": { + "$ref": "#/$defs/JSONObject", + "description": "Present if the server supports sending log messages to the client." + }, + "prompts": { + "description": "Present if the server offers any prompt templates.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the prompt list.", + "type": "boolean" + } + }, + "type": "object" + }, + "resources": { + "description": "Present if the server offers any resources to read.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the resource list.", + "type": "boolean" + }, + "subscribe": { + "description": "Whether this server supports subscribing to resource updates.", + "type": "boolean" + } + }, + "type": "object" + }, + "tools": { + "description": "Present if the server offers any tools to call.", + "properties": { + "listChanged": { + "description": "Whether this server supports notifications for changes to the tool list.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ServerNotification": { + "anyOf": [ + { + "$ref": "#/$defs/CancelledNotification" + }, + { + "$ref": "#/$defs/ProgressNotification" + }, + { + "$ref": "#/$defs/ResourceListChangedNotification" + }, + { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotification" + }, + { + "$ref": "#/$defs/ResourceUpdatedNotification" + }, + { + "$ref": "#/$defs/PromptListChangedNotification" + }, + { + "$ref": "#/$defs/ToolListChangedNotification" + }, + { + "$ref": "#/$defs/LoggingMessageNotification" + } + ] + }, + "ServerResult": { + "anyOf": [ + { + "$ref": "#/$defs/Result" + }, + { + "$ref": "#/$defs/InputRequiredResult" + }, + { + "$ref": "#/$defs/DiscoverResult" + }, + { + "$ref": "#/$defs/ListResourcesResult" + }, + { + "$ref": "#/$defs/ListResourceTemplatesResult" + }, + { + "$ref": "#/$defs/ReadResourceResult" + }, + { + "$ref": "#/$defs/SubscriptionsListenResult" + }, + { + "$ref": "#/$defs/ListPromptsResult" + }, + { + "$ref": "#/$defs/GetPromptResult" + }, + { + "$ref": "#/$defs/ListToolsResult" + }, + { + "$ref": "#/$defs/CallToolResult" + }, + { + "$ref": "#/$defs/CompleteResult" + } + ] + }, + "SingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/$defs/UntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/$defs/TitledSingleSelectEnumSchema" + } + ] + }, + "StringSchema": { + "properties": { + "default": { + "type": "string" + }, + "description": { + "type": "string" + }, + "format": { + "enum": [ + "date", + "date-time", + "email", + "uri" + ], + "type": "string" + }, + "maxLength": { + "type": "integer" + }, + "minLength": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "SubscriptionFilter": { + "description": "The set of notification types a client may opt in to on a\n{@link SubscriptionsListenRequestsubscriptions/listen} request.\n\nEach notification type is **opt-in**; the server **MUST NOT** send\nnotification types the client has not explicitly requested here.", + "properties": { + "promptsListChanged": { + "description": "If true, receive {@link PromptListChangedNotificationnotifications/prompts/list_changed}.", + "type": "boolean" + }, + "resourceSubscriptions": { + "description": "Subscribe to {@link ResourceUpdatedNotificationnotifications/resources/updated} for these resource URIs.\nReplaces the former `resources/subscribe` RPC.", + "items": { + "type": "string" + }, + "type": "array" + }, + "resourcesListChanged": { + "description": "If true, receive {@link ResourceListChangedNotificationnotifications/resources/list_changed}.", + "type": "boolean" + }, + "toolsListChanged": { + "description": "If true, receive {@link ToolListChangedNotificationnotifications/tools/list_changed}.", + "type": "boolean" + } + }, + "type": "object" + }, + "SubscriptionsAcknowledgedNotification": { + "description": "Sent by the server to acknowledge that a\n{@link SubscriptionsListenRequestsubscriptions/listen} subscription has been\nestablished and to report which notification types it agreed to honor.\n\nThis notification MUST be the first message the server sends carrying the\nsubscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST\nNOT send any notification on the subscription before acknowledging it. On\nstdio, where every subscription shares one channel, this ordering is defined\nper subscription ID and not per channel: messages belonging to other\nsubscriptions MAY be interleaved before it.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/subscriptions/acknowledged", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsAcknowledgedNotificationParams" + } + }, + "required": [ + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsAcknowledgedNotificationParams": { + "description": "Parameters for a {@link SubscriptionsAcknowledgedNotificationnotifications/subscriptions/acknowledged} notification.", + "properties": { + "_meta": { + "$ref": "#/$defs/NotificationMetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The subset of requested notification types the server agreed to honor.\nOnly includes notification types the server actually supports; if the\nclient requested an unsupported type (e.g., `promptsListChanged` when\nthe server has no prompts), it is omitted from this set." + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "SubscriptionsListenRequest": { + "description": "Sent from the client to open a long-lived channel for receiving notifications\noutside the context of a specific request. Replaces the previous HTTP GET\nendpoint and ensures consistent behavior between HTTP and STDIO.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "subscriptions/listen", + "type": "string" + }, + "params": { + "$ref": "#/$defs/SubscriptionsListenRequestParams" + } + }, + "required": [ + "id", + "jsonrpc", + "method", + "params" + ], + "type": "object" + }, + "SubscriptionsListenRequestParams": { + "description": "Parameters for a {@link SubscriptionsListenRequestsubscriptions/listen} request.", + "properties": { + "_meta": { + "$ref": "#/$defs/RequestMetaObject" + }, + "notifications": { + "$ref": "#/$defs/SubscriptionFilter", + "description": "The notifications the client opts in to on this stream. The server\n**MUST NOT** send notification types the client has not explicitly\nrequested." + } + }, + "required": [ + "_meta", + "notifications" + ], + "type": "object" + }, + "SubscriptionsListenResult": { + "description": "The response to a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, signalling that the subscription has ended gracefully (for example,\nduring server shutdown). Because the listen stream is long-lived, this result\nis sent only when the server tears the subscription down; an abrupt transport\nclose carries no response. The result body is otherwise empty.", + "properties": { + "_meta": { + "$ref": "#/$defs/SubscriptionsListenResultMeta" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "_meta", + "resultType" + ], + "type": "object" + }, + "SubscriptionsListenResultMeta": { + "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/serverInfo": { + "$ref": "#/$defs/Implementation", + "description": "Identifies the server software producing the response. Servers SHOULD\ninclude this field on every response unless specifically configured not\nto do so.\n\nThe {@link Implementation} schema requires `name` and `version`; other\nfields are optional.\n\nThe value is self-reported by the server and is not verified by the\nprotocol. It is intended for display, logging, and debugging. Clients\nSHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for\nsecurity decisions." + }, + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." + } + }, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ], + "type": "object" + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/Annotations", + "description": "Optional annotations for the client." + }, + "text": { + "description": "The text content of the message.", + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "type": "object" + }, + "TextResourceContents": { + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "mimeType": { + "description": "The MIME type of this resource, if known.", + "type": "string" + }, + "text": { + "description": "The text of the item. This must only be set if the item can actually be represented as text (not binary data).", + "type": "string" + }, + "uri": { + "description": "The URI of this resource.", + "format": "uri", + "type": "string" + } + }, + "required": [ + "text", + "uri" + ], + "type": "object" + }, + "TitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for array items with enum options and display labels.", + "properties": { + "anyOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The constant enum value.", + "type": "string" + }, + "title": { + "description": "Display title for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "anyOf" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "TitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration with display titles for each option.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "oneOf": { + "description": "Array of enum options with values and display labels.", + "items": { + "properties": { + "const": { + "description": "The enum value.", + "type": "string" + }, + "title": { + "description": "Display label for this option.", + "type": "string" + } + }, + "required": [ + "const", + "title" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "oneOf", + "type" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject" + }, + "annotations": { + "$ref": "#/$defs/ToolAnnotations", + "description": "Optional additional tool information.\n\nDisplay name precedence order is: `title`, `annotations.title`, then `name`." + }, + "description": { + "description": "A human-readable description of the tool.\n\nThis can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a \"hint\" to the model.", + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- `image/png` - PNG images (safe, universal compatibility)\n- `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- `image/svg+xml` - SVG images (scalable but requires security precautions)\n- `image/webp` - WebP images (modern, efficient format)", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "inputSchema": { + "additionalProperties": {}, + "description": "A JSON Schema object defining the expected parameters for the tool.\n\nTool arguments are always JSON objects, so `type: \"object\"` is required at the root.\nBeyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including\ncomposition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords\n(`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other\nstandard validation or annotation keywords.\n\nProperty schemas may carry an `x-mcp-header` annotation to mirror the\nargument value into an HTTP header on the Streamable HTTP transport. See\nthe Streamable HTTP transport specification for the validity and\nextraction rules.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + }, + "type": { + "const": "object", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "name": { + "description": "Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present).", + "type": "string" + }, + "outputSchema": { + "additionalProperties": {}, + "description": "An optional JSON Schema object defining the structure of the tool's output returned in\nthe structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12.\n\nDefaults to JSON Schema 2020-12 when no explicit `$schema` is provided.", + "properties": { + "$schema": { + "type": "string" + } + }, + "type": "object" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable and easily understood,\neven by those unfamiliar with domain-specific terminology.\n\nIf not provided, the name should be used for display (except for {@link Tool},\nwhere `annotations.title` should be given precedence over using `name`,\nif present).", + "type": "string" + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolAnnotations": { + "description": "Additional properties describing a {@link Tool} to clients.\n\nNOTE: all properties in `ToolAnnotations` are **hints**.\nThey are not guaranteed to provide a faithful description of\ntool behavior (including descriptive properties like `title`).\n\nClients should never make tool use decisions based on `ToolAnnotations`\nreceived from untrusted servers.", + "properties": { + "destructiveHint": { + "description": "If true, the tool may perform destructive updates to its environment.\nIf false, the tool performs only additive updates.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: true", + "type": "boolean" + }, + "idempotentHint": { + "description": "If true, calling the tool repeatedly with the same arguments\nwill have no additional effect on its environment.\n\n(This property is meaningful only when `readOnlyHint == false`)\n\nDefault: false", + "type": "boolean" + }, + "openWorldHint": { + "description": "If true, this tool may interact with an \"open world\" of external\nentities. If false, the tool's domain of interaction is closed.\nFor example, the world of a web search tool is open, whereas that\nof a memory tool is not.\n\nDefault: true", + "type": "boolean" + }, + "readOnlyHint": { + "description": "If true, the tool does not modify its environment.\n\nDefault: false", + "type": "boolean" + }, + "title": { + "description": "A human-readable title for the tool.", + "type": "string" + } + }, + "type": "object" + }, + "ToolChoice": { + "description": "Controls tool selection behavior for sampling requests.", + "properties": { + "mode": { + "description": "Controls the tool use ability of the model:\n- `\"auto\"`: Model decides whether to use tools (default)\n- `\"required\"`: Model MUST use at least one tool before completing\n- `\"none\"`: Model MUST NOT use any tools", + "enum": [ + "auto", + "none", + "required" + ], + "type": "string" + } + }, + "type": "object" + }, + "ToolListChangedNotification": { + "description": "An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequestsubscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field.", + "properties": { + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "method": { + "const": "notifications/tools/list_changed", + "type": "string" + }, + "params": { + "$ref": "#/$defs/NotificationParams" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "type": "object" + }, + "ToolResultContent": { + "description": "The result of a tool use, provided by the user back to the assistant.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool result. Clients SHOULD preserve this field when\nincluding tool results in subsequent sampling requests to enable caching optimizations." + }, + "content": { + "description": "The unstructured result content of the tool use.\n\nThis has the same format as {@link CallToolResult.content} and can include text, images,\naudio, resource links, and embedded resources.", + "items": { + "$ref": "#/$defs/ContentBlock" + }, + "type": "array" + }, + "isError": { + "description": "Whether the tool use resulted in an error.\n\nIf true, the content typically describes the error that occurred.\nDefault: false", + "type": "boolean" + }, + "structuredContent": { + "description": "An optional structured result value.\n\nThis can be any JSON value (object, array, string, number, boolean, or null).\nIf the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema." + }, + "toolUseId": { + "description": "The ID of the tool use this result corresponds to.\n\nThis MUST match the ID from a previous {@link ToolUseContent}.", + "type": "string" + }, + "type": { + "const": "tool_result", + "type": "string" + } + }, + "required": [ + "content", + "toolUseId", + "type" + ], + "type": "object" + }, + "ToolUseContent": { + "description": "A request from the assistant to call a tool.", + "properties": { + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Optional metadata about the tool use. Clients SHOULD preserve this field when\nincluding tool uses in subsequent sampling requests to enable caching optimizations." + }, + "id": { + "description": "A unique identifier for this tool use.\n\nThis ID is used to match tool results to their corresponding tool uses.", + "type": "string" + }, + "input": { + "additionalProperties": {}, + "description": "The arguments to pass to the tool, conforming to the tool's input schema.", + "type": "object" + }, + "name": { + "description": "The name of the tool to call.", + "type": "string" + }, + "type": { + "const": "tool_use", + "type": "string" + } + }, + "required": [ + "id", + "input", + "name", + "type" + ], + "type": "object" + }, + "UnsupportedProtocolVersionError": { + "description": "Returned when the request's protocol version is unknown to the server or\nunsupported (e.g., a known experimental or draft version the server has\nchosen not to implement). For HTTP, the response status code MUST be\n`400 Bad Request`.", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/$defs/Error" + }, + { + "properties": { + "code": { + "const": -32022, + "type": "integer" + }, + "data": { + "properties": { + "requested": { + "description": "The protocol version that was requested by the client.", + "type": "string" + }, + "supported": { + "description": "Protocol versions the server supports. The client should choose a\nmutually supported version from this list and retry.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "requested", + "supported" + ], + "type": "object" + } + }, + "required": [ + "code", + "data" + ], + "type": "object" + } + ] + }, + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + } + }, + "required": [ + "error", + "jsonrpc" + ], + "type": "object" + }, + "UntitledMultiSelectEnumSchema": { + "description": "Schema for multiple-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "items": { + "description": "Schema for the array items.", + "properties": { + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + }, + "maxItems": { + "description": "Maximum number of items to select.", + "type": "integer" + }, + "minItems": { + "description": "Minimum number of items to select.", + "type": "integer" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "array", + "type": "string" + } + }, + "required": [ + "items", + "type" + ], + "type": "object" + }, + "UntitledSingleSelectEnumSchema": { + "description": "Schema for single-selection enumeration without display titles for options.", + "properties": { + "default": { + "description": "Optional default value.", + "type": "string" + }, + "description": { + "description": "Optional description for the enum field.", + "type": "string" + }, + "enum": { + "description": "Array of enum values to choose from.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Optional title for the enum field.", + "type": "string" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "enum", + "type" + ], + "type": "object" + } + } +} + diff --git a/src/spec-types/draft.ts b/src/spec-types/draft.ts new file mode 100644 index 0000000..110485f --- /dev/null +++ b/src/spec-types/draft.ts @@ -0,0 +1,3184 @@ +/* JSON types */ + +/** + * @category Common Types + */ +export type JSONValue = + string | number | boolean | null | JSONObject | JSONArray; + +/** + * @category Common Types + */ +export type JSONObject = { [key: string]: JSONValue }; + +/** + * @category Common Types + */ +export type JSONArray = JSONValue[]; + +/* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + * + * @category JSON-RPC + */ +export type JSONRPCMessage = + JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; + +/** @internal */ +export const LATEST_PROTOCOL_VERSION = "2026-07-28"; +/** @internal */ +export const JSONRPC_VERSION = "2.0"; + +/** + * Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions. + * + * Certain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions. + * + * Valid keys have two segments: + * + * **Prefix:** + * - Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`). + * - Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`). + * - Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`). + * - Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`. + * + * **Name:** + * - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). + * - Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`). + * + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export type MetaObject = Record; + +/** + * Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface RequestMetaObject extends MetaObject { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotification | notifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + /** + * The MCP Protocol Version being used for this request. Required. + * + * For the HTTP transport, this value MUST match the `MCP-Protocol-Version` + * header; otherwise the server MUST return a `400 Bad Request`. If the + * server does not support the requested version, it MUST return an + * {@link UnsupportedProtocolVersionError}. + */ + "io.modelcontextprotocol/protocolVersion": string; + /** + * Identifies the client software making the request. Clients SHOULD + * include this field on every request unless specifically configured not + * to do so. + * + * The {@link Implementation} schema requires `name` and `version`; other + * fields are optional. + * + * The value is self-reported by the client and is not verified by the + * protocol. It is intended for display, logging, and debugging. Servers + * SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + * security decisions. + */ + "io.modelcontextprotocol/clientInfo"?: Implementation; + /** + * The client's capabilities for this specific request. Required. + * + * Capabilities are declared per-request rather than once at initialization; + * an empty object means the client supports no optional capabilities. + * Servers MUST NOT infer capabilities from prior requests. + */ + "io.modelcontextprotocol/clientCapabilities": ClientCapabilities; + /** + * The desired log level for this request. Optional. + * + * If absent, the server MUST NOT send any {@link LoggingMessageNotification | notifications/message} + * notifications for this request. The client opts in to log messages by + * explicitly setting a level. Replaces the former `logging/setLevel` RPC. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + */ + "io.modelcontextprotocol/logLevel"?: LoggingLevel; +} + +/** + * Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface NotificationMetaObject extends MetaObject { + /** + * Identifies the subscription stream a notification was delivered on. The + * server MUST include this key on every notification delivered via a + * {@link SubscriptionsListenRequest | subscriptions/listen} stream, so the + * client can correlate the notification with the originating subscription. + * The key is absent on notifications not delivered via a subscription + * stream (e.g. progress notifications for an in-flight request), which is + * why it is optional here. + * + * The value is the JSON-RPC ID of the `subscriptions/listen` request that + * opened the stream. + */ + "io.modelcontextprotocol/subscriptionId"?: RequestId; +} + +/** + * Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface ResultMetaObject extends MetaObject { + /** + * Identifies the server software producing the response. Servers SHOULD + * include this field on every response unless specifically configured not + * to do so. + * + * The {@link Implementation} schema requires `name` and `version`; other + * fields are optional. + * + * The value is self-reported by the server and is not verified by the + * protocol. It is intended for display, logging, and debugging. Clients + * SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for + * security decisions. + */ + "io.modelcontextprotocol/serverInfo"?: Implementation; +} + +/** + * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + * + * @category Common Types + */ +export type Cursor = string; + +/** + * Common params for any request. + * + * @category Common Types + */ +export interface RequestParams { + _meta: RequestMetaObject; +} + +/** @internal */ +export interface Request { + method: string; + // Allow unofficial extensions of `Request.params` without impacting `RequestParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * Common params for any notification. + * + * @category Common Types + */ +export interface NotificationParams { + _meta?: NotificationMetaObject; +} + +/** @internal */ +export interface Notification { + method: string; + // Allow unofficial extensions of `Notification.params` without impacting `NotificationParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * Indicates the type of a {@link Result} object, allowing the client to + * determine how to parse the response. + * + * complete - the request completed successfully and the result contains the final content. + * input_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request. + * @category Common Types + */ +export type ResultType = "complete" | "input_required" | string; + +/** + * Common result fields. + * + * @category Common Types + */ +export interface Result { + _meta?: ResultMetaObject; + /** + * Indicates the type of the result, which allows the client to determine + * how to parse the result object. + * + * Servers implementing this protocol version MUST include this field. + * For backward compatibility, when a client receives a result from a + * server implementing an earlier protocol version (which does not include + * `resultType`), the client MUST treat the absent field as `"complete"`. + */ + resultType: ResultType; + [key: string]: unknown; +} + +/** + * @category Errors + */ +export interface Error { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + * + * @category JSON-RPC + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + * + * @category JSON-RPC + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + * + * @category JSON-RPC + */ +export interface JSONRPCResultResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +/** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ +export interface JSONRPCErrorResponse { + jsonrpc: typeof JSONRPC_VERSION; + id?: RequestId; + error: Error; +} + +/** + * A response to a request, containing either the result or error. + * + * @category JSON-RPC + */ +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +/** + * A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message. + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Invalid JSON + * {@includeCode ./examples/ParseError/invalid-json.json} + * + * @category Errors + */ +export interface ParseError extends Error { + code: typeof PARSE_ERROR; +} + +/** + * A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields). + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @category Errors + */ +export interface InvalidRequestError extends Error { + code: typeof INVALID_REQUEST; +} + +/** + * A JSON-RPC error indicating that the requested method does not exist or is not available. + * + * In MCP, a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised). + * + * A request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32021`). + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Prompts not supported + * {@includeCode ./examples/MethodNotFoundError/prompts-not-supported.json} + * + * @category Errors + */ +export interface MethodNotFoundError extends Error { + code: typeof METHOD_NOT_FOUND; +} + +/** + * A JSON-RPC error indicating that the method parameters are invalid or malformed. + * + * In MCP, this error is returned in various contexts when request parameters fail validation: + * + * - **Tools**: Unknown tool name or invalid tool arguments + * - **Prompts**: Unknown prompt name or missing required arguments + * - **Pagination**: Invalid or expired cursor values + * - **Logging**: Invalid log level + * - **Elicitation**: Server requests an elicitation mode not declared in client capabilities + * - **Sampling**: Missing tool result or tool results mixed with other content + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Unknown tool + * {@includeCode ./examples/InvalidParamsError/unknown-tool.json} + * + * @example Invalid tool arguments + * {@includeCode ./examples/InvalidParamsError/invalid-tool-arguments.json} + * + * @example Unknown prompt + * {@includeCode ./examples/InvalidParamsError/unknown-prompt.json} + * + * @example Invalid cursor + * {@includeCode ./examples/InvalidParamsError/invalid-cursor.json} + * + * @category Errors + */ +export interface InvalidParamsError extends Error { + code: typeof INVALID_PARAMS; +} + +/** + * A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request. + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Unexpected error + * {@includeCode ./examples/InternalError/unexpected-error.json} + * + * @category Errors + */ +export interface InternalError extends Error { + code: typeof INTERNAL_ERROR; +} + +/* + * MCP error codes. + * + * JSON-RPC 2.0 reserves `-32000` to `-32099` for implementation-defined + * server errors. MCP partitions that range: + * + * - `-32000` to `-32019`: implementation-defined. Existing SDKs and + * implementations use codes here for their own purposes; the specification + * will never define codes in this sub-range, and receivers must not assign + * cross-implementation semantics to them. + * - `-32020` to `-32099`: reserved for error codes defined by the MCP + * specification. Every code allocated here is recorded in this file. + * Codes are allocated sequentially starting at `-32020` and proceeding + * toward `-32099`. + * + * Codes defined by earlier protocol versions remain reserved and are never + * reused: `-32002` (resource not found, 2025-11-25 and earlier; replaced by + * `-32602`) and `-32042` (URL elicitation required, 2025-11-25 only). + */ + +/** + * Error code returned when the HTTP headers of a request do not match the + * corresponding values in the request body, or required headers are + * missing or malformed. + * + * @category Errors + */ +export const HEADER_MISMATCH = -32020; + +/** + * Error code returned when a server requires a client capability that was + * not declared in the request's `clientCapabilities`. + * + * @category Errors + */ +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; + +/** + * Error code returned when the request's protocol version is not supported + * by the server. + * + * @category Errors + */ +export const UNSUPPORTED_PROTOCOL_VERSION = -32022; + +/** + * Returned when a server rejects a request because the values in the HTTP + * headers do not match the corresponding values in the request body, or + * because required headers are missing or malformed. For HTTP, the response + * status code MUST be `400 Bad Request`. + * + * @example Header mismatch + * {@includeCode ./examples/HeaderMismatchError/header-mismatch.json} + * + * @category Errors + */ +export interface HeaderMismatchError extends Omit< + JSONRPCErrorResponse, + "error" +> { + error: Error & { + code: typeof HEADER_MISMATCH; + }; +} + +/** + * Returned when the request's protocol version is unknown to the server or + * unsupported (e.g., a known experimental or draft version the server has + * chosen not to implement). For HTTP, the response status code MUST be + * `400 Bad Request`. + * + * @example Unsupported protocol version + * {@includeCode ./examples/UnsupportedProtocolVersionError/unsupported-version.json} + * + * @category Errors + */ +export interface UnsupportedProtocolVersionError extends Omit< + JSONRPCErrorResponse, + "error" +> { + error: Error & { + code: typeof UNSUPPORTED_PROTOCOL_VERSION; + data: { + /** + * Protocol versions the server supports. The client should choose a + * mutually supported version from this list and retry. + */ + supported: string[]; + /** + * The protocol version that was requested by the client. + */ + requested: string; + }; + }; +} + +/** + * Returned when processing a request requires a capability the client did not + * declare in `clientCapabilities`. For HTTP, the response status code MUST be + * `400 Bad Request`. + * + * @example Missing elicitation capability + * {@includeCode ./examples/MissingRequiredClientCapabilityError/missing-elicitation-capability.json} + * + * @category Errors + */ +export interface MissingRequiredClientCapabilityError extends Omit< + JSONRPCErrorResponse, + "error" +> { + error: Error & { + code: typeof MISSING_REQUIRED_CLIENT_CAPABILITY; + data: { + /** + * The capabilities the server requires from the client to process this request. + */ + requiredCapabilities: ClientCapabilities; + }; + }; +} + +/* Empty result */ +/** + * A result that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/** @internal */ +export type InputRequest = + CreateMessageRequest | ListRootsRequest | ElicitRequest; + +/** @internal */ +export type InputResponse = + CreateMessageResult | ListRootsResult | ElicitResult; + +/** + * A map of server-initiated requests that the client must fulfill. + * Keys are server-assigned identifiers; values are the request objects. + * + * @example Elicitation and sampling input requests + * {@includeCode ./examples/InputRequests/elicitation-and-sampling-input-requests.json} + * + * @category Multi Round-Trip + */ +export interface InputRequests { + [key: string]: InputRequest; +} + +/** + * A map of client responses to server-initiated requests. + * Keys correspond to the keys in the {@link InputRequests} map; + * values are the client's result for each request. + * + * @example Elicitation and sampling input responses + * {@includeCode ./examples/InputResponses/elicitation-and-sampling-input-responses.json} + * + * @category Multi Round-Trip + */ +export interface InputResponses { + [key: string]: InputResponse; +} + +/** + * An InputRequiredResult sent by the server to indicate that additional input is needed + * before the request can be completed. + * + * At least one of `inputRequests` or `requestState` MUST be present. + * @example InputRequiredResult with elicitation and sampling input requests and request state + * {@includeCode ./examples/InputRequiredResult/input-required-result-with-elicitation-and-sampling-and-request-state.json} + * + * @example InputRequiredResult with request state only (load shedding) + * {@includeCode ./examples/InputRequiredResult/input-required-result-with-request-state-only.json} + * + * @category Multi Round-Trip + */ +export interface InputRequiredResult extends Result { + /* Requests issued by the server that must be complete before the + * client can retry the original request. + */ + inputRequests?: InputRequests; + /* Request state to be passed back to the server when the client + * retries the original request. + * Note: The client must treat this as an opaque blob; it must not + * interpret it in any way. + */ + requestState?: string; +} + +/* Request parameter type that includes input responses and request state. + * These parameters may be included in any client-initiated request. + */ +export interface InputResponseRequestParams extends RequestParams { + /* New field to carry the responses for the server's requests from the + * InputRequiredResult message. For each key in the response's inputRequests + * field, the same key must appear here with the associated response. + */ + inputResponses?: InputResponses; + /* Request state passed back to the server from the client. + */ + requestState?: string; +} + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @example User-requested cancellation + * {@includeCode ./examples/CancelledNotificationParams/user-requested-cancellation.json} + * + * @category `notifications/cancelled` + */ +export interface CancelledNotificationParams extends NotificationParams { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request the client previously issued. + */ + requestId: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; +} + +/** + * This notification is sent by the client to indicate that it is cancelling a request it previously issued. + * + * On stdio, the server also sends this notification, solely to terminate a {@link SubscriptionsListenRequest | subscriptions/listen} stream: it references the ID of the `subscriptions/listen` request that opened the stream. Servers MUST NOT use this notification to cancel any other request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * @example User-requested cancellation + * {@includeCode ./examples/CancelledNotification/user-requested-cancellation.json} + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: "notifications/cancelled"; + params: CancelledNotificationParams; +} + +/* Discovery */ +/** + * A request from the client asking the server to advertise its supported + * protocol versions, capabilities, and other metadata. Servers **MUST** + * implement `server/discover`. Clients **MAY** call it but are not required + * to — version negotiation can also happen inline via per-request `_meta`. + * + * @example Discover request + * {@includeCode ./examples/DiscoverRequest/server-discover-request.json} + * + * @category `server/discover` + */ +export interface DiscoverRequest extends JSONRPCRequest { + method: "server/discover"; + params: RequestParams; +} + +/** + * The result returned by the server for a {@link DiscoverRequest | server/discover} request. + * + * @example Server capabilities discovery + * {@includeCode ./examples/DiscoverResult/server-capabilities-discovery.json} + * + * @category `server/discover` + */ +export interface DiscoverResult extends CacheableResult { + /** + * MCP Protocol Versions this server supports. The client should choose a + * version from this list for use in subsequent requests. + */ + supportedVersions: string[]; + /** + * The capabilities of the server. + */ + capabilities: ServerCapabilities; + /** + * Natural-language guidance describing the server and its features. + * + * This can be used by clients to improve an LLM's understanding of + * available tools (e.g., by including it in a system prompt). It should + * focus on information that helps the model use the server effectively + * and should not duplicate information already in tool descriptions. + */ + instructions?: string; +} + +/** + * A successful response from the server for a {@link DiscoverRequest | server/discover} request. + * + * @example Discover result response + * {@includeCode ./examples/DiscoverResultResponse/discover-result-response.json} + * + * @category `server/discover` + */ +export interface DiscoverResultResponse extends JSONRPCResultResponse { + result: DiscoverResult; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `server/discover` + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: JSONObject }; + /** + * Present if the client supports listing roots. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @example Roots — minimum baseline support + * {@includeCode ./examples/ClientCapabilities/roots-minimum-baseline-support.json} + */ + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + roots?: {}; + /** + * Present if the client supports sampling from an LLM. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @example Sampling — minimum baseline support + * {@includeCode ./examples/ClientCapabilities/sampling-minimum-baseline-support.json} + * + * @example Sampling — tool use support + * {@includeCode ./examples/ClientCapabilities/sampling-tool-use-support.json} + * + * @example Sampling — context inclusion support (deprecated) + * {@includeCode ./examples/ClientCapabilities/sampling-context-inclusion-support-deprecated.json} + */ + sampling?: { + /** + * Whether the client supports context inclusion via `includeContext` parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: JSONObject; + /** + * Whether the client supports tool use via `tools` and `toolChoice` parameters. + */ + tools?: JSONObject; + }; + /** + * Present if the client supports elicitation from the server. + * + * @example Elicitation — form and URL mode support + * {@includeCode ./examples/ClientCapabilities/elicitation-form-and-url-mode-support.json} + * + * @example Elicitation — form mode only (implicit) + * {@includeCode ./examples/ClientCapabilities/elicitation-form-only-implicit.json} + */ + elicitation?: { + form?: JSONObject; + url?: JSONObject; + }; + + /** + * Optional MCP extensions that the client supports. Keys are extension identifiers + * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are + * per-extension settings objects. An empty object indicates support with no settings. + * + * Keys MUST follow the {@link MetaObject | `_meta` key naming rules}, with a + * mandatory prefix. + * + * @example Extensions — MCP Apps (UI) extension with MIME type support + * {@includeCode ./examples/ClientCapabilities/extensions-ui-mime-types.json} + */ + extensions?: { [key: string]: JSONObject }; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `server/discover` + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: JSONObject }; + /** + * Present if the server supports sending log messages to the client. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @example Logging — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/logging-minimum-baseline-support.json} + */ + logging?: JSONObject; + /** + * Present if the server supports argument autocompletion suggestions. + * + * @example Completions — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/completions-minimum-baseline-support.json} + */ + completions?: JSONObject; + /** + * Present if the server offers any prompt templates. + * + * @example Prompts — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/prompts-minimum-baseline-support.json} + * + * @example Prompts — list changed notifications + * {@includeCode ./examples/ServerCapabilities/prompts-list-changed-notifications.json} + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + * + * @example Resources — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/resources-minimum-baseline-support.json} + * + * @example Resources — subscription to individual resource updates (only) + * {@includeCode ./examples/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json} + * + * @example Resources — list changed notifications (only) + * {@includeCode ./examples/ServerCapabilities/resources-list-changed-notifications-only.json} + * + * @example Resources — all notifications + * {@includeCode ./examples/ServerCapabilities/resources-all-notifications.json} + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + * + * @example Tools — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/tools-minimum-baseline-support.json} + * + * @example Tools — list changed notifications + * {@includeCode ./examples/ServerCapabilities/tools-list-changed-notifications.json} + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; + /** + * Optional MCP extensions that the server supports. Keys are extension identifiers + * (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings + * objects. An empty object indicates support with no settings. + * + * Keys MUST follow the {@link MetaObject | `_meta` key naming rules}, with a + * mandatory prefix. + * + * @example Extensions — Tasks extension support + * {@includeCode ./examples/ServerCapabilities/extensions-tasks.json} + */ + extensions?: { [key: string]: JSONObject }; +} + +/** + * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types + */ +export interface Icon { + /** + * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + * `data:` URI with Base64-encoded image data. + * + * Consumers SHOULD take steps to ensure URLs serving icons are from the + * same domain as the client/server or a trusted domain. + * + * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + * executable JavaScript. + * + * @format uri + */ + src: string; + + /** + * Optional MIME type override if the source MIME type is missing or generic. + * For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + */ + mimeType?: string; + + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes?: string[]; + + /** + * Optional specifier for the theme this icon is designed for. `"light"` indicates + * the icon is designed to be used with a light background, and `"dark"` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme?: "light" | "dark"; +} + +/** + * Base interface to add `icons` property. + * + * @internal + */ +export interface Icons { + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons?: Icon[]; +} + +/** + * Base interface for metadata with name (identifier) and title (display name) properties. + * + * @internal + */ +export interface BaseMetadata { + /** + * Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + */ + name: string; + + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for {@link Tool}, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title?: string; +} + +/** + * Describes the MCP implementation. + * + * @category `server/discover` + */ +export interface Implementation extends BaseMetadata, Icons { + /** + * The version of this implementation. + */ + version: string; + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description?: string; + + /** + * An optional URL of the website for this implementation. + * + * @format uri + */ + websiteUrl?: string; +} + +/* Progress notifications */ + +/** + * Parameters for a {@link ProgressNotification | notifications/progress} notification. + * + * @example Progress message + * {@includeCode ./examples/ProgressNotificationParams/progress-message.json} + * + * @category `notifications/progress` + */ +export interface ProgressNotificationParams extends NotificationParams { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; +} + +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @example Progress message + * {@includeCode ./examples/ProgressNotification/progress-message.json} + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: "notifications/progress"; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common params for paginated requests. + * + * @example List request with cursor + * {@includeCode ./examples/PaginatedRequestParams/list-with-cursor.json} + * + * @category Common Types + */ +export interface PaginatedRequestParams extends RequestParams { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; +} + +/** @internal */ +export interface PaginatedRequest extends JSONRPCRequest { + params: PaginatedRequestParams; +} + +/** @internal */ +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/** + * A result that supports a time-to-live (TTL) hint for client-side caching. + * + * @internal + */ +export interface CacheableResult extends Result { + /** + * A hint from the server indicating how long (in milliseconds) the + * client MAY cache this response before re-fetching. Semantics are + * analogous to HTTP Cache-Control max-age. + * + * - If 0, The response SHOULD be considered immediately stale, + * The client MAY re-fetch every time the result is needed. + * - If positive, the client SHOULD consider the result fresh for this many + * milliseconds after receiving the response. + * + * @minimum 0 + */ + ttlMs: number; + + /** + * Indicates the intended scope of the cached response, analogous to HTTP + * `Cache-Control: public` vs `Cache-Control: private`. + * + * - `"public"`: The response does not contain user-specific data. Any + * client or intermediary (e.g., shared gateway, caching proxy) MAY cache + * the response and serve it across authorization contexts. + * - `"private"`: The response MAY be cached and reused only within the + * same authorization context. Caches MUST NOT be shared across + * authorization contexts (e.g., a different access token requires a + * different cache). + * + */ + cacheScope: "public" | "private"; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + * + * @example List resources request + * {@includeCode ./examples/ListResourcesRequest/list-resources-request.json} + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; +} + +/** + * The result returned by the server for a {@link ListResourcesRequest | resources/list} request. + * + * @example Resources list with cursor and TTL + * {@includeCode ./examples/ListResourcesResult/resources-list-with-cursor-and-ttl.json} + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult, CacheableResult { + resources: Resource[]; +} + +/** + * A successful response from the server for a {@link ListResourcesRequest | resources/list} request. + * + * @example List resources result response + * {@includeCode ./examples/ListResourcesResultResponse/list-resources-result-response.json} + * + * @category `resources/list` + */ +export interface ListResourcesResultResponse extends JSONRPCResultResponse { + result: ListResourcesResult; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @example List resource templates request + * {@includeCode ./examples/ListResourceTemplatesRequest/list-resource-templates-request.json} + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; +} + +/** + * The result returned by the server for a {@link ListResourceTemplatesRequest | resources/templates/list} request. + * + * @example Resource templates list with cursor and TTL + * {@includeCode ./examples/ListResourceTemplatesResult/resource-templates-list-with-cursor-and-ttl.json} + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult + extends PaginatedResult, CacheableResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * A successful response from the server for a {@link ListResourceTemplatesRequest | resources/templates/list} request. + * + * @example List resource templates result response + * {@includeCode ./examples/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json} + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResultResponse extends JSONRPCResultResponse { + result: ListResourceTemplatesResult; +} + +/** + * Common params for resource-related requests. + * + * @internal + */ +export interface ResourceRequestParams extends RequestParams { + /** + * The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; +} + +/** + * Parameters for a `resources/read` request. + * + * @category `resources/read` + */ +export interface ReadResourceRequestParams + extends ResourceRequestParams, InputResponseRequestParams {} + +/** + * Sent from the client to the server, to read a specific resource URI. + * + * @example Read resource request + * {@includeCode ./examples/ReadResourceRequest/read-resource-request.json} + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: "resources/read"; + params: ReadResourceRequestParams; +} + +/** + * The result returned by the server for a {@link ReadResourceRequest | resources/read} request. + * + * @example File resource contents + * {@includeCode ./examples/ReadResourceResult/file-resource-contents.json} + * + * @category `resources/read` + */ +export interface ReadResourceResult extends CacheableResult { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * A successful response from the server for a {@link ReadResourceRequest | resources/read} request. + * + * @example Read resource result response + * {@includeCode ./examples/ReadResourceResultResponse/read-resource-result-response.json} + * + * @example Read resource result response with TTL + * {@includeCode ./examples/ReadResourceResultResponse/read-resource-result-response-with-ttl.json} + * + * @category `resources/read` + */ +export interface ReadResourceResultResponse extends JSONRPCResultResponse { + result: ReadResourceResult | InputRequiredResult; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `resourcesListChanged` filter field. + * + * @example Resources list changed + * {@includeCode ./examples/ResourceListChangedNotification/resources-list-changed.json} + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: "notifications/resources/list_changed"; + params?: NotificationParams; +} + +/** + * The set of notification types a client may opt in to on a + * {@link SubscriptionsListenRequest | subscriptions/listen} request. + * + * Each notification type is **opt-in**; the server **MUST NOT** send + * notification types the client has not explicitly requested here. + * + * @category `subscriptions/listen` + */ +export interface SubscriptionFilter { + /** + * If true, receive {@link ToolListChangedNotification | notifications/tools/list_changed}. + */ + toolsListChanged?: boolean; + /** + * If true, receive {@link PromptListChangedNotification | notifications/prompts/list_changed}. + */ + promptsListChanged?: boolean; + /** + * If true, receive {@link ResourceListChangedNotification | notifications/resources/list_changed}. + */ + resourcesListChanged?: boolean; + /** + * Subscribe to {@link ResourceUpdatedNotification | notifications/resources/updated} for these resource URIs. + * Replaces the former `resources/subscribe` RPC. + */ + resourceSubscriptions?: string[]; +} + +/** + * Parameters for a {@link SubscriptionsListenRequest | subscriptions/listen} request. + * + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenRequestParams extends RequestParams { + /** + * The notifications the client opts in to on this stream. The server + * **MUST NOT** send notification types the client has not explicitly + * requested. + */ + notifications: SubscriptionFilter; +} + +/** + * Sent from the client to open a long-lived channel for receiving notifications + * outside the context of a specific request. Replaces the previous HTTP GET + * endpoint and ensures consistent behavior between HTTP and STDIO. + * + * @example Listen for tools and resource list changes + * {@includeCode ./examples/SubscriptionsListenRequest/listen-for-list-changes.json} + * + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenRequest extends JSONRPCRequest { + method: "subscriptions/listen"; + params: SubscriptionsListenRequestParams; +} + +/** + * Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a + * {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenResultMeta extends ResultMetaObject { + /** + * Identifies the subscription stream this response closes, so the client can + * correlate it with the originating subscription — mirroring the same key on + * the stream's notifications. The value is the JSON-RPC ID of the + * `subscriptions/listen` request that opened the stream (and equals this + * response's `id`). + */ + "io.modelcontextprotocol/subscriptionId": RequestId; +} + +/** + * The response to a {@link SubscriptionsListenRequest | subscriptions/listen} + * request, signalling that the subscription has ended gracefully (for example, + * during server shutdown). Because the listen stream is long-lived, this result + * is sent only when the server tears the subscription down; an abrupt transport + * close carries no response. The result body is otherwise empty. + * + * @example Subscription closed gracefully + * {@includeCode ./examples/SubscriptionsListenResult/listen-closed.json} + * + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenResult extends Result { + _meta: SubscriptionsListenResultMeta; +} + +/** + * Parameters for a {@link SubscriptionsAcknowledgedNotification | notifications/subscriptions/acknowledged} notification. + * + * @category `notifications/subscriptions/acknowledged` + */ +export interface SubscriptionsAcknowledgedNotificationParams extends NotificationParams { + /** + * The subset of requested notification types the server agreed to honor. + * Only includes notification types the server actually supports; if the + * client requested an unsupported type (e.g., `promptsListChanged` when + * the server has no prompts), it is omitted from this set. + */ + notifications: SubscriptionFilter; +} + +/** + * Sent by the server to acknowledge that a + * {@link SubscriptionsListenRequest | subscriptions/listen} subscription has been + * established and to report which notification types it agreed to honor. + * + * This notification MUST be the first message the server sends carrying the + * subscription's ID in `io.modelcontextprotocol/subscriptionId`. The server MUST + * NOT send any notification on the subscription before acknowledging it. On + * stdio, where every subscription shares one channel, this ordering is defined + * per subscription ID and not per channel: messages belonging to other + * subscriptions MAY be interleaved before it. + * + * @example Listen acknowledged + * {@includeCode ./examples/SubscriptionsAcknowledgedNotification/listen-acknowledged.json} + * + * @category `notifications/subscriptions/acknowledged` + */ +export interface SubscriptionsAcknowledgedNotification extends JSONRPCNotification { + method: "notifications/subscriptions/acknowledged"; + params: SubscriptionsAcknowledgedNotificationParams; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @example File resource updated + * {@includeCode ./examples/ResourceUpdatedNotificationParams/file-resource-updated.json} + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotificationParams extends NotificationParams { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This is only sent for resources the client opted in to via the `resourceSubscriptions` field of a {@link SubscriptionsListenRequest | subscriptions/listen} request. + * + * @example File resource updated notification + * {@includeCode ./examples/ResourceUpdatedNotification/file-resource-updated-notification.json} + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotification extends JSONRPCNotification { + method: "notifications/resources/updated"; + params: ResourceUpdatedNotificationParams; +} + +/** + * A known resource that the server is capable of reading. + * + * @example File resource with annotations + * {@includeCode ./examples/Resource/file-resource-with-annotations.json} + * + * @category `resources/list` + */ +export interface Resource extends BaseMetadata, Icons { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + + _meta?: MetaObject; +} + +/** + * A template description for resources available on the server. + * + * @category `resources/templates/list` + */ +export interface ResourceTemplate extends BaseMetadata, Icons { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * The contents of a specific resource or sub-resource. + * + * @internal + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + _meta?: MetaObject; +} + +/** + * @example Text file contents + * {@includeCode ./examples/TextResourceContents/text-file-contents.json} + * + * @category Content + */ +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +/** + * @example Image file contents + * {@includeCode ./examples/BlobResourceContents/image-file-contents.json} + * + * @category Content + */ +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + * + * @example List prompts request + * {@includeCode ./examples/ListPromptsRequest/list-prompts-request.json} + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; +} + +/** + * The result returned by the server for a {@link ListPromptsRequest | prompts/list} request. + * + * @example Prompts list with cursor and TTL + * {@includeCode ./examples/ListPromptsResult/prompts-list-with-cursor-and-ttl.json} + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult, CacheableResult { + prompts: Prompt[]; +} + +/** + * A successful response from the server for a {@link ListPromptsRequest | prompts/list} request. + * + * @example List prompts result response + * {@includeCode ./examples/ListPromptsResultResponse/list-prompts-result-response.json} + * + * @category `prompts/list` + */ +export interface ListPromptsResultResponse extends JSONRPCResultResponse { + result: ListPromptsResult; +} + +/** + * Parameters for a `prompts/get` request. + * + * @example Get code review prompt + * {@includeCode ./examples/GetPromptRequestParams/get-code-review-prompt.json} + * + * @category `prompts/get` + */ +export interface GetPromptRequestParams extends InputResponseRequestParams { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; +} + +/** + * Used by the client to get a prompt provided by the server. + * + * @example Get prompt request + * {@includeCode ./examples/GetPromptRequest/get-prompt-request.json} + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: "prompts/get"; + params: GetPromptRequestParams; +} + +/** + * The result returned by the server for a {@link GetPromptRequest | prompts/get} request. + * + * @example Code review prompt + * {@includeCode ./examples/GetPromptResult/code-review-prompt.json} + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A successful response from the server for a {@link GetPromptRequest | prompts/get} request. + * + * @example Get prompt result response + * {@includeCode ./examples/GetPromptResultResponse/get-prompt-result-response.json} + * + * @category `prompts/get` + */ +export interface GetPromptResultResponse extends JSONRPCResultResponse { + result: GetPromptResult | InputRequiredResult; +} + +/** + * A prompt or prompt template that the server offers. + * + * @category `prompts/list` + */ +export interface Prompt extends BaseMetadata, Icons { + /** + * An optional description of what this prompt provides + */ + description?: string; + + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + + _meta?: MetaObject; +} + +/** + * Describes an argument that a prompt can accept. + * + * @category `prompts/list` + */ +export interface PromptArgument extends BaseMetadata { + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + * + * @category Common Types + */ +export type Role = "user" | "assistant"; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to {@link SamplingMessage}, but also supports the embedding of + * resources from the MCP server. + * + * @category `prompts/get` + */ +export interface PromptMessage { + role: Role; + content: ContentBlock; +} + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequest | resources/list} requests. + * + * @example File resource link + * {@includeCode ./examples/ResourceLink/file-resource-link.json} + * + * @category Content + */ +export interface ResourceLink extends Resource { + type: "resource_link"; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + * + * @example Embedded file resource with annotations + * {@includeCode ./examples/EmbeddedResource/embedded-file-resource-with-annotations.json} + * + * @category Content + */ +export interface EmbeddedResource { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `promptsListChanged` filter field. + * + * @example Prompts list changed + * {@includeCode ./examples/PromptListChangedNotification/prompts-list-changed.json} + * + * @category `notifications/prompts/list_changed` + */ +export interface PromptListChangedNotification extends JSONRPCNotification { + method: "notifications/prompts/list_changed"; + params?: NotificationParams; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + * + * @example List tools request + * {@includeCode ./examples/ListToolsRequest/list-tools-request.json} + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; +} + +/** + * The result returned by the server for a {@link ListToolsRequest | tools/list} request. + * + * @example Tools list with cursor and TTL + * {@includeCode ./examples/ListToolsResult/tools-list-with-cursor-and-ttl.json} + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult, CacheableResult { + tools: Tool[]; +} + +/** + * A successful response from the server for a {@link ListToolsRequest | tools/list} request. + * + * @example List tools result response + * {@includeCode ./examples/ListToolsResultResponse/list-tools-result-response.json} + * + * @category `tools/list` + */ +export interface ListToolsResultResponse extends JSONRPCResultResponse { + result: ListToolsResult; +} + +/** + * The result returned by the server for a {@link CallToolRequest | tools/call} request. + * + * @example Result with unstructured text + * {@includeCode ./examples/CallToolResult/result-with-unstructured-text.json} + * + * @example Result with structured content + * {@includeCode ./examples/CallToolResult/result-with-structured-content.json} + * + * @example Invalid tool input error + * {@includeCode ./examples/CallToolResult/invalid-tool-input-error.json} + * + * @category `tools/call` + */ +export interface CallToolResult extends Result { + /** + * A list of content objects that represent the unstructured result of the tool call. + */ + content: ContentBlock[]; + + /** + * An optional JSON value that represents the structured result of the tool call. + * + * This can be any JSON value (object, array, string, number, boolean, or null) + * that conforms to the tool's outputSchema if one is defined. + */ + structuredContent?: unknown; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError?: boolean; +} + +/** + * A successful response from the server for a {@link CallToolRequest | tools/call} request. + * + * @example Call tool result response + * {@includeCode ./examples/CallToolResultResponse/call-tool-result-response.json} + * + * @category `tools/call` + */ +export interface CallToolResultResponse extends JSONRPCResultResponse { + result: CallToolResult | InputRequiredResult; +} + +/** + * Parameters for a `tools/call` request. + * + * @example `get_weather` tool call params + * {@includeCode ./examples/CallToolRequestParams/get-weather-tool-call-params.json} + * + * @example Tool call params with progress token + * {@includeCode ./examples/CallToolRequestParams/tool-call-params-with-progress-token.json} + * + * @category `tools/call` + */ +export interface CallToolRequestParams extends InputResponseRequestParams { + /** + * The name of the tool. + */ + name: string; + /** + * Arguments to use for the tool call. + */ + arguments?: { [key: string]: unknown }; +} + +/** + * Used by the client to invoke a tool provided by the server. + * + * @example Call tool request + * {@includeCode ./examples/CallToolRequest/call-tool-request.json} + * + * @category `tools/call` + */ +export interface CallToolRequest extends JSONRPCRequest { + method: "tools/call"; + params: CallToolRequestParams; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This is only delivered on a {@link SubscriptionsListenRequest | subscriptions/listen} stream when the client requested it via the `toolsListChanged` filter field. + * + * @example Tools list changed + * {@includeCode ./examples/ToolListChangedNotification/tools-list-changed.json} + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: "notifications/tools/list_changed"; + params?: NotificationParams; +} + +/** + * Additional properties describing a {@link Tool} to clients. + * + * NOTE: all properties in `ToolAnnotations` are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on `ToolAnnotations` + * received from untrusted servers. + * + * @category `tools/list` + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Definition for a tool the client can call. + * + * @example With default 2020-12 input schema + * {@includeCode ./examples/Tool/with-default-2020-12-input-schema.json} + * + * @example With explicit draft-07 input schema + * {@includeCode ./examples/Tool/with-explicit-draft-07-input-schema.json} + * + * @example With no parameters + * {@includeCode ./examples/Tool/with-no-parameters.json} + * + * @example With output schema for structured content + * {@includeCode ./examples/Tool/with-output-schema-for-structured-content.json} + * + * @category `tools/list` + */ +export interface Tool extends BaseMetadata, Icons { + /** + * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + * + * Tool arguments are always JSON objects, so `type: "object"` is required at the root. + * Beyond that, any JSON Schema 2020-12 keyword may appear alongside `type` — including + * composition keywords (`oneOf`, `anyOf`, `allOf`, `not`), conditional keywords + * (`if`/`then`/`else`), reference keywords (`$ref`, `$defs`, `$anchor`), and any other + * standard validation or annotation keywords. + * + * Property schemas may carry an `x-mcp-header` annotation to mirror the + * argument value into an HTTP header on the Streamable HTTP transport. See + * the Streamable HTTP transport specification for the validity and + * extraction rules. + * + * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + */ + inputSchema: { $schema?: string; type: "object"; [key: string]: unknown }; + + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a {@link CallToolResult}. This can be any valid JSON Schema 2020-12. + * + * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + */ + outputSchema?: { $schema?: string; [key: string]: unknown }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: `title`, `annotations.title`, then `name`. + */ + annotations?: ToolAnnotations; + + _meta?: MetaObject; +} + +/* Logging */ + +/** + * Parameters for a `notifications/message` notification. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @example Log database connection failed + * {@includeCode ./examples/LoggingMessageNotificationParams/log-database-connection-failed.json} + * + * @category `notifications/message` + */ +export interface LoggingMessageNotificationParams extends NotificationParams { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; +} + +/** + * JSONRPCNotification of a log message passed from server to client. The client opts in by setting `"io.modelcontextprotocol/logLevel"` in a request's `_meta`. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @example Log database connection failed + * {@includeCode ./examples/LoggingMessageNotification/log-database-connection-failed.json} + * + * @category `notifications/message` + */ +export interface LoggingMessageNotification extends JSONRPCNotification { + method: "notifications/message"; + params: LoggingMessageNotificationParams; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category Common Types + */ +export type LoggingLevel = + | "debug" + | "info" + | "notice" + | "warning" + | "error" + | "critical" + | "alert" + | "emergency"; + +/* Sampling */ +/** + * Parameters for a `sampling/createMessage` request. + * + * @example Basic request + * {@includeCode ./examples/CreateMessageRequestParams/basic-request.json} + * + * @example Request with tools + * {@includeCode ./examples/CreateMessageRequestParams/request-with-tools.json} + * + * @example Follow-up request with tool results + * {@includeCode ./examples/CreateMessageRequestParams/follow-up-with-tool-results.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequestParams { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is `"none"`. The values `"thisServer"` and `"allServers"` are deprecated (SEP-2596): servers SHOULD + * omit this field or use `"none"`, and SHOULD only use the deprecated values if the client declares + * {@link ClientCapabilities.sampling.context}. + * + * @deprecated The `"thisServer"` and `"allServers"` values are deprecated as of protocol version 2025-11-25 + * (SEP-2596) and will be removed no later than the Sampling feature itself (SEP-2577). Omit this field or use `"none"`. + */ + includeContext?: "none" | "thisServer" | "allServers"; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: JSONObject; + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + */ + tools?: Tool[]; + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice?: ToolChoice; +} + +/** + * Controls tool selection behavior for sampling requests. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface ToolChoice { + /** + * Controls the tool use ability of the model: + * - `"auto"`: Model decides whether to use tools (default) + * - `"required"`: Model MUST use at least one tool before completing + * - `"none"`: Model MUST NOT use any tools + */ + mode?: "auto" | "required" | "none"; +} + +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + * + * @example Sampling request + * {@includeCode ./examples/CreateMessageRequest/sampling-request.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest { + method: "sampling/createMessage"; + params: CreateMessageRequestParams; +} + +/** + * The result returned by the client for a {@link CreateMessageRequest | sampling/createMessage} request. + * The client should inform the user before returning the sampled message, to allow them + * to inspect the response (human in the loop) and decide whether to allow the server to see it. + * + * @example Text response + * {@includeCode ./examples/CreateMessageResult/text-response.json} + * + * @example Tool use response + * {@includeCode ./examples/CreateMessageResult/tool-use-response.json} + * + * @example Final response after tool use + * {@includeCode ./examples/CreateMessageResult/final-response.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResult extends SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * - `"toolUse"`: The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason?: "endTurn" | "stopSequence" | "maxTokens" | "toolUse" | string; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @example Single content block + * {@includeCode ./examples/SamplingMessage/single-content-block.json} + * + * @example Multiple content blocks + * {@includeCode ./examples/SamplingMessage/multiple-content-blocks.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + _meta?: MetaObject; +} + +/** + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export type SamplingMessageContentBlock = + | TextContent + | ImageContent + | AudioContent + | ToolUseContent + | ToolResultContent; + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types + */ +export interface Annotations { + /** + * Describes who the intended audience of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + */ + lastModified?: string; +} + +/** + * @category Content + */ +export type ContentBlock = + TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; + +/** + * Text provided to or from an LLM. + * + * @example Text content + * {@includeCode ./examples/TextContent/text-content.json} + * + * @category Content + */ +export interface TextContent { + type: "text"; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * An image provided to or from an LLM. + * + * @example `image/png` content with annotations + * {@includeCode ./examples/ImageContent/image-png-content-with-annotations.json} + * + * @category Content + */ +export interface ImageContent { + type: "image"; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * Audio provided to or from an LLM. + * + * @example `audio/wav` content + * {@includeCode ./examples/AudioContent/audio-wav-content.json} + * + * @category Content + */ +export interface AudioContent { + type: "audio"; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * A request from the assistant to call a tool. + * + * @example `get_weather` tool use + * {@includeCode ./examples/ToolUseContent/get-weather-tool-use.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface ToolUseContent { + type: "tool_use"; + + /** + * A unique identifier for this tool use. + * + * This ID is used to match tool results to their corresponding tool uses. + */ + id: string; + + /** + * The name of the tool to call. + */ + name: string; + + /** + * The arguments to pass to the tool, conforming to the tool's input schema. + */ + input: { [key: string]: unknown }; + + /** + * Optional metadata about the tool use. Clients SHOULD preserve this field when + * including tool uses in subsequent sampling requests to enable caching optimizations. + */ + _meta?: MetaObject; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @example `get_weather` tool result + * {@includeCode ./examples/ToolResultContent/get-weather-tool-result.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface ToolResultContent { + type: "tool_result"; + + /** + * The ID of the tool use this result corresponds to. + * + * This MUST match the ID from a previous {@link ToolUseContent}. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as {@link CallToolResult.content} and can include text, images, + * audio, resource links, and embedded resources. + */ + content: ContentBlock[]; + + /** + * An optional structured result value. + * + * This can be any JSON value (object, array, string, number, boolean, or null). + * If the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema. + */ + structuredContent?: unknown; + + /** + * Whether the tool use resulted in an error. + * + * If true, the content typically describes the error that occurred. + * Default: false + */ + isError?: boolean; + + /** + * Optional metadata about the tool result. Clients SHOULD preserve this field when + * including tool results in subsequent sampling requests to enable caching optimizations. + */ + _meta?: MetaObject; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + * + * @example With hints and priorities + * {@includeCode ./examples/ModelPreferences/with-hints-and-priorities.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `sampling/createMessage` + */ +export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; +} + +/* Autocomplete */ +/** + * Parameters for a `completion/complete` request. + * + * @category `completion/complete` + * + * @example Prompt argument completion + * {@includeCode ./examples/CompleteRequestParams/prompt-argument-completion.json} + * + * @example Prompt argument completion with context + * {@includeCode ./examples/CompleteRequestParams/prompt-argument-completion-with-context.json} + */ +export interface CompleteRequestParams extends RequestParams { + ref: PromptReference | ResourceTemplateReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + + /** + * Additional, optional context for completions + */ + context?: { + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * A request from the client to the server, to ask for completion options. + * + * @example Completion request + * {@includeCode ./examples/CompleteRequest/completion-request.json} + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: "completion/complete"; + params: CompleteRequestParams; +} + +/** + * The result returned by the server for a {@link CompleteRequest | completion/complete} request. + * + * @category `completion/complete` + * + * @example Single completion value + * {@includeCode ./examples/CompleteResult/single-completion-value.json} + * + * @example Multiple completion values with more available + * {@includeCode ./examples/CompleteResult/multiple-completion-values-with-more-available.json} + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + * + * @maxItems 100 + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A successful response from the server for a {@link CompleteRequest | completion/complete} request. + * + * @example Completion result response + * {@includeCode ./examples/CompleteResultResponse/completion-result-response.json} + * + * @category `completion/complete` + */ +export interface CompleteResultResponse extends JSONRPCResultResponse { + result: CompleteResult; +} + +/** + * A reference to a resource or resource template definition. + * + * @category `completion/complete` + */ +export interface ResourceTemplateReference { + type: "ref/resource"; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + * + * @category `completion/complete` + */ +export interface PromptReference extends BaseMetadata { + type: "ref/prompt"; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + * + * @example List roots request + * {@includeCode ./examples/ListRootsRequest/list-roots-request.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `roots/list` + */ +export interface ListRootsRequest { + method: "roots/list"; + params?: { + _meta?: MetaObject; + }; +} + +/** + * The result returned by the client for a {@link ListRootsRequest | roots/list} request. + * This result contains an array of {@link Root} objects, each representing a root directory + * or file that the server can operate on. + * + * @example Single root directory + * {@includeCode ./examples/ListRootsResult/single-root-directory.json} + * + * @example Multiple root directories + * {@includeCode ./examples/ListRootsResult/multiple-root-directories.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `roots/list` + */ +export interface ListRootsResult { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @example Project directory root + * {@includeCode ./examples/Root/project-directory.json} + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + * + * @category `roots/list` + */ +export interface Root { + /** + * The URI identifying the root. This *must* start with `file://` for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; + + _meta?: MetaObject; +} + +/** + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. + * + * @example Elicit single field + * {@includeCode ./examples/ElicitRequestFormParams/elicit-single-field.json} + * + * @example Elicit multiple fields + * {@includeCode ./examples/ElicitRequestFormParams/elicit-multiple-fields.json} + * + * @category `elicitation/create` + */ +export interface ElicitRequestFormParams { + /** + * The elicitation mode. + */ + mode?: "form"; + + /** + * The message to present to the user describing what information is being requested. + */ + message: string; + + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: { + $schema?: string; + type: "object"; + properties: { + [key: string]: PrimitiveSchemaDefinition; + }; + required?: string[]; + }; +} + +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @example Elicit sensitive data + * {@includeCode ./examples/ElicitRequestURLParams/elicit-sensitive-data.json} + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams { + /** + * The elicitation mode. + */ + mode: "url"; + + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string; + + /** + * The URL that the user should navigate to. + * + * @format uri + */ + url: string; +} + +/** + * The parameters for a request to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export type ElicitRequestParams = + ElicitRequestFormParams | ElicitRequestURLParams; + +/** + * A request from the server to elicit additional information from the user via the client. + * + * @example Elicitation request + * {@includeCode ./examples/ElicitRequest/elicitation-request.json} + * + * @category `elicitation/create` + */ +export interface ElicitRequest { + method: "elicitation/create"; + params: ElicitRequestParams; +} + +/** + * Restricted schema definitions that only allow primitive types + * without nested objects or arrays. + * + * @category `elicitation/create` + */ +export type PrimitiveSchemaDefinition = + StringSchema | NumberSchema | BooleanSchema | EnumSchema; + +/** + * @example Email input schema + * {@includeCode ./examples/StringSchema/email-input-schema.json} + * + * @category `elicitation/create` + */ +export interface StringSchema { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; + default?: string; +} + +/** + * @example Number input schema + * {@includeCode ./examples/NumberSchema/number-input-schema.json} + * + * @category `elicitation/create` + */ +export interface NumberSchema { + type: "number" | "integer"; + title?: string; + description?: string; + /** + * @TJS-type number + */ + minimum?: number; + /** + * @TJS-type number + */ + maximum?: number; + /** + * @TJS-type number + */ + default?: number; +} + +/** + * @example Boolean input schema + * {@includeCode ./examples/BooleanSchema/boolean-input-schema.json} + * + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @example Color select schema + * {@includeCode ./examples/UntitledSingleSelectEnumSchema/color-select-schema.json} + * + * @category `elicitation/create` + */ +export interface UntitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum values to choose from. + */ + enum: string[]; + /** + * Optional default value. + */ + default?: string; +} + +/** + * Schema for single-selection enumeration with display titles for each option. + * + * @example Titled color select schema + * {@includeCode ./examples/TitledSingleSelectEnumSchema/titled-color-select-schema.json} + * + * @category `elicitation/create` + */ +export interface TitledSingleSelectEnumSchema { + type: "string"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum options with values and display labels. + */ + oneOf: Array<{ + /** + * The enum value. + */ + const: string; + /** + * Display label for this option. + */ + title: string; + }>; + /** + * Optional default value. + */ + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Combined single selection enumeration +export type SingleSelectEnumSchema = + UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema; + +/** + * Schema for multiple-selection enumeration without display titles for options. + * + * @example Color multi-select schema + * {@includeCode ./examples/UntitledMultiSelectEnumSchema/color-multi-select-schema.json} + * + * @category `elicitation/create` + */ +export interface UntitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for the array items. + */ + items: { + type: "string"; + /** + * Array of enum values to choose from. + */ + enum: string[]; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @example Titled color multi-select schema + * {@includeCode ./examples/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json} + * + * @category `elicitation/create` + */ +export interface TitledMultiSelectEnumSchema { + type: "array"; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for array items with enum options and display labels. + */ + items: { + /** + * Array of enum options with values and display labels. + */ + anyOf: Array<{ + /** + * The constant enum value. + */ + const: string; + /** + * Display title for this option. + */ + title: string; + }>; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * @category `elicitation/create` + */ +// Combined multiple selection enumeration +export type MultiSelectEnumSchema = + UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema; + +/** + * Use {@link TitledSingleSelectEnumSchema} instead. + * This interface will be removed in a future version. + * + * @category `elicitation/create` + */ +export interface LegacyTitledEnumSchema { + type: "string"; + title?: string; + description?: string; + enum: string[]; + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + */ + enumNames?: string[]; + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Union type for all enum schemas +export type EnumSchema = + SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema; + +/** + * The result returned by the client for an {@link ElicitRequest| elicitation/create} request. + * + * @example Input single field + * {@includeCode ./examples/ElicitResult/input-single-field.json} + * + * @example Input multiple fields + * {@includeCode ./examples/ElicitResult/input-multiple-fields.json} + * + * @example Accept URL mode (no content) + * {@includeCode ./examples/ElicitResult/accept-url-mode-no-content.json} + * + * @category `elicitation/create` + */ +export interface ElicitResult { + /** + * The user action in response to the elicitation. + * - `"accept"`: User submitted the form/confirmed the action + * - `"decline"`: User explicitly declined the action + * - `"cancel"`: User dismissed without making an explicit choice + */ + action: "accept" | "decline" | "cancel"; + + /** + * The submitted form data, only present when action is `"accept"` and mode was `"form"`. + * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. + */ + content?: { [key: string]: string | number | boolean | string[] }; +} + +/* Client messages */ +/** @internal */ +export type ClientRequest = + | DiscoverRequest + | CompleteRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscriptionsListenRequest + | CallToolRequest + | ListToolsRequest; + +/** @internal */ +export type ClientNotification = CancelledNotification; + +/** @internal */ +export type ClientResult = EmptyResult; + +/* Server messages */ + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | SubscriptionsAcknowledgedNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | DiscoverResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | SubscriptionsListenResult + | CallToolResult + | ListToolsResult + | InputRequiredResult; diff --git a/src/tier-check/checks/release.test.ts b/src/tier-check/checks/release.test.ts new file mode 100644 index 0000000..f58da54 --- /dev/null +++ b/src/tier-check/checks/release.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { Octokit } from '@octokit/rest'; +import { checkStableRelease } from './release'; + +type Release = { + tag_name: string; + draft?: boolean; + prerelease?: boolean; +}; + +/** An Octokit stub that returns a fixed release list. */ +function octokitWith(releases: Release[]): Octokit { + return { + repos: { + listReleases: async () => ({ + data: releases.map((r) => ({ + draft: false, + prerelease: false, + ...r + })) + }) + } + } as unknown as Octokit; +} + +async function check(releases: Release[]) { + return checkStableRelease( + octokitWith(releases), + 'modelcontextprotocol', + 'sdk' + ); +} + +describe('checkStableRelease', () => { + it('accepts a bare semver tag', async () => { + expect(await check([{ tag_name: '1.2.3' }])).toMatchObject({ + status: 'pass', + version: '1.2.3', + is_stable: true + }); + }); + + it('accepts a v-prefixed tag', async () => { + expect(await check([{ tag_name: 'v1.2.3' }])).toMatchObject({ + status: 'pass', + version: '1.2.3', + is_stable: true + }); + }); + + // https://github.com/modelcontextprotocol/conformance/issues/425 + it('accepts a monorepo tag carrying a package-name prefix', async () => { + expect(await check([{ tag_name: 'rmcp-v3.0.1' }])).toMatchObject({ + status: 'pass', + version: '3.0.1', + is_stable: true + }); + }); + + it('accepts a monorepo tag whose package name contains a hyphen', async () => { + expect(await check([{ tag_name: 'rmcp-macros-v3.0.1' }])).toMatchObject({ + status: 'pass', + version: '3.0.1', + is_stable: true + }); + }); + + it('rejects a pre-1.0 release', async () => { + expect(await check([{ tag_name: 'rmcp-v0.9.0' }])).toMatchObject({ + status: 'fail', + version: '0.9.0', + is_stable: false + }); + }); + + it('rejects a prerelease named in the tag', async () => { + expect(await check([{ tag_name: 'rmcp-v3.0.1-alpha.1' }])).toMatchObject({ + status: 'fail', + version: '3.0.1-alpha.1', + is_stable: false, + is_prerelease: true + }); + }); + + it('rejects a release GitHub flags as a prerelease', async () => { + expect( + await check([{ tag_name: 'rmcp-v3.0.1', prerelease: true }]) + ).toMatchObject({ + status: 'fail', + is_stable: false, + is_prerelease: true + }); + }); + + it('skips drafts and scores the first published release', async () => { + expect( + await check([ + { tag_name: 'rmcp-v4.0.0', draft: true }, + { tag_name: 'rmcp-v3.0.1' } + ]) + ).toMatchObject({ status: 'pass', version: '3.0.1' }); + }); + + it('reports an unparseable tag unchanged rather than dropping it', async () => { + expect(await check([{ tag_name: 'nightly' }])).toMatchObject({ + status: 'fail', + version: 'nightly', + is_stable: false + }); + }); + + it('fails when the repo has no releases', async () => { + expect(await check([])).toMatchObject({ + status: 'fail', + version: null, + is_stable: false + }); + }); +}); diff --git a/src/tier-check/checks/release.ts b/src/tier-check/checks/release.ts index 3eb53f1..f47192a 100644 --- a/src/tier-check/checks/release.ts +++ b/src/tier-check/checks/release.ts @@ -33,7 +33,7 @@ export async function checkStableRelease( }; } - const version = latest.tag_name.replace(/^v/, ''); + const version = versionFromTag(latest.tag_name); const isPrerelease = latest.prerelease || /-(alpha|beta|rc|dev|preview|snapshot)/i.test(version); @@ -57,3 +57,16 @@ export async function checkStableRelease( }; } } + +/** + * Extract the semantic version from a release tag. + * + * Handles bare tags (`1.2.3`), `v`-prefixed tags (`v1.2.3`), and the + * package-prefixed tags monorepos publish (`rmcp-v3.0.1`, + * `rmcp-macros-v3.0.1`). Tags that carry no recognizable version are + * returned unchanged so they still surface in the check output. + */ +function versionFromTag(tag: string): string { + const match = tag.match(/(?:^|-)v?(\d+(?:\.\d+)*(?:[-+].*)?)$/); + return match ? match[1] : tag; +} diff --git a/src/tier-check/checks/spec-tracking.ts b/src/tier-check/checks/spec-tracking.ts index ce4c101..d255770 100644 --- a/src/tier-check/checks/spec-tracking.ts +++ b/src/tier-check/checks/spec-tracking.ts @@ -1,6 +1,10 @@ import { Octokit } from '@octokit/rest'; import { SpecTrackingResult } from '../types'; +// Lockstep SDKs publish in the hours before the spec release is tagged; +// a release up to this long before it counts as the release for that spec. +const LEAD_MS = 24 * 60 * 60 * 1000; + export async function checkSpecTracking( octokit: Octokit, owner: string, @@ -38,7 +42,7 @@ export async function checkSpecTracking( // Reverse so oldest-first, then find the FIRST SDK release after the spec const oldestFirst = [...nonDraftSdkReleases].reverse(); const firstSdkAfterSpec = oldestFirst.find( - (r) => new Date(r.published_at!) >= specDate + (r) => new Date(r.published_at!).getTime() >= specDate.getTime() - LEAD_MS ); if (!firstSdkAfterSpec) { diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index 6637136..2de317b 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -1,4 +1,5 @@ -import { execSync } from 'child_process'; +import { spawn } from 'child_process'; +import { resolve as resolvePath } from 'path'; import { mkdtempSync, readFileSync, existsSync, globSync } from 'fs'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; @@ -6,9 +7,33 @@ import { ConformanceResult } from '../types'; import { listScenarios, listActiveClientScenarios, + listScenariosForSpec, + listClientScenariosForSpec, getScenarioSpecVersions } from '../../scenarios'; -import { ConformanceCheck } from '../../types'; +import { + notScoredScenarios, + RequirementSet, + scenariosToRun, + scoredScenarios +} from '../../requirements'; +import { + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + ScenarioSpecTag, + SpecVersion +} from '../../types'; + +const NON_SCORING_TAGS: ScenarioSpecTag[] = [ + DRAFT_PROTOCOL_VERSION, + 'extension' +]; + +/** Whether a scenario counts toward tier scoring (has at least one date-versioned spec). */ +function isTierScoring(specVersions?: ScenarioSpecTag[]): boolean { + if (!specVersions || specVersions.length === 0) return true; // unknown = count it + return specVersions.some((v) => !NON_SCORING_TAGS.includes(v)); +} /** * Parse conformance results from an output directory. @@ -35,7 +60,7 @@ function parseOutputDir(outputDir: string): ConformanceResult { const checksFiles = globSync('**/checks.json', { cwd: outputDir }); for (const checksFile of checksFiles) { - const scenarioName = dirname(checksFile); + const scenarioName = dirname(checksFile).replace(/\\/g, '/'); const checksPath = join(outputDir, checksFile); try { @@ -97,7 +122,14 @@ function stripTimestamp(dirName: string): string { function reconcileWithExpected( result: ConformanceResult, expectedScenarios: string[], - resultPrefix?: string + resultPrefix?: string, + /** + * Names that count toward the pass rate. When a requirement set is in play it + * decides this directly, so extensions and post-release additions are run and + * reported but never scored. Undefined keeps the spec-version heuristic. + */ + scoredNames?: Set, + notScoredReasons?: Map ): ConformanceResult { const reportedNames = new Set( result.details.map((d) => { @@ -109,30 +141,57 @@ function reconcileWithExpected( }) ); - // Attach specVersion to existing detail entries + // Normalise to the canonical scenario name. Results live in per-run + // directories (`server-ping-`), and reporting that spelling means + // a reported name does not match the same scenario in `list` or in a + // requirement set. for (const detail of result.details) { let name = stripTimestamp(detail.scenario); if (resultPrefix) { name = name.replace(new RegExp(`^${resultPrefix}-`), ''); } + detail.scenario = name; detail.specVersions = getScenarioSpecVersions(name); + const reason = notScoredReasons?.get(name); + if (reason) detail.notScoredReason = reason; } for (const expected of expectedScenarios) { if (!reportedNames.has(expected)) { result.failed++; result.total++; + const reason = notScoredReasons?.get(expected); result.details.push({ scenario: expected, passed: false, checks_passed: 0, checks_failed: 0, - specVersions: getScenarioSpecVersions(expected) + specVersions: getScenarioSpecVersions(expected), + ...(reason ? { notScoredReason: reason } : {}) }); } } - result.pass_rate = result.total > 0 ? result.passed / result.total : 0; + // passed/failed/total describe the SCORED set, so they agree with pass_rate and + // with the report. Counting every scenario here made the object self- + // contradictory (passed 56, total 64, pass_rate 1) and invited 56/64 to be + // quoted as the conformance rate. Anything run without being scored is + // reported under not_scored instead. + const tierDetails = scoredNames + ? result.details.filter((d) => scoredNames.has(d.scenario)) + : result.details.filter((d) => isTierScoring(d.specVersions)); + const tierPassed = tierDetails.filter((d) => d.passed).length; + const tierTotal = tierDetails.length; + const unscored = result.details.filter((d) => !tierDetails.includes(d)); + + result.passed = tierPassed; + result.failed = tierTotal - tierPassed; + result.total = tierTotal; + result.not_scored = { + total: unscored.length, + failed: unscored.filter((d) => !d.passed).length + }; + result.pass_rate = tierTotal > 0 ? tierPassed / tierTotal : 0; result.status = result.pass_rate >= 1.0 ? 'pass' @@ -143,12 +202,134 @@ function reconcileWithExpected( return result; } +/** + * Run the conformance CLI as a child. A non-zero exit is normal when scenarios + * fail, so it is not itself an error; what matters is whether the child got far + * enough to write results. Returns the child's stderr when it did not. + */ +function unmeasured(error: string): ConformanceResult { + return { + status: 'fail', + pass_rate: 0, + passed: 0, + failed: 0, + total: 0, + details: [], + error + }; +} + +function runChild(args: string[], cwd?: string): Promise { + return new Promise((resolve) => { + // Async on purpose: a leg can run under a managed SDK server whose + // stdout/stderr the parent is draining. A synchronous exec would block the + // event loop for the whole leg, the server's pipes would fill, its log + // writes would block, and the server would freeze mid-leg — scored as + // conformance failures against the SDK. + const child = spawn(process.execPath, args, { + // Results are read from the -o directory, so the child's stdout is + // noise. Keep a bounded stderr tail for error reporting. + stdio: ['ignore', 'ignore', 'pipe'], + cwd + }); + let stderr = ''; + child.stderr.on('data', (d) => { + if (stderr.length < 64 * 1024) stderr += d.toString(); + }); + // Generous whole-leg budget: a slow SDK on CI-class hardware must not + // have a killed harness scored as conformance failures. + const timer = setTimeout(() => child.kill('SIGKILL'), 600_000); + child.on('error', (err) => { + clearTimeout(timer); + resolve(err.message); + }); + child.on('close', (code, signal) => { + clearTimeout(timer); + if (code === 0) resolve(undefined); + else if (signal) resolve(`conformance run terminated (${signal})`); + else { + const tail = stderr.trim(); + // Non-zero exit is normal when scenarios fail; what matters is whether + // the child got far enough to write results. Callers treat this as + // fatal only when the output directory is empty. + resolve(tail ? tail.split('\n').slice(-3).join(' ') : undefined); + } + }); + }); +} + /** * Run server conformance tests by shelling out to the conformance CLI. */ +/** Merge per-revision results so a single pass rate spans every revision claimed. */ +export function mergeByRevision( + parts: { revision: string; result: ConformanceResult }[] +): ConformanceResult { + // Single revision: same result, but details still carry their revision so + // the JSON shape does not depend on how many revisions were claimed. + if (parts.length === 1) { + const only = parts[0]; + return { + ...only.result, + details: only.result.details.map((d) => ({ + ...d, + revision: only.revision + })) + }; + } + + const errored = parts.find((p) => p.result.error); + if (errored) + return unmeasured(`${errored.revision}: ${errored.result.error}`); + + // A skipped leg never ran; folding it into the numbers would report a + // failure that never happened. All revisions skipped is a skipped leg. + // SOME revisions skipped means a claimed revision went unmeasured, which + // must surface through the error/exit machinery rather than merge into a + // clean pass that quietly covers fewer revisions than were claimed. + const skipped = parts.filter((p) => p.result.status === 'skipped'); + if (skipped.length === parts.length) { + return { + status: 'skipped', + pass_rate: 0, + passed: 0, + failed: 0, + total: 0, + details: [] + }; + } + if (skipped.length > 0) { + return unmeasured( + `${skipped.map((p) => p.revision).join(', ')}: leg not measured for this revision` + ); + } + + const details = parts.flatMap((p) => + p.result.details.map((d) => ({ ...d, revision: p.revision })) + ); + const scored = details.filter((d) => !d.notScoredReason); + const passed = scored.filter((d) => d.passed).length; + const unscored = details.filter((d) => d.notScoredReason); + const pass_rate = scored.length > 0 ? passed / scored.length : 0; + return { + status: pass_rate >= 1 ? 'pass' : pass_rate >= 0.8 ? 'partial' : 'fail', + pass_rate, + passed, + failed: scored.length - passed, + total: scored.length, + not_scored: { + total: unscored.length, + failed: unscored.filter((d) => !d.passed).length + }, + details + }; +} + export async function checkConformance(options: { serverUrl?: string; skip?: boolean; + specVersion?: SpecVersion; + requirements?: RequirementSet; }): Promise { if (options.skip || !options.serverUrl) { return { @@ -162,25 +343,48 @@ export async function checkConformance(options: { } const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-server-')); - - try { - execSync( - `node dist/index.js server --url ${options.serverUrl} -o ${outputDir}`, - { - cwd: process.cwd(), - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000 - } - ); - } catch { - // Non-zero exit is expected when tests fail — results are still in outputDir + const args = [ + resolvePath(process.argv[1]), + 'server', + '--url', + options.serverUrl, + '-o', + outputDir + ]; + if (options.requirements) { + args.push('--requirements', options.requirements.revision); + } else if (options.specVersion) { + args.push('--spec-version', options.specVersion); } - return reconcileWithExpected( - parseOutputDir(outputDir), - listActiveClientScenarios(), - 'server' - ); + const failure = await runChild(args); + + const parsedServer = parseOutputDir(outputDir); + if (failure && parsedServer.total === 0) return unmeasured(failure); + + if (options.requirements) { + return reconcileWithExpected( + parsedServer, + scenariosToRun(options.requirements, 'server'), + 'server', + new Set(scoredScenarios(options.requirements, 'server')), + new Map( + notScoredScenarios(options.requirements, 'server').map((e) => [ + e.scenario, + e.reason + ]) + ) + ); + } + + const activeScenarios = new Set(listActiveClientScenarios()); + const expectedScenarios = options.specVersion + ? listClientScenariosForSpec(options.specVersion).filter((s) => + activeScenarios.has(s) + ) + : [...activeScenarios]; + + return reconcileWithExpected(parsedServer, expectedScenarios, 'server'); } /** @@ -189,6 +393,16 @@ export async function checkConformance(options: { export async function checkClientConformance(options: { clientCmd?: string; skip?: boolean; + specVersion?: SpecVersion; + requirements?: RequirementSet; + /** + * Working directory for the client command. SDK CI keeps client commands + * relative to the SDK checkout (.venv/bin/python …, ./.conformance-client); + * spawning them from the conformance repo turns that into a silent 0%. + */ + cwd?: string; + /** Per-scenario client timeout in ms, forwarded to the client leg. */ + timeoutMs?: number; }): Promise { if (options.skip || !options.clientCmd) { return { @@ -202,19 +416,49 @@ export async function checkClientConformance(options: { } const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-client-')); - - try { - execSync( - `node dist/index.js client --command '${options.clientCmd}' --suite all -o ${outputDir}`, - { - cwd: process.cwd(), - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000 - } - ); - } catch { - // Non-zero exit is expected when tests fail — results are still in outputDir + const args = [ + resolvePath(process.argv[1]), + 'client', + '--command', + options.clientCmd, + '-o', + outputDir + ]; + if (options.requirements) { + args.push('--requirements', options.requirements.revision); + } else { + args.push('--suite', 'all'); + if (options.specVersion) { + args.push('--spec-version', options.specVersion); + } + } + if (options.timeoutMs) { + args.push('--timeout', String(options.timeoutMs)); } - return reconcileWithExpected(parseOutputDir(outputDir), listScenarios()); + const failure = await runChild(args, options.cwd); + + const parsedClient = parseOutputDir(outputDir); + if (failure && parsedClient.total === 0) return unmeasured(failure); + + if (options.requirements) { + return reconcileWithExpected( + parsedClient, + scenariosToRun(options.requirements, 'client'), + undefined, + new Set(scoredScenarios(options.requirements, 'client')), + new Map( + notScoredScenarios(options.requirements, 'client').map((e) => [ + e.scenario, + e.reason + ]) + ) + ); + } + + const expectedScenarios = options.specVersion + ? listScenariosForSpec(options.specVersion) + : listScenarios(); + + return reconcileWithExpected(parsedClient, expectedScenarios); } diff --git a/src/tier-check/index.ts b/src/tier-check/index.ts index 5416748..db1f4b1 100644 --- a/src/tier-check/index.ts +++ b/src/tier-check/index.ts @@ -2,7 +2,8 @@ import { Command } from 'commander'; import { Octokit } from '@octokit/rest'; import { checkConformance, - checkClientConformance + checkClientConformance, + mergeByRevision } from './checks/test-conformance-results'; import { checkLabels } from './checks/labels'; import { checkTriage } from './checks/triage'; @@ -13,6 +14,88 @@ import { checkSpecTracking } from './checks/spec-tracking'; import { computeTier } from './tier-logic'; import { formatJson, formatMarkdown, formatTerminal } from './output'; import { TierScorecard } from './types'; +import { resolveSpecVersion } from '../scenarios'; +import { + listRequirementRevisions, + loadRequirements, + RequirementSet +} from '../requirements'; +import { execShell, withManagedServer } from '../sdk-runner'; +import { lookupBuiltinConfig } from '../sdk-runner/known-sdks'; +import { resolveConfigForSpec, SdkConfig } from '../sdk-runner/config'; +import { ensureCheckout, parseSdkSpec } from '../sdk-runner/checkout'; +import { ConformanceResult } from './types'; +import path from 'path'; + +/** + * Run both conformance legs for each claimed revision with the SDK's own + * invocation for that revision, resolved from KNOWN_SDKS specOverrides. This is + * what makes multi-revision tiering correct for SDKs whose wire era is fixed + * per process (go-sdk starts a different server per revision) or per endpoint + * (csharp-sdk serves the stateless lifecycle at /stateless): one + * --conformance-server-url cannot describe them, but their config already does. + * Sequential on purpose — consecutive revisions reuse the same port. + */ +async function runSdkConformance( + dir: string, + base: SdkConfig, + requirements: RequirementSet[], + options: { skipBuild?: boolean; timeoutMs?: number } +): Promise<{ server: ConformanceResult; client: ConformanceResult }> { + const server: { revision: string; result: ConformanceResult }[] = []; + const client: { revision: string; result: ConformanceResult }[] = []; + const built = new Set(); + + for (const req of requirements) { + const cfg = resolveConfigForSpec(base, req.revision); + + if (cfg.build && !options.skipBuild && !built.has(cfg.build)) { + console.error(` [sdk] Building for ${req.revision}: ${cfg.build}`); + await execShell(cfg.build, dir); + built.add(cfg.build); + } + + if (cfg.server) { + const { command, url, readyTimeoutMs } = cfg.server; + const result = await withManagedServer( + command, + dir, + url, + readyTimeoutMs ?? 15000, + () => checkConformance({ serverUrl: url, requirements: req }) + ); + server.push({ revision: req.revision, result }); + } else { + server.push({ + revision: req.revision, + result: { + status: 'skipped', + pass_rate: 0, + passed: 0, + failed: 0, + total: 0, + details: [] + } + }); + } + + client.push({ + revision: req.revision, + result: await checkClientConformance({ + clientCmd: cfg.client?.command, + skip: !cfg.client, + requirements: req, + cwd: dir, + timeoutMs: options.timeoutMs + }) + }); + } + + return { + server: mergeByRevision(server), + client: mergeByRevision(client) + }; +} function parseRepo(repo: string): { owner: string; repo: string } { const parts = repo.split('/'); @@ -24,18 +107,36 @@ function parseRepo(repo: string): { owner: string; repo: string } { export function createTierCheckCommand(): Command { const tierCheck = new Command('tier-check') .description('Run SDK tier assessment checks against a GitHub repository') - .requiredOption( + .option( '--repo ', - 'GitHub repository (e.g., modelcontextprotocol/typescript-sdk)' + 'GitHub repository (e.g., modelcontextprotocol/typescript-sdk). Derived from --sdk when omitted' ) .option('--branch ', 'Branch to check') + .option( + '--sdk ', + 'Assess a known SDK end to end: clones/builds it and runs each revision with the invocation its config declares for that revision (see src/sdk-runner/known-sdks.ts)' + ) + .option( + '--sdk-path ', + 'Use an existing local SDK checkout with --sdk semantics (config resolved from the directory name unless --sdk names it)' + ) + .option('--skip-build', 'Skip the SDK build step (with --sdk/--sdk-path)') + .option( + '--cache-dir ', + 'Directory for cached SDK clones (with --sdk)', + '.sdk-under-test' + ) + .option( + '--timeout ', + 'Per-scenario client timeout, forwarded to the client leg' + ) .option( '--conformance-server-url ', - 'URL of the already-running conformance server' + 'URL of an already-running conformance server. Only correct when that one endpoint serves every claimed revision at its own wire; SDKs that vary the server per revision need --sdk' ) .option( '--client-cmd ', - 'Command to run the SDK conformance client (for client conformance tests)' + 'Command to run the SDK conformance client (with --conformance-server-url; must be invocable from this directory)' ) .option('--skip-conformance', 'Skip conformance tests') .option('--days ', 'Limit triage check to issues created in last N days') @@ -48,10 +149,135 @@ export function createTierCheckCommand(): Command { '--token ', 'GitHub token (defaults to GITHUB_TOKEN env var)' ) + .option( + '--spec-version ', + 'Only run conformance scenarios for this spec version' + ) + .option( + '--requirements ', + 'Score against the frozen requirement set for a spec revision (e.g. 2026-07-28), so the SDK is measured against the suite as it stood when that revision shipped' + ) .action(async (options) => { + const sdkMode = + options.sdk !== undefined || options.sdkPath !== undefined; + + if (sdkMode && (options.conformanceServerUrl || options.clientCmd)) { + console.error( + '--sdk resolves the server and client invocation per revision from the SDK config; it cannot be combined with --conformance-server-url or --client-cmd.' + ); + process.exit(1); + } + if (sdkMode && (options.specVersion || options.skipConformance)) { + console.error( + '--sdk runs the frozen requirement sets; it cannot be combined with --spec-version or --skip-conformance.' + ); + process.exit(1); + } + + // Resolve the SDK checkout and config up front so --repo/--branch can be + // derived from it. + let sdkDir: string | undefined; + let sdkConfig: SdkConfig | undefined; + if (sdkMode) { + const spec = options.sdk ? parseSdkSpec(options.sdk) : undefined; + const sdkName = + spec?.name ?? path.basename(path.resolve(options.sdkPath)); + sdkConfig = lookupBuiltinConfig(sdkName) ?? undefined; + if (!sdkConfig) { + console.error( + `'${sdkName}' is not a known SDK (see src/sdk-runner/known-sdks.ts). Use --conformance-server-url/--client-cmd to drive an unknown SDK directly.` + ); + process.exit(1); + } + // One ref, used for the checkout, the scorecard and the policy-file + // reads alike — computing them independently attributed verdicts to + // branches that never ran (an aliased entry like typescript-sdk-v1 + // tests v1.x but read policy files from main). + if (options.sdk && options.branch) { + console.error( + '--branch cannot be combined with --sdk: put the ref in the SDK spec instead (e.g. --sdk typescript-sdk@v1.x).' + ); + process.exit(1); + } + if (options.sdkPath) { + sdkDir = path.resolve(options.sdkPath); + if (!options.branch) { + // Report the branch the checkout is actually on. + try { + const { execSync } = await import('child_process'); + const head = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: sdkDir, + encoding: 'utf-8' + }).trim(); + if (head && head !== 'HEAD') options.branch = head; + } catch { + // not a git checkout; leave branch unset + } + } + } else { + const ref = spec!.ref ?? sdkConfig.defaultRef ?? 'main'; + sdkDir = await ensureCheckout( + { name: sdkConfig.repo ?? spec!.name, ref }, + options.cacheDir + ); + options.branch = ref; + } + if (!options.repo) { + options.repo = `modelcontextprotocol/${sdkConfig.repo ?? sdkName}`; + } + } + if (!options.repo) { + console.error( + '--repo is required (or pass --sdk to derive it from the SDK config).' + ); + process.exit(1); + } + const { owner, repo } = parseRepo(options.repo); let token = options.token || process.env.GITHUB_TOKEN; + const specVersion = options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; + + let requirements: RequirementSet[] | undefined; + if (options.requirements !== undefined) { + if (specVersion) { + console.error( + '--requirements cannot be combined with --spec-version: a requirement set already fixes which scenarios run.' + ); + process.exit(1); + } + try { + // Several revisions may be claimed at once. Tier 1 then means every + // one of them passes, because a scenario shared by two revisions has + // to work on both wires, and one run does not cover the other. + // Dedupe: '2026-07-28,2026-07-28' must not run and count twice. + requirements = [ + ...new Set( + String(options.requirements) + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + ) + ].map(loadRequirements); + if (requirements.length === 0) { + throw new Error('--requirements needs at least one revision'); + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + } else if (sdkMode) { + // Tiering means every shipped revision: a scenario shared by two + // revisions must pass on both wires, so default to all of them. + const revisions = listRequirementRevisions(); + console.error( + `No --requirements given; assessing every shipped revision: ${revisions.join(', ')}` + ); + requirements = revisions.map(loadRequirements); + } + if (!token) { // Try to get token from GitHub CLI try { @@ -74,59 +300,110 @@ export function createTierCheckCommand(): Command { const octokit = new Octokit({ auth: token }); const days = options.days ? parseInt(options.days, 10) : undefined; + const timeoutMs = options.timeout + ? parseInt(options.timeout, 10) + : undefined; console.error('Running tier assessment checks...\n'); - // Run all checks - const [ - conformance, - clientConformance, - labels, - triage, - p0, - release, - files, - specTracking - ] = await Promise.all([ - checkConformance({ + // Conformance strategy. SDK mode resolves the invocation per revision + // from the SDK's config and manages the server itself; URL mode drives an + // already-running server that must serve every claimed revision. + let serverConformancePromise: Promise; + let clientConformancePromise: Promise; + if (sdkMode) { + const sdkRun = runSdkConformance(sdkDir!, sdkConfig!, requirements!, { + skipBuild: options.skipBuild, + timeoutMs + }); + serverConformancePromise = sdkRun.then((r) => r.server); + clientConformancePromise = sdkRun.then((r) => r.client); + } else if (requirements) { + const reqs = requirements; + serverConformancePromise = Promise.all( + reqs.map((req) => + checkConformance({ + serverUrl: options.conformanceServerUrl, + skip: options.skipConformance, + requirements: req + }).then((result) => ({ revision: req.revision, result })) + ) + ).then(mergeByRevision); + clientConformancePromise = Promise.all( + reqs.map((req) => + checkClientConformance({ + clientCmd: options.clientCmd, + skip: options.skipConformance || !options.clientCmd, + requirements: req, + timeoutMs + }).then((result) => ({ revision: req.revision, result })) + ) + ).then(mergeByRevision); + } else { + serverConformancePromise = checkConformance({ serverUrl: options.conformanceServerUrl, - skip: options.skipConformance - }).then((r) => { - console.error(' ✓ Server Conformance'); - return r; - }), - checkClientConformance({ + skip: options.skipConformance, + specVersion + }); + clientConformancePromise = checkClientConformance({ clientCmd: options.clientCmd, - skip: options.skipConformance || !options.clientCmd - }).then((r) => { - console.error(' ✓ Client Conformance'); - return r; - }), - checkLabels(octokit, owner, repo).then((r) => { - console.error(' ✓ Labels'); - return r; - }), - checkTriage(octokit, owner, repo, days).then((r) => { - console.error(' \u2713 Triage'); - return r; - }), - checkP0Resolution(octokit, owner, repo).then((r) => { - console.error(' \u2713 P0 Resolution'); - return r; - }), - checkStableRelease(octokit, owner, repo).then((r) => { - console.error(' \u2713 Stable Release'); - return r; - }), - checkPolicySignals(octokit, owner, repo, options.branch).then((r) => { - console.error(' \u2713 Policy Signals'); - return r; - }), - checkSpecTracking(octokit, owner, repo).then((r) => { - console.error(' \u2713 Spec Tracking'); - return r; - }) - ]); + skip: options.skipConformance || !options.clientCmd, + specVersion, + timeoutMs + }); + } + + // Conformance first, to completion, so a failing GitHub API call can + // never interrupt a leg mid-run and orphan a managed server. The GitHub + // checks are seconds; nothing meaningful is lost by not overlapping. + let conformance: ConformanceResult; + let clientConformance: ConformanceResult; + try { + conformance = await serverConformancePromise; + console.error(' \u2713 Server Conformance'); + clientConformance = await clientConformancePromise; + console.error(' \u2713 Client Conformance'); + } catch (error) { + console.error( + `Conformance run failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } + + let labels, triage, p0, release, files, specTracking; + try { + [labels, triage, p0, release, files, specTracking] = await Promise.all([ + checkLabels(octokit, owner, repo).then((r) => { + console.error(' ✓ Labels'); + return r; + }), + checkTriage(octokit, owner, repo, days).then((r) => { + console.error(' \u2713 Triage'); + return r; + }), + checkP0Resolution(octokit, owner, repo).then((r) => { + console.error(' \u2713 P0 Resolution'); + return r; + }), + checkStableRelease(octokit, owner, repo).then((r) => { + console.error(' \u2713 Stable Release'); + return r; + }), + checkPolicySignals(octokit, owner, repo, options.branch).then((r) => { + console.error(' \u2713 Policy Signals'); + return r; + }), + checkSpecTracking(octokit, owner, repo).then((r) => { + console.error(' \u2713 Spec Tracking'); + return r; + }) + ]); + } catch (error) { + console.error( + `GitHub API check failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } const checks = { conformance, @@ -146,6 +423,7 @@ export function createTierCheckCommand(): Command { branch: options.branch || null, timestamp: new Date().toISOString(), version: release.version, + requirements_revisions: requirements?.map((r) => r.revision) ?? null, checks, implied_tier }; @@ -160,6 +438,20 @@ export function createTierCheckCommand(): Command { default: formatTerminal(scorecard); } + + // Exit code gates on the machine-checkable half — scored conformance + // failures, or a leg that could not be measured — but only under + // --requirements, where "failed" is measured against a frozen contract. + // Legacy mode scores against today's suite, which drifts: gating there + // would flip existing pipelines red the day a post-release scenario + // merges, the exact injustice the frozen sets exist to remove. Legacy + // stays report-only (exit 0), as it always was. + if (requirements) { + const conformanceBroken = [conformance, clientConformance].some( + (c) => c.error !== undefined || c.failed > 0 + ); + process.exitCode = conformanceBroken ? 1 : 0; + } }); // Subcommands for individual checks diff --git a/src/tier-check/output.ts b/src/tier-check/output.ts index d4a857f..95686c2 100644 --- a/src/tier-check/output.ts +++ b/src/tier-check/output.ts @@ -1,4 +1,5 @@ import { TierScorecard, CheckStatus, ConformanceResult } from './types'; +import { DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION } from '../types'; const COLORS = { RESET: '\x1b[0m', @@ -23,23 +24,28 @@ function statusIcon(status: CheckStatus): string { } } -const SPEC_VERSIONS = [ - '2025-03-26', - '2025-06-18', - '2025-11-25', - 'draft', - 'extension' -] as const; +const TIER_SPEC_VERSIONS = DATED_SPEC_VERSIONS; + +const INFO_SPEC_VERSIONS = [DRAFT_PROTOCOL_VERSION, 'extension'] as const; type Cell = { passed: number; total: number }; interface MatrixRow { cells: Map; - unique: Cell; + /** Unique scenario counts for tier-scoring versions only. */ + tierUnique: Cell; + /** Unique scenario counts for informational versions only. */ + infoUnique: Cell; } +const INFO_SET = new Set(INFO_SPEC_VERSIONS); + function newRow(): MatrixRow { - return { cells: new Map(), unique: { passed: 0, total: 0 } }; + return { + cells: new Map(), + tierUnique: { passed: 0, total: 0 }, + infoUnique: { passed: 0, total: 0 } + }; } interface ConformanceMatrix { @@ -58,29 +64,32 @@ function buildConformanceMatrix( clientAuth: newRow() }; - for (const d of server.details) { - matrix.server.unique.total++; - if (d.passed) matrix.server.unique.passed++; - for (const v of d.specVersions ?? ['unknown']) { - const cell = matrix.server.cells.get(v) ?? { passed: 0, total: 0 }; + function addToRow( + row: MatrixRow, + d: { passed: boolean; specVersions?: string[] } + ) { + const versions = d.specVersions ?? ['unknown']; + const isTierScoring = versions.some((v) => !INFO_SET.has(v)); + const bucket = isTierScoring ? row.tierUnique : row.infoUnique; + bucket.total++; + if (d.passed) bucket.passed++; + for (const v of versions) { + const cell = row.cells.get(v) ?? { passed: 0, total: 0 }; cell.total++; if (d.passed) cell.passed++; - matrix.server.cells.set(v, cell); + row.cells.set(v, cell); } } + for (const d of server.details) { + addToRow(matrix.server, d); + } + for (const d of client.details) { const row = d.scenario.startsWith('auth/') ? matrix.clientAuth : matrix.clientCore; - row.unique.total++; - if (d.passed) row.unique.passed++; - for (const v of d.specVersions ?? ['unknown']) { - const cell = row.cells.get(v) ?? { passed: 0, total: 0 }; - cell.total++; - if (d.passed) cell.passed++; - row.cells.set(v, cell); - } + addToRow(row, d); } return matrix; @@ -96,6 +105,87 @@ function formatRate(cell: Cell): string { return `${cell.passed}/${cell.total} (${Math.round((cell.passed / cell.total) * 100)}%)`; } +/** + * Under a requirement set the spec-version matrix is the wrong picture: the set + * already names exactly what is scored, so splitting it by applicability tag and + * filing part of it as "informational" would contradict the score. + */ +/** + * A run at one revision's wire and a run at another's are distinct + * measurements, so entries are never deduplicated across revisions — the + * counts here must match the JSON, and a scenario that fails on one wire but + * passes on the other must say which. The label carries the revision whenever + * one is recorded. + */ +function withRevision(d: { scenario: string; revision?: string }): string { + return d.revision ? `${d.scenario} [${d.revision}]` : d.scenario; +} + +function requirementsSummary(scorecard: TierScorecard): string[] { + const revisions = scorecard.requirements_revisions ?? []; + const rows: [string, ConformanceResult][] = [ + ['Server', scorecard.checks.conformance as ConformanceResult], + ['Client', scorecard.checks.client_conformance as ConformanceResult] + ]; + const lines = [ + '', + `Scored against the ${revisions.join(' and ')} requirement set${revisions.length > 1 ? 's' : ''}, each run at its own wire version.`, + '', + '| | Required | Passed | |', + '|---|---|---|---|' + ]; + for (const [label, result] of rows) { + if (result.error) { + lines.push(`| ${label} | — | — | not measured |`); + continue; + } + if (result.status === 'skipped') { + lines.push(`| ${label} | — | — | skipped |`); + continue; + } + lines.push( + `| ${label} | ${result.total} | ${result.passed} | ${Math.round(result.pass_rate * 100)}% |` + ); + } + + // A requirement set is small and every entry is named, so a bare count is not + // actionable: say which ones failed. + const failed = rows.flatMap(([label, result]) => + result.details + .filter((d) => !d.passed && !d.notScoredReason) + .map((d) => `${label.toLowerCase()}: ${withRevision(d)}`) + ); + if (failed.length > 0) { + lines.push(''); + lines.push(`_Failing (${failed.length}):_`); + lines.push(''); + failed.forEach((f) => lines.push(`- ${f}`)); + } + + // Run and reported, but deliberately outside the score. + const notScored = rows.flatMap(([label, result]) => + result.details + .filter((d) => d.notScoredReason) + .map((d) => ({ ...d, leg: label.toLowerCase() })) + ); + if (notScored.length > 0) { + const failing = notScored.filter((d) => !d.passed); + lines.push(''); + lines.push( + `_Not scored (${notScored.length} run, ${failing.length} failing). These do not affect the tier:_` + ); + lines.push(''); + lines.push('| Scenario | Leg | Result | Why not scored |'); + lines.push('|---|---|---|---|'); + for (const d of notScored) { + lines.push( + `| ${withRevision(d)} | ${d.leg} | ${d.passed ? 'pass' : 'fail'} | ${d.notScoredReason} |` + ); + } + } + return lines; +} + export function formatJson(scorecard: TierScorecard): string { return JSON.stringify(scorecard, null, 2); } @@ -109,39 +199,74 @@ export function formatMarkdown(scorecard: TierScorecard): string { lines.push(`**Repo**: ${scorecard.repo}`); if (scorecard.branch) lines.push(`**Branch**: ${scorecard.branch}`); if (scorecard.version) lines.push(`**Version**: ${scorecard.version}`); + if (scorecard.requirements_revisions) + lines.push( + `**Requirements**: ${scorecard.requirements_revisions.join(', ')} (frozen; each run at its own wire)` + ); lines.push(`**Timestamp**: ${scorecard.timestamp}`); lines.push(''); lines.push('## Check Results'); lines.push(''); - lines.push('| Check | Status | Detail |'); - lines.push('|-------|--------|--------|'); + // Conformance matrix const matrix = buildConformanceMatrix( c.conformance as ConformanceResult, c.client_conformance as ConformanceResult ); - lines.push(''); - lines.push(`| | ${SPEC_VERSIONS.join(' | ')} | All* |`); - lines.push(`|---|${SPEC_VERSIONS.map(() => '---|').join('')}---|`); + if (scorecard.requirements_revisions) { + lines.push(...requirementsSummary(scorecard)); + } else { + // Tier-scoring matrix + lines.push(''); + lines.push(`| | ${TIER_SPEC_VERSIONS.join(' | ')} | All* |`); + lines.push(`|---|${TIER_SPEC_VERSIONS.map(() => '---|').join('')}---|`); - const mdRows: [string, MatrixRow][] = [ - ['Server', matrix.server], - ['Client: Core', matrix.clientCore], - ['Client: Auth', matrix.clientAuth] - ]; + const mdRows: [string, MatrixRow][] = [ + ['Server', matrix.server], + ['Client: Core', matrix.clientCore], + ['Client: Auth', matrix.clientAuth] + ]; - for (const [label, row] of mdRows) { + for (const [label, row] of mdRows) { + lines.push( + `| ${label} | ${TIER_SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} | ${formatRate(row.tierUnique)} |` + ); + } + + lines.push(''); lines.push( - `| ${label} | ${SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} | ${formatRate(row.unique)} |` + '_* unique scenarios — a scenario may apply to multiple spec versions_' ); - } + // Informational matrix (draft/extension) + const hasInfoMd = mdRows.some(([, row]) => + INFO_SPEC_VERSIONS.some((v) => { + const cell = row.cells.get(v); + return cell && cell.total > 0; + }) + ); + if (hasInfoMd) { + lines.push(''); + lines.push('_Informational (not scored for tier):_'); + lines.push(''); + lines.push(`| | ${INFO_SPEC_VERSIONS.join(' | ')} |`); + lines.push(`|---|${INFO_SPEC_VERSIONS.map(() => '---|').join('')}`); + for (const [label, row] of mdRows) { + const hasData = INFO_SPEC_VERSIONS.some((v) => { + const cell = row.cells.get(v); + return cell && cell.total > 0; + }); + if (!hasData) continue; + lines.push( + `| ${label} | ${INFO_SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} |` + ); + } + } + } lines.push(''); - lines.push( - '_* unique scenarios — a scenario may apply to multiple spec versions_' - ); - lines.push(''); + lines.push('| Check | Status | Detail |'); + lines.push('|-------|--------|--------|'); lines.push( `| Labels | ${c.labels.status} | ${c.labels.present}/${c.labels.required} required labels${c.labels.missing.length > 0 ? ` (missing: ${c.labels.missing.join(', ')})` : ''} |` ); @@ -192,52 +317,142 @@ export function formatTerminal(scorecard: TierScorecard): void { console.log(`Repo: ${scorecard.repo}`); if (scorecard.branch) console.log(`Branch: ${scorecard.branch}`); if (scorecard.version) console.log(`Version: ${scorecard.version}`); + if (scorecard.requirements_revisions) + console.log( + `Requires: ${scorecard.requirements_revisions.join(', ')} (frozen, each at its own wire)` + ); console.log(`Timestamp: ${scorecard.timestamp}\n`); console.log(`${COLORS.BOLD}Conformance:${COLORS.RESET}\n`); - // Conformance matrix - const matrix = buildConformanceMatrix( - c.conformance as ConformanceResult, - c.client_conformance as ConformanceResult - ); - - const vw = 10; // column width for version cells - const lw = 14; // label column width - const tw = 16; // total column width - const rp = (s: string, w: number) => s.padStart(w); - const lp = (s: string, w: number) => s.padEnd(w); - - console.log( - ` ${COLORS.DIM}${lp('', lw + 2)} ${SPEC_VERSIONS.map((v) => rp(v, vw)).join(' ')} ${rp('All*', tw)}${COLORS.RESET}` - ); - - const rows: [string, MatrixRow, CheckStatus | null, boolean][] = [ - ['Server', matrix.server, c.conformance.status, true], - ['Client: Core', matrix.clientCore, null, false], - ['Client: Auth', matrix.clientAuth, null, false] - ]; - - for (const [label, row, status, bold] of rows) { - const icon = status ? statusIcon(status) + ' ' : ' '; - const b = bold ? COLORS.BOLD : ''; - const r = bold ? COLORS.RESET : ''; + if (scorecard.requirements_revisions) { console.log( - ` ${icon}${b}${lp(label, lw)}${r} ${SPEC_VERSIONS.map((v) => rp(formatCell(row.cells.get(v)), vw)).join(' ')} ${b}${rp(formatRate(row.unique), tw)}${r}` + ` ${COLORS.DIM}Scored against ${scorecard.requirements_revisions.join(' and ')}, each run at its own wire version.${COLORS.RESET}\n` + ); + const reqRows: [string, ConformanceResult][] = [ + ['Server', c.conformance as ConformanceResult], + ['Client', c.client_conformance as ConformanceResult] + ]; + for (const [label, result] of reqRows) { + if (result.status === 'skipped') { + console.log(` ${label.padEnd(8)} skipped`); + continue; + } + if (result.error) { + console.log( + ` ${label.padEnd(8)} ${COLORS.RED}not measured${COLORS.RESET}: ${result.error}` + ); + continue; + } + console.log( + ` ${label.padEnd(8)} ${result.passed}/${result.total} required scenarios (${Math.round(result.pass_rate * 100)}%)` + ); + } + const failedReq = reqRows.flatMap(([label, result]) => + result.details + .filter((d) => !d.passed && !d.notScoredReason) + .map((d) => `${label.toLowerCase()}: ${withRevision(d)}`) + ); + if (failedReq.length > 0) { + console.log( + `\n ${COLORS.RED}Failing (${failedReq.length}):${COLORS.RESET}` + ); + failedReq.forEach((f) => + console.log(` ${COLORS.RED}\u2717${COLORS.RESET} ${f}`) + ); + } + const notScoredReq = reqRows.flatMap(([, result]) => + result.details.filter((d) => d.notScoredReason) + ); + if (notScoredReq.length > 0) { + const failing = notScoredReq.filter((d) => !d.passed); + console.log( + `\n ${COLORS.DIM}Not scored (${notScoredReq.length} run, ${failing.length} failing, no effect on tier):${COLORS.RESET}` + ); + for (const d of notScoredReq) { + const mark = d.passed + ? `${COLORS.GREEN}\u2713${COLORS.RESET}` + : `${COLORS.DIM}\u2717${COLORS.RESET}`; + console.log( + ` ${mark} ${withRevision(d)} ${COLORS.DIM}(${d.notScoredReason})${COLORS.RESET}` + ); + } + } + console.log(''); + } else { + // Conformance matrix + const matrix = buildConformanceMatrix( + c.conformance as ConformanceResult, + c.client_conformance as ConformanceResult ); - } - // Client total line - const clientTotal: Cell = { - passed: matrix.clientCore.unique.passed + matrix.clientAuth.unique.passed, - total: matrix.clientCore.unique.total + matrix.clientAuth.unique.total - }; - console.log( - ` ${statusIcon(c.client_conformance.status)} ${COLORS.BOLD}${lp('Client Total', lw)}${COLORS.RESET} ${' '.repeat(SPEC_VERSIONS.length * (vw + 1) - 1)} ${COLORS.BOLD}${rp(formatRate(clientTotal), tw)}${COLORS.RESET}` - ); - console.log( - `\n ${COLORS.DIM}* unique scenarios — a scenario may apply to multiple spec versions${COLORS.RESET}` - ); + const vw = 10; // column width for version cells + const lw = 14; // label column width + const tw = 16; // total column width + const rp = (s: string, w: number) => s.padStart(w); + const lp = (s: string, w: number) => s.padEnd(w); + + // Tier-scoring matrix (date-versioned specs only) + console.log( + ` ${COLORS.DIM}${lp('', lw + 2)} ${TIER_SPEC_VERSIONS.map((v) => rp(v, vw)).join(' ')} ${rp('All*', tw)}${COLORS.RESET}` + ); + + const rows: [string, MatrixRow, CheckStatus | null, boolean][] = [ + ['Server', matrix.server, c.conformance.status, true], + ['Client: Core', matrix.clientCore, null, false], + ['Client: Auth', matrix.clientAuth, null, false] + ]; + + for (const [label, row, status, bold] of rows) { + const icon = status ? statusIcon(status) + ' ' : ' '; + const b = bold ? COLORS.BOLD : ''; + const r = bold ? COLORS.RESET : ''; + console.log( + ` ${icon}${b}${lp(label, lw)}${r} ${TIER_SPEC_VERSIONS.map((v) => rp(formatCell(row.cells.get(v)), vw)).join(' ')} ${b}${rp(formatRate(row.tierUnique), tw)}${r}` + ); + } + + // Client total line (tier-scoring only) + const clientTierTotal: Cell = { + passed: + matrix.clientCore.tierUnique.passed + + matrix.clientAuth.tierUnique.passed, + total: + matrix.clientCore.tierUnique.total + matrix.clientAuth.tierUnique.total + }; + console.log( + ` ${statusIcon(c.client_conformance.status)} ${COLORS.BOLD}${lp('Client Total', lw)}${COLORS.RESET} ${' '.repeat(TIER_SPEC_VERSIONS.length * (vw + 1) - 1)} ${COLORS.BOLD}${rp(formatRate(clientTierTotal), tw)}${COLORS.RESET}` + ); + console.log( + `\n ${COLORS.DIM}* unique scenarios — a scenario may apply to multiple spec versions${COLORS.RESET}` + ); + + // Informational matrix (draft/extension) — only if there are any + const hasInfo = rows.some(([, row]) => + INFO_SPEC_VERSIONS.some((v) => { + const cell = row.cells.get(v); + return cell && cell.total > 0; + }) + ); + if (hasInfo) { + console.log(`\n Informational (not scored for tier):\n`); + console.log( + ` ${COLORS.DIM}${lp('', lw + 2)} ${INFO_SPEC_VERSIONS.map((v) => rp(v, vw)).join(' ')}${COLORS.RESET}` + ); + for (const [label, row, , bold] of rows) { + const hasData = INFO_SPEC_VERSIONS.some((v) => { + const cell = row.cells.get(v); + return cell && cell.total > 0; + }); + if (!hasData) continue; + const b = bold ? COLORS.BOLD : ''; + const r = bold ? COLORS.RESET : ''; + console.log( + ` ${b}${lp(label, lw)}${r} ${INFO_SPEC_VERSIONS.map((v) => rp(formatCell(row.cells.get(v)), vw)).join(' ')}` + ); + } + } + } console.log(`\n${COLORS.BOLD}Repository Health:${COLORS.RESET}\n`); console.log( ` ${statusIcon(c.labels.status)} Labels ${c.labels.present}/${c.labels.required} required labels` diff --git a/src/tier-check/types.ts b/src/tier-check/types.ts index a9830f4..5016a26 100644 --- a/src/tier-check/types.ts +++ b/src/tier-check/types.ts @@ -1,4 +1,4 @@ -import type { SpecVersion } from '../types'; +import type { ScenarioSpecTag } from '../types'; export type CheckStatus = 'pass' | 'fail' | 'partial' | 'skipped'; @@ -12,12 +12,32 @@ export interface ConformanceResult extends CheckResult { passed: number; failed: number; total: number; + /** + * Scenarios run without being scored, kept out of passed/failed/total so those + * always agree with pass_rate. + */ + not_scored?: { total: number; failed: number }; + /** + * Set when the conformance run could not be performed at all, e.g. the child + * CLI rejected a requirement set naming a scenario this build lacks. Without + * this the empty result reconciles into "every scenario failed", which blames + * the implementation for a broken invocation. + */ + error?: string; details: Array<{ scenario: string; passed: boolean; checks_passed: number; checks_failed: number; - specVersions?: SpecVersion[]; + specVersions?: ScenarioSpecTag[]; + /** + * Why this scenario ran but did not count toward the pass rate. Set only + * under a requirement set, which decides scoring by name rather than by + * spec-version tag. + */ + notScoredReason?: string; + /** Which revision's run produced this, when several were scored. */ + revision?: string; }>; } @@ -71,6 +91,14 @@ export interface TierScorecard { branch: string | null; timestamp: string; version: string | null; + /** + * Spec revisions whose frozen requirement sets were scored, if any were used. + * Every listed revision must pass for Tier 1: a scenario shared by two + * revisions must work on both wires, and one run does not cover the other. + * When set, the pass rates count exactly the scenarios that revision + * required when it shipped, rather than everything the suite carries today. + */ + requirements_revisions: string[] | null; checks: { conformance: ConformanceResult; client_conformance: ConformanceResult; diff --git a/src/traceability/index.test.ts b/src/traceability/index.test.ts new file mode 100644 index 0000000..721365e --- /dev/null +++ b/src/traceability/index.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { computeTraceability, DeclaredSep } from './index'; + +const decl = ( + sep: number, + requirements: DeclaredSep['requirements'], + yaml = `src/seps/sep-${sep}.yaml`, + specUrl: string | null = `https://modelcontextprotocol.io/sep-${sep}` +): DeclaredSep => ({ sep, yaml, specUrl, requirements }); + +describe('computeTraceability', () => { + it('marks a declared check tested when its ID was emitted', () => { + const m = computeTraceability({ + declared: [decl(2164, [{ check: 'sep-2164-error-code', text: 'x' }])], + emitted: new Set(['sep-2164-error-code']) + }); + expect(m.seps['2164'].requirements[0]).toEqual({ + check: 'sep-2164-error-code', + text: 'x', + status: 'tested' + }); + expect(m.seps['2164'].summary.tested).toBe(1); + }); + + it('marks a declared check untested when its ID was not emitted', () => { + const m = computeTraceability({ + declared: [decl(2164, [{ check: 'sep-2164-missing' }])], + emitted: new Set() + }); + expect(m.seps['2164'].requirements[0].status).toBe('untested'); + expect(m.seps['2164'].summary.untested).toBe(1); + }); + + it('propagates text, url, and issue onto requirement rows', () => { + const m = computeTraceability({ + declared: [ + decl(2243, [ + { + check: 'sep-2243-x', + text: 'The client MUST do X', + url: 'https://spec/x#y', + issue: 'https://gh/1' + } + ]) + ], + emitted: new Set(['sep-2243-x']) + }); + expect(m.seps['2243'].requirements[0]).toEqual({ + check: 'sep-2243-x', + text: 'The client MUST do X', + url: 'https://spec/x#y', + issue: 'https://gh/1', + status: 'tested' + }); + }); + + it('collects excluded rows with reasons and issue links', () => { + const m = computeTraceability({ + declared: [ + decl(2243, [ + { + text: 'intermediary rule', + excluded: 'not tested', + issue: 'https://x/1' + } + ]) + ], + emitted: new Set() + }); + expect(m.seps['2243'].excluded).toEqual([ + { text: 'intermediary rule', reason: 'not tested', issue: 'https://x/1' } + ]); + expect(m.seps['2243'].requirements).toEqual([]); + }); + + it('lists rows with neither check nor excluded as unkeyed', () => { + const m = computeTraceability({ + declared: [decl(2243, [{ text: 'orphan row' }])], + emitted: new Set() + }); + expect(m.seps['2243'].unkeyed).toEqual([{ text: 'orphan row' }]); + expect(m.seps['2243'].summary.unkeyed).toBe(1); + }); + + it('reports emitted IDs with no yaml row as untracked', () => { + const m = computeTraceability({ + declared: [decl(2164, [{ check: 'sep-2164-error-code' }])], + emitted: new Set(['sep-2164-error-code', 'sep-2164-extra-check']) + }); + expect(m.seps['2164'].untracked).toEqual(['sep-2164-extra-check']); + }); + + it('includes SEPs with emitted IDs but no yaml (tests without traceability)', () => { + const m = computeTraceability({ + declared: [], + emitted: new Set(['sep-2207-offline-access-requested']) + }); + expect(m.seps['2207'].yaml).toBeNull(); + expect(m.seps['2207'].requirements).toEqual([]); + expect(m.seps['2207'].untracked).toEqual([ + 'sep-2207-offline-access-requested' + ]); + }); + + it('sorts SEP keys numerically and stamps schema/meaning/source', () => { + const m = computeTraceability({ + declared: [ + decl(2243, [{ check: 'sep-2243-a' }]), + decl(414, [{ check: 'sep-414-a' }]) + ], + emitted: new Set(), + source: 'typescript-sdk@abc123' + }); + expect(Object.keys(m.seps)).toEqual(['414', '2243']); + expect(m.schemaVersion).toBe(1); + expect(m.docs).toMatch(/^https?:\/\//); + expect(m.source).toBe('typescript-sdk@abc123'); + }); + + it('defaults source to null', () => { + const m = computeTraceability({ declared: [], emitted: new Set() }); + expect(m.source).toBeNull(); + }); +}); diff --git a/src/traceability/index.ts b/src/traceability/index.ts new file mode 100644 index 0000000..055076d --- /dev/null +++ b/src/traceability/index.ts @@ -0,0 +1,316 @@ +import { Command } from 'commander'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'; +import path from 'path'; +import { parse as parseYaml } from 'yaml'; +import { + TRACEABILITY_SCHEMA_VERSION, + TraceabilityManifest, + ExcludedRequirement, + RequirementTraceability, + SepTraceability, + UnkeyedRequirement +} from './types'; + +const SEPS_DIR = 'src/seps'; +const OUT_FILE = path.join(SEPS_DIR, 'traceability.json'); + +const DOCS = + 'https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest'; + +// A yaml requirement row (mirrors new-sep's RequirementRow). +interface RawRequirement { + text?: string; + check?: string; + excluded?: string; + issue?: string; + url?: string; +} +interface RawSepYaml { + sep?: number; + spec_url?: string; + requirements?: RawRequirement[]; +} + +export interface DeclaredSep { + sep: number; + yaml: string; + specUrl: string | null; + requirements: RawRequirement[]; +} + +const CHECK_ID_RE = /^sep-\d+-/; + +function sepOf(id: string): number | null { + const m = id.match(/^sep-(\d+)-/); + return m ? Number(m[1]) : null; +} + +/** + * Pure: join declared requirements against the emitted check-ID set into the + * manifest. A requirement is "tested" iff its check ID was emitted by the run. + * No filesystem access — fully testable. + */ +export function computeTraceability(args: { + declared: DeclaredSep[]; + emitted: Set; + source?: string | null; +}): TraceabilityManifest { + const { declared, emitted } = args; + + const emittedBySep = new Map>(); + for (const id of emitted) { + const sep = sepOf(id); + if (sep === null) continue; + let set = emittedBySep.get(sep); + if (!set) emittedBySep.set(sep, (set = new Set())); + set.add(id); + } + + const declaredBySep = new Map(); + for (const d of declared) declaredBySep.set(d.sep, d); + + const allSeps = [ + ...new Set([...declaredBySep.keys(), ...emittedBySep.keys()]) + ].sort((a, b) => a - b); + + const seps: Record = {}; + + for (const sep of allSeps) { + const d = declaredBySep.get(sep); + const emittedIds = emittedBySep.get(sep) ?? new Set(); + + const requirements: RequirementTraceability[] = []; + const excluded: ExcludedRequirement[] = []; + const unkeyed: UnkeyedRequirement[] = []; + const declaredCheckIds = new Set(); + + for (const r of d?.requirements ?? []) { + const check = r.check; + if (check) { + declaredCheckIds.add(check); + requirements.push({ + check, + status: emittedIds.has(check) ? 'tested' : 'untested', + ...(r.text ? { text: r.text } : {}), + ...(r.url ? { url: r.url } : {}), + ...(r.issue ? { issue: r.issue } : {}) + }); + } else if (r.excluded) { + excluded.push({ + text: r.text ?? '', + reason: r.excluded, + ...(r.issue ? { issue: r.issue } : {}) + }); + } else { + unkeyed.push({ text: r.text ?? '' }); + } + } + + // Untracked: emitted IDs not declared in any yaml row. + const untracked = [...emittedIds] + .filter((id) => !declaredCheckIds.has(id)) + .sort(); + + seps[String(sep)] = { + yaml: d?.yaml ?? null, + specUrl: d?.specUrl ?? null, + requirements, + excluded, + unkeyed, + untracked, + summary: { + tested: requirements.filter((r) => r.status === 'tested').length, + untested: requirements.filter((r) => r.status === 'untested').length, + excluded: excluded.length, + untracked: untracked.length, + unkeyed: unkeyed.length + } + }; + } + + return { + schemaVersion: TRACEABILITY_SCHEMA_VERSION, + docs: DOCS, + source: args.source ?? null, + seps + }; +} + +/** Serialize deterministically (sorted SEP keys, trailing newline). */ +export function serializeManifest(manifest: TraceabilityManifest): string { + return JSON.stringify(manifest, null, 2) + '\n'; +} + +// --- filesystem gathering (not unit-tested; thin IO wrappers) ------------- + +/** Recursively collect emitted sep-NNNN-* check IDs from checks.json files. */ +export function collectEmittedIds(resultsDir: string): Set { + const ids = new Set(); + let entries: string[]; + try { + entries = readdirSync(resultsDir, { recursive: true, encoding: 'utf-8' }); + } catch { + return ids; + } + for (const rel of entries) { + if (path.basename(rel) !== 'checks.json') continue; + try { + const arr = JSON.parse(readFileSync(path.join(resultsDir, rel), 'utf8')); + if (!Array.isArray(arr)) continue; + for (const c of arr) { + if (c && typeof c.id === 'string' && CHECK_ID_RE.test(c.id)) + ids.add(c.id); + } + } catch { + // skip unreadable/partial result files + } + } + return ids; +} + +export function gatherDeclared(sepsDir = SEPS_DIR): DeclaredSep[] { + const out: DeclaredSep[] = []; + const files = readdirSync(sepsDir) + .filter((f) => /^sep-\d+\.yaml$/.test(f)) + .sort(); + for (const f of files) { + const full = path.join(sepsDir, f); + const doc = (parseYaml(readFileSync(full, 'utf8')) ?? {}) as RawSepYaml; + const fileSep = Number(f.match(/^sep-(\d+)\.yaml$/)![1]); + if (!Number.isInteger(doc.sep)) { + console.warn(`WARN ${f}: missing/invalid \`sep:\`; skipping`); + continue; + } + if (doc.sep !== fileSep) { + console.warn( + `WARN ${f}: filename SEP ${fileSep} != doc.sep ${doc.sep}; using doc.sep` + ); + } + out.push({ + sep: doc.sep as number, + yaml: full, + specUrl: doc.spec_url ?? null, + requirements: doc.requirements ?? [] + }); + } + return out; +} + +/** Print per-SEP gaps to stderr. */ +function reportGaps(manifest: TraceabilityManifest): void { + for (const [sep, c] of Object.entries(manifest.seps)) { + const untested = c.requirements.filter((r) => r.status === 'untested'); + if (!untested.length && !c.summary.unkeyed && !c.summary.untracked) + continue; + + const bits: string[] = []; + if (untested.length) bits.push(`${untested.length} untested`); + if (c.summary.unkeyed) bits.push(`${c.summary.unkeyed} unkeyed`); + if (c.summary.untracked) bits.push(`${c.summary.untracked} untracked`); + console.error( + `sep-${sep}: ${bits.join(', ')}${c.yaml ? '' : ' (no yaml)'}` + ); + for (const r of untested) console.error(` untested: ${r.check}`); + for (const id of c.untracked) console.error(` untracked: ${id}`); + } +} + +const HELP_EPILOG = ` +"tested" means a scenario emitted the check ID when the conformance suite ran +against the reference SDK — NOT that any SDK passes it. "untested" means the +declared check ID was never emitted (a real gap, or a check that only fires +against a broken impl / a feature the reference SDK has not implemented). + +--results is required: point it at the output of a suite run against the +reference SDK. Produce one with the sdk runner (clones+builds+runs the SDK), +once per side into the same dir: + conformance sdk typescript-sdk@ --mode client --suite all -o + conformance sdk typescript-sdk@ --mode server --suite all --skip-build -o +Check IDs are collected from /**/checks.json. + +--source records what the run was against (e.g. "typescript-sdk@"). +--allow-empty writes even when no check IDs were collected (default: refuse). +--check exits 1 if the on-disk traceability.json differs from a fresh compute. +--strict exits 1 on any untested requirement (advisory for now).`; + +export function createTraceabilityCommand(): Command { + return new Command('traceability') + .description( + 'Generate src/seps/traceability.json: a manifest mapping declared SEP ' + + 'requirements to conformance scenarios that emit their check IDs' + ) + .addHelpText('after', HELP_EPILOG) + .requiredOption( + '--results ', + 'Results dir from a suite run against the reference SDK ' + + '(reads /**/checks.json)' + ) + .option( + '--source ', + 'What the run was against, recorded in the manifest (e.g. typescript-sdk@)' + ) + .option( + '--allow-empty', + 'Write even when zero check IDs were collected (default: refuse)' + ) + .option( + '--check', + 'Do not write; exit 1 if the on-disk traceability.json is stale' + ) + .option('--strict', 'Exit 1 if any declared requirement is untested') + .action((options) => { + if (!existsSync(options.results)) { + console.error(`results dir not found: ${options.results}`); + process.exit(1); + } + const declared = gatherDeclared(); + const emitted = collectEmittedIds(options.results); + + // Guard the footgun: an empty/wrong results dir would mark everything + // untested and silently clobber the manifest. + if (emitted.size === 0 && !options.allowEmpty) { + console.error( + `no sep-NNNN-* check IDs found under ${options.results} — did the ` + + `suite run write checks.json there? Pass --allow-empty to override.` + ); + process.exit(1); + } + + const manifest = computeTraceability({ + declared, + emitted, + source: options.source ?? null + }); + const serialized = serializeManifest(manifest); + const untestedTotal = Object.values(manifest.seps).reduce( + (n, c) => n + c.summary.untested, + 0 + ); + + if (options.check) { + let current = ''; + try { + current = readFileSync(OUT_FILE, 'utf8'); + } catch { + // missing file -> stale + } + if (current !== serialized) { + console.error( + `${OUT_FILE} is out of date. Regenerate with ` + + `\`npm run traceability -- --results ${options.results}\`, ` + + `review with \`git diff ${OUT_FILE}\`, and commit.` + ); + process.exit(1); + } + console.log(`${OUT_FILE} is up to date.`); + } else { + writeFileSync(OUT_FILE, serialized); + console.log( + `wrote ${OUT_FILE}: ${Object.keys(manifest.seps).length} SEP(s)` + ); + reportGaps(manifest); + } + + if (options.strict && untestedTotal > 0) process.exit(1); + }); +} diff --git a/src/traceability/types.ts b/src/traceability/types.ts new file mode 100644 index 0000000..8edc022 --- /dev/null +++ b/src/traceability/types.ts @@ -0,0 +1,79 @@ +/** + * Shared types for the SEP traceability manifest (src/seps/traceability.json). + * + * IMPORTANT scope note: this manifest records whether a *conformance scenario + * exists* for each declared SEP requirement. It says NOTHING about whether any + * particular SDK passes that scenario — per-SDK pass/fail lives in `tier-check`. + * + * Joining the two is a future goal, NOT possible today: tier-check reports at + * scenario granularity and does not currently expose per-check IDs, while this + * manifest carries check IDs but not scenario names. Wiring plan.mcp.io's two + * feeds together needs one side to add the missing column first. + */ + +export const TRACEABILITY_SCHEMA_VERSION = 1; + +/** Status of a single declared requirement (a yaml `check:` row). */ +export type CheckStatus = + /** A matching check ID was emitted when the conformance suite ran. */ + | 'tested' + /** Declared, but no matching check ID was emitted by any scenario run. */ + | 'untested'; + +export interface RequirementTraceability { + check: string; + status: CheckStatus; + /** The normative sentence from the yaml (for tracker display). */ + text?: string; + /** Per-requirement spec URL from the yaml, if finer than the SEP's specUrl. */ + url?: string; + /** Tracking issue from the yaml, if any. */ + issue?: string; +} + +export interface ExcludedRequirement { + text: string; + reason: string; + issue?: string; +} + +/** A yaml row with neither `check:` nor `excluded:` (an authoring gap). */ +export interface UnkeyedRequirement { + text: string; +} + +export interface SepTraceability { + /** Path to the traceability yaml, or null if scenarios exist but no yaml. */ + yaml: string | null; + /** Spec URL from the yaml's `spec_url`, or null. */ + specUrl: string | null; + requirements: RequirementTraceability[]; + excluded: ExcludedRequirement[]; + unkeyed: UnkeyedRequirement[]; + /** + * Check IDs emitted by the suite run but not declared in any yaml row. + * Usually scenario scaffolding (gates) or extra checks beyond the SEP. + */ + untracked: string[]; + summary: { + tested: number; + untested: number; + excluded: number; + untracked: number; + unkeyed: number; + }; +} + +export interface TraceabilityManifest { + schemaVersion: number; + /** Pointer to where this file's semantics are documented (not prose-in-data). */ + docs: string; + /** + * What the emitted set was collected against, e.g. "typescript-sdk@". + * Provenance for consumers; no wall-clock timestamp so an unchanged run + * produces an empty diff. null when not supplied. + */ + source: string | null; + /** Keyed by SEP number (as a string). */ + seps: Record; +} diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 0000000..6612196 --- /dev/null +++ b/src/types.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { LATEST_PROTOCOL_VERSION } from './spec-types/draft'; +import { DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION } from './types'; + +describe('DRAFT_PROTOCOL_VERSION', () => { + it('mirrors LATEST_PROTOCOL_VERSION from the vendored draft schema', () => { + // DRAFT_PROTOCOL_VERSION is the wire protocolVersion the harness asserts + // for draft-spec scenarios. It must match what the spec's draft schema + // declares, or SDKs implementing the draft cannot pass the draft suite. + expect(DRAFT_PROTOCOL_VERSION).toBe(LATEST_PROTOCOL_VERSION); + }); + + it('is distinct from every dated spec version', () => { + // Scenario applicability (introducedIn/removedIn) and --spec-version + // resolution rely on the draft identifier not colliding with a released + // version. + expect(DATED_SPEC_VERSIONS).not.toContain(DRAFT_PROTOCOL_VERSION); + }); +}); diff --git a/src/types.ts b/src/types.ts index c086e65..ebe75a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +import type { RunContext } from './connection'; +import type { ScenarioContext } from './mock-server'; +import type { AuthorizationServerOptions } from './schemas'; + export type CheckStatus = | 'SUCCESS' | 'FAILURE' @@ -17,18 +21,103 @@ export interface ConformanceCheck { status: CheckStatus; timestamp: string; specReferences?: SpecReference[]; + /** + * Optional spec-version range for this individual check. When set, runners + * drop the check for `--spec-version` values outside the range. + */ + source?: ScenarioSource; details?: Record; metadata?: Record; errorMessage?: string; logs?: string[]; } -export type SpecVersion = - | '2025-03-26' - | '2025-06-18' - | '2025-11-25' - | 'draft' - | 'extension'; +export const DATED_SPEC_VERSIONS = [ + '2025-03-26', + '2025-06-18', + '2025-11-25' +] as const; + +export type DatedSpecVersion = (typeof DATED_SPEC_VERSIONS)[number]; + +export const LATEST_SPEC_VERSION: DatedSpecVersion = '2025-11-25'; + +/** + * Wire `protocolVersion` for the in-progress spec. Mirrors + * `LATEST_PROTOCOL_VERSION` in the spec repo's `schema/draft/schema.ts`; + * bump when that constant changes. + */ +export const DRAFT_PROTOCOL_VERSION = '2026-07-28'; + +// Wire protocolVersion strings the mock server will negotiate on initialize. +export const NEGOTIABLE_PROTOCOL_VERSIONS: readonly string[] = [ + '2025-06-18', + LATEST_SPEC_VERSION, + DRAFT_PROTOCOL_VERSION +]; + +/** + * A spec revision the conformance suite can target via `--spec-version`. + * Always a wire `protocolVersion` string. The CLI also accepts `'draft'` as + * an alias for {@link DRAFT_PROTOCOL_VERSION}. + */ +export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; + +/** Spec versions in timeline order, dated revisions followed by the draft. */ +export const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [ + ...DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION +]; + +/** True when `v` names a known spec version (dated or draft). */ +export function isSpecVersion(v: unknown): v is SpecVersion { + return SPEC_VERSION_TIMELINE.includes(v as SpecVersion); +} + +/** + * True when `v` is at or after `threshold` on the spec timeline. Lets a check + * gate itself to the version that introduced its requirement (e.g. a draft-only + * requirement passes `DRAFT_PROTOCOL_VERSION` as the threshold). + */ +export function specVersionAtLeast( + v: SpecVersion, + threshold: SpecVersion +): boolean { + return ( + SPEC_VERSION_TIMELINE.indexOf(v) >= SPEC_VERSION_TIMELINE.indexOf(threshold) + ); +} + +// Scenarios may also be tagged 'extension' to mark them as off-timeline +// (selectable via --suite extensions, never via --spec-version). See #256. +export type ScenarioSpecTag = SpecVersion | 'extension'; + +/** + * Known protocol extensions that this suite has scenarios for. + * Values are SEP-2133 extension identifiers (the keys used in + * `capabilities.extensions`). + */ +export const EXTENSION_IDS = [ + 'io.modelcontextprotocol/oauth-client-credentials', + 'io.modelcontextprotocol/enterprise-managed-authorization', + 'io.modelcontextprotocol/auth/dpop', + 'io.modelcontextprotocol/auth/wif', + 'io.modelcontextprotocol/tasks', + 'io.modelcontextprotocol/skills' +] as const; +export type ExtensionId = (typeof EXTENSION_IDS)[number]; + +/** + * Where a scenario's requirement comes from. Either the dated spec timeline + * (`introducedIn`/`removedIn`) or a named protocol extension that lives + * outside the spec release cycle. Extensions never match `--spec-version`. + */ +export type ScenarioSource = + | { + introducedIn: DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; + removedIn?: DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; + } + | { extensionId: ExtensionId }; export interface ScenarioUrls { serverUrl: string; @@ -43,13 +132,13 @@ export interface ScenarioUrls { export interface Scenario { name: string; description: string; - specVersions: SpecVersion[]; + source: ScenarioSource; /** * If true, a non-zero client exit code is expected and will not cause the test to fail. * Use this for scenarios where the client is expected to error (e.g., rejecting invalid auth). */ allowClientError?: boolean; - start(): Promise; + start(ctx: ScenarioContext): Promise; stop(): Promise; getChecks(): ConformanceCheck[]; } @@ -57,6 +146,16 @@ export interface Scenario { export interface ClientScenario { name: string; description: string; - specVersions: SpecVersion[]; - run(serverUrl: string): Promise; + source: ScenarioSource; + run(ctx: RunContext): Promise; +} + +export interface ClientScenarioForAuthorizationServer { + name: string; + description: string; + source: ScenarioSource; + run( + options: AuthorizationServerOptions, + details: Record + ): Promise; } diff --git a/src/validation/vitest-hooks.ts b/src/validation/vitest-hooks.ts new file mode 100644 index 0000000..fd9cac1 --- /dev/null +++ b/src/validation/vitest-hooks.ts @@ -0,0 +1,23 @@ +// Vitest-wide guard: in self-tests both sides of the wire are harness-authored, so any +// wire-schema violation is a harness bug — fail the test loudly. Tests that intentionally +// send nonconformant traffic drain the recorder with `takeWireViolations()` before ending. + +import { afterEach, beforeEach } from 'vitest'; +import { formatWireViolation, takeWireViolations } from './wire-schema'; + +beforeEach(() => { + // Discard anything recorded between tests (e.g. by a mock server still + // answering a straggling request from the previous test). + takeWireViolations(); +}); + +afterEach(() => { + const { violations } = takeWireViolations(); + if (violations.length > 0) { + throw new Error( + 'Wire messages violated the spec JSON schema during this test ' + + '(intentional? drain with takeWireViolations()):\n ' + + violations.map(formatWireViolation).join('\n ') + ); + } +}); diff --git a/src/validation/wire-schema.test.ts b/src/validation/wire-schema.test.ts new file mode 100644 index 0000000..d351047 --- /dev/null +++ b/src/validation/wire-schema.test.ts @@ -0,0 +1,581 @@ +import { createServer, type Server } from 'http'; +import rawSchema2025_06_18 from '../spec-types/2025-06-18.schema.json'; +import rawSchema2025_11_25 from '../spec-types/2025-11-25.schema.json'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; +import { sendStatelessRequest } from '../connection/stateless'; +import { + resetWireValidation, + specDispatchMaps, + takeWireViolations, + validateWireMessage, + wireSchemaChecks, + wireSchemaErrors, + withWireRecorder +} from './wire-schema'; + +const META = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'c', version: '1' }, + 'io.modelcontextprotocol/clientCapabilities': { sampling: {} } +}; + +/** The exact hallucinated shape PR #376 fixed: an array instead of a + * ClientCapabilities object in error.data.requiredCapabilities. */ +const PR_376_INVALID_ERROR = { + jsonrpc: '2.0', + id: 1, + error: { + code: -32021, + message: 'Server requires the sampling capability', + data: { requiredCapabilities: ['sampling'] } + } +}; + +function listen( + handler: (body: string) => { status: number; body: unknown } +): Promise<{ url: string; server: Server }> { + return new Promise((resolve) => { + const server = createServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + const { status, body } = handler(raw); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }); + }); + server.listen(0, () => { + const addr = server.address() as { port: number }; + resolve({ url: `http://localhost:${addr.port}/mcp`, server }); + }); + }); +} + +describe('wireSchemaErrors', () => { + it('rejects the PR #376 hallucinated requiredCapabilities array under the draft schema', () => { + const errors = wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + PR_376_INVALID_ERROR + ); + expect(errors).toEqual([ + expect.stringContaining( + 'MissingRequiredClientCapabilityError/error/data/requiredCapabilities: must be object' + ) + ]); + }); + + it('accepts the spec-valid ClientCapabilities object shape', () => { + expect( + wireSchemaErrors(DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 1, + error: { + code: -32021, + message: 'Server requires the sampling capability', + data: { requiredCapabilities: { sampling: {} } } + } + }) + ).toEqual([]); + }); + + it('rejects messages that are not JSON-RPC at all', () => { + expect(wireSchemaErrors('2025-06-18', { hello: 'world' })).toEqual([ + expect.stringContaining('JSONRPCMessage') + ]); + }); + + it('validates typed requests by method const', () => { + expect( + wireSchemaErrors(DRAFT_PROTOCOL_VERSION, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { _meta: META } + }) + ).toEqual([ + expect.stringContaining( + "CallToolRequest/params: must have required property 'name'" + ) + ]); + }); + + it('validates results against the typed result definition for the request method', () => { + // Draft results must carry resultType / ttlMs / cacheScope. + const errors = wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { jsonrpc: '2.0', id: 3, result: { tools: [] } }, + 'tools/list' + ); + expect(errors.join('\n')).toContain('ListToolsResult'); + // The same result is valid at 2025-11-25, which has no such requirement. + expect( + wireSchemaErrors( + '2025-11-25', + { jsonrpc: '2.0', id: 3, result: { tools: [] } }, + 'tools/list' + ) + ).toEqual([]); + }); + + it('accepts extension result types through the generic result envelope', () => { + expect( + wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 3, + result: { + resultType: 'task', + taskId: 'task-1', + status: 'working' + } + }, + 'tools/call' + ) + ).toEqual([]); + }); + + it('names an unrecognised resultType when the result then fails its typed definition', () => { + const errors = wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { jsonrpc: '2.0', id: 3, result: { resultType: 'complet' } }, + 'tools/call' + ); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain('CallToolResult'); + expect(errors[0]).toContain("resultType 'complet' is not a core value"); + }); + + it('accepts an unrecognised resultType whose result satisfies the typed definition (open discriminator)', () => { + expect( + wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + { + jsonrpc: '2.0', + id: 3, + result: { resultType: 'x-acme/streamed', content: [] } + }, + 'tools/call' + ) + ).toEqual([]); + }); + + it('accepts a JSON-RPC batch under 2025-03-26 and reports per-element errors', () => { + expect( + wireSchemaErrors('2025-03-26', [ + { jsonrpc: '2.0', id: 1, method: 'ping' }, + { jsonrpc: '2.0', method: 'notifications/initialized' } + ]) + ).toEqual([]); + expect( + wireSchemaErrors('2025-03-26', [ + { jsonrpc: '2.0', id: 1, method: 'ping' }, + { nope: true } + ]) + ).toEqual([expect.stringContaining('[1] JSONRPCMessage')]); + }); +}); + +describe('wire-schema choke points and checks', () => { + it('records a failing wire-schema-valid check when the implementation sends the PR #376 shape', async () => { + const { url, server } = await listen(() => ({ + status: 400, + body: PR_376_INVALID_ERROR + })); + try { + resetWireValidation(); + await sendStatelessRequest(url, 'tools/list'); + const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION); + const wire = checks.find((c) => c.id === 'wire-schema-valid'); + expect(wire?.status).toBe('FAILURE'); + expect(wire?.errorMessage).toContain('requiredCapabilities'); + // The invalid message came from the (mock) implementation, not the + // harness — no harness-error check. + expect( + checks.find((c) => c.id === 'wire-schema-harness-error') + ).toBeUndefined(); + } finally { + server.close(); + } + }); + + it('records a hard harness error when the harness itself sends an invalid message', async () => { + const { url, server } = await listen(() => ({ + status: 200, + body: { + jsonrpc: '2.0', + id: 1, + result: { resultType: 'complete', content: [] } + } + })); + try { + resetWireValidation(); + // tools/call without params.name violates CallToolRequest. + await sendStatelessRequest(url, 'tools/call', { arguments: {} }); + const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION); + const harness = checks.find((c) => c.id === 'wire-schema-harness-error'); + expect(harness?.status).toBe('FAILURE'); + expect(harness?.errorMessage).toContain('HARNESS ERROR'); + expect(harness?.errorMessage).toContain("required property 'name'"); + } finally { + server.close(); + } + }); + + it('skips only the request direction for a call that opts out via skipValidation', async () => { + const { url, server } = await listen(() => ({ + status: 400, + body: PR_376_INVALID_ERROR + })); + try { + resetWireValidation(); + // The deliberately-malformed request is not validated, but the + // implementation's response still is — and here it is invalid. + await sendStatelessRequest( + url, + 'tools/call', + { arguments: {} }, + { skipValidation: true } + ); + const { violations, observed } = takeWireViolations(); + expect(observed).toBe(1); + expect(violations).toHaveLength(1); + expect(violations[0].origin).toBe('implementation'); + expect(violations[0].errors.join('\n')).toContain('requiredCapabilities'); + } finally { + server.close(); + } + }); + + it('tolerates a JSON-RPC 2.0 `id: null` error response only on skip-validated requests', async () => { + // JSON-RPC 2.0 requires id: null on error responses to requests that + // could not be processed; the MCP schema's RequestId forbids null. + const { url, server } = await listen(() => ({ + status: 400, + body: { + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Invalid Request' } + } + })); + try { + resetWireValidation(); + await sendStatelessRequest( + url, + 'tools/call', + { arguments: {} }, + { skipValidation: true } + ); + const skipped = takeWireViolations(); + expect(skipped.observed).toBe(1); + expect(skipped.violations).toEqual([]); + + // The carve-out is narrow: the same response on a normal (validated) + // call is still a violation. + await sendStatelessRequest(url, 'tools/list'); + const normal = takeWireViolations(); + expect( + normal.violations.filter((v) => v.origin === 'implementation') + ).toHaveLength(1); + } finally { + server.close(); + } + }); + + it('emits a passing wire-schema-valid check for clean traffic', async () => { + const { url, server } = await listen(() => ({ + status: 200, + body: { + jsonrpc: '2.0', + id: 1, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [] + } + } + })); + try { + resetWireValidation(); + await sendStatelessRequest(url, 'tools/list'); + const checks = wireSchemaChecks(DRAFT_PROTOCOL_VERSION); + expect(checks).toHaveLength(1); + expect(checks[0].id).toBe('wire-schema-valid'); + expect(checks[0].status).toBe('SUCCESS'); + expect(checks[0].details?.messagesValidated).toBe(2); + } finally { + server.close(); + } + }); + + it('emits no checks when no wire traffic was observed', () => { + resetWireValidation(); + expect(wireSchemaChecks(DRAFT_PROTOCOL_VERSION)).toEqual([]); + }); +}); + +describe('specDispatchMaps', () => { + // Pin the dispatch maps extracted from each vendored schema: if a schema sync + // restructures the `method`/`error.code` consts the extraction walks, validation + // silently degrades to envelope-only. These pins make that loud instead. + + const METHOD_DEFS_2025_03_26: Record = { + 'tools/call': 'CallToolRequest', + 'notifications/cancelled': 'CancelledNotification', + 'completion/complete': 'CompleteRequest', + 'sampling/createMessage': 'CreateMessageRequest', + 'prompts/get': 'GetPromptRequest', + initialize: 'InitializeRequest', + 'notifications/initialized': 'InitializedNotification', + 'prompts/list': 'ListPromptsRequest', + 'resources/templates/list': 'ListResourceTemplatesRequest', + 'resources/list': 'ListResourcesRequest', + 'roots/list': 'ListRootsRequest', + 'tools/list': 'ListToolsRequest', + 'notifications/message': 'LoggingMessageNotification', + ping: 'PingRequest', + 'notifications/progress': 'ProgressNotification', + 'notifications/prompts/list_changed': 'PromptListChangedNotification', + 'resources/read': 'ReadResourceRequest', + 'notifications/resources/list_changed': 'ResourceListChangedNotification', + 'notifications/resources/updated': 'ResourceUpdatedNotification', + 'notifications/roots/list_changed': 'RootsListChangedNotification', + 'logging/setLevel': 'SetLevelRequest', + 'resources/subscribe': 'SubscribeRequest', + 'notifications/tools/list_changed': 'ToolListChangedNotification', + 'resources/unsubscribe': 'UnsubscribeRequest' + }; + + // 2025-06-18 adds elicitation on top of 2025-03-26. + const METHOD_DEFS_2025_06_18: Record = { + ...METHOD_DEFS_2025_03_26, + 'elicitation/create': 'ElicitRequest' + }; + + // 2025-11-25 adds tasks (SEP-1686) and the elicitation-complete + // notification on top of 2025-06-18. + const METHOD_DEFS_2025_11_25: Record = { + ...METHOD_DEFS_2025_06_18, + 'tasks/cancel': 'CancelTaskRequest', + 'notifications/elicitation/complete': 'ElicitationCompleteNotification', + 'tasks/result': 'GetTaskPayloadRequest', + 'tasks/get': 'GetTaskRequest', + 'tasks/list': 'ListTasksRequest', + 'notifications/tasks/status': 'TaskStatusNotification' + }; + + // The draft (SEP-2575) drops the initialize/session lifecycle, ping, + // logging/setLevel, roots-changed, tasks, and resource subscriptions, and + // adds server/discover plus subscriptions/listen. + const METHOD_DEFS_DRAFT: Record = (() => { + const defs: Record = { + ...METHOD_DEFS_2025_11_25, + 'server/discover': 'DiscoverRequest', + 'subscriptions/listen': 'SubscriptionsListenRequest', + 'notifications/subscriptions/acknowledged': + 'SubscriptionsAcknowledgedNotification' + }; + for (const removed of [ + 'initialize', + 'notifications/initialized', + 'ping', + 'logging/setLevel', + 'notifications/roots/list_changed', + 'resources/subscribe', + 'resources/unsubscribe', + 'tasks/cancel', + 'notifications/elicitation/complete', + 'tasks/result', + 'tasks/get', + 'tasks/list', + 'notifications/tasks/status' + ]) { + delete defs[removed]; + } + return defs; + })(); + + it.each([ + ['2025-03-26', METHOD_DEFS_2025_03_26, {}], + ['2025-06-18', METHOD_DEFS_2025_06_18, {}], + [ + '2025-11-25', + METHOD_DEFS_2025_11_25, + { '-32042': 'URLElicitationRequiredError' } + ], + [ + DRAFT_PROTOCOL_VERSION, + METHOD_DEFS_DRAFT, + { + '-32020': 'HeaderMismatchError', + '-32021': 'MissingRequiredClientCapabilityError', + '-32022': 'UnsupportedProtocolVersionError' + } + ] + ] as const)( + 'extracts the expected methodDefs and errorDefs for %s', + (version, expectedMethods, expectedErrors) => { + const { methodDefs, errorDefs, resultDefs } = specDispatchMaps( + version as never + ); + expect(Object.fromEntries(methodDefs)).toEqual(expectedMethods); + expect( + Object.fromEntries( + [...errorDefs].map(([code, def]) => [String(code), def]) + ) + ).toEqual(expectedErrors); + // Every XxxRequest with a schema-defined XxxResult must be paired. + expect(resultDefs.get('tools/call')).toBe('CallToolResult'); + expect(resultDefs.get('tools/list')).toBe('ListToolsResult'); + } + ); + + it('pairs elicitation/create with ElicitResult from 2025-06-18 on (the #376 class)', () => { + expect( + specDispatchMaps('2025-03-26').resultDefs.get('elicitation/create') + ).toBeUndefined(); + expect( + specDispatchMaps('2025-06-18').resultDefs.get('elicitation/create') + ).toBe('ElicitResult'); + expect( + specDispatchMaps('2025-11-25').resultDefs.get('elicitation/create') + ).toBe('ElicitResult'); + expect( + specDispatchMaps(DRAFT_PROTOCOL_VERSION).resultDefs.get( + 'elicitation/create' + ) + ).toBe('ElicitResult'); + expect( + specDispatchMaps(DRAFT_PROTOCOL_VERSION).resultDefs.get('server/discover') + ).toBe('DiscoverResult'); + }); +}); + +describe('withWireRecorder', () => { + it('keeps concurrent scenario recorders isolated (suite runs)', async () => { + const { url, server } = await listen(() => ({ + status: 400, + body: PR_376_INVALID_ERROR + })); + try { + const scope = () => + withWireRecorder(async () => { + await sendStatelessRequest(url, 'tools/list'); + return takeWireViolations(); + }); + // With a shared recorder one scope would drain both violations and the + // other none; scoped recorders see exactly their own. + const [a, b] = await Promise.all([scope(), scope()]); + expect(a.violations).toHaveLength(1); + expect(b.violations).toHaveLength(1); + expect(takeWireViolations().observed).toBe(0); + } finally { + server.close(); + } + }); + + it('propagates the scoped recorder into handlers of servers created in-scope', async () => { + // Mirrors the mock-server flow: the server is created inside the scenario + // scope, but requests arrive from outside it (the client subprocess). + let resolveUrl!: (url: string) => void; + const urlReady = new Promise((r) => (resolveUrl = r)); + let resolveRequestDone!: () => void; + const requestDone = new Promise((r) => (resolveRequestDone = r)); + + const scopeResult = withWireRecorder(async () => { + const server = createServer((_req, res) => { + validateWireMessage(DRAFT_PROTOCOL_VERSION, PR_376_INVALID_ERROR, { + origin: 'implementation', + context: 'inbound message to in-scope server' + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + await new Promise((r) => { + server.listen(0, () => { + const addr = server.address() as { port: number }; + resolveUrl(`http://localhost:${addr.port}/`); + r(); + }); + }); + await requestDone; + const drained = takeWireViolations(); + server.close(); + return drained; + }); + + await fetch(await urlReady, { method: 'POST', body: '{}' }); + resolveRequestDone(); + + const drained = await scopeResult; + expect(drained.observed).toBe(1); + expect(drained.violations).toHaveLength(1); + expect(takeWireViolations().observed).toBe(0); + }); +}); + +describe('schema errata', () => { + const elicitWithScore = (score: Record) => ({ + jsonrpc: '2.0', + id: 1, + method: 'elicitation/create', + params: { + message: 'score please', + requestedSchema: { type: 'object', properties: { score } } + } + }); + + it('accepts fractional NumberSchema bounds/defaults at 2025-11-25 (spec generator bug, modelcontextprotocol#2710)', () => { + expect( + wireSchemaErrors( + '2025-11-25', + elicitWithScore({ + type: 'number', + minimum: 0.5, + maximum: 99.5, + default: 95.5 + }) + ) + ).toEqual([]); + // The erratum makes 2025-11-25 agree with the fixed draft schema. + expect( + wireSchemaErrors( + DRAFT_PROTOCOL_VERSION, + elicitWithScore({ + type: 'number', + minimum: 0.5, + maximum: 99.5, + default: 95.5 + }) + ) + ).toEqual([]); + }); + + it('accepts fractional NumberSchema bounds at 2025-06-18 (same generator bug, no default there)', () => { + expect( + wireSchemaErrors( + '2025-06-18', + elicitWithScore({ type: 'number', minimum: 0.5, maximum: 99.5 }) + ) + ).toEqual([]); + }); + + it('tripwire: delete applySchemaErrata once the vendored schemas are fixed (modelcontextprotocol#3139)', () => { + const numberSchema = (raw: Record, defsKey: string) => + ( + raw[defsKey] as Record< + string, + { properties: Record } + > + ).NumberSchema.properties; + expect( + numberSchema(rawSchema2025_11_25, '$defs').default.type, + 'the re-vendored 2025-11-25 schema no longer needs the erratum — delete its applySchemaErrata branch' + ).toBe('integer'); + expect( + numberSchema(rawSchema2025_06_18, 'definitions').minimum.type, + 'the re-vendored 2025-06-18 schema no longer needs the erratum — delete its applySchemaErrata branch' + ).toBe('integer'); + }); +}); diff --git a/src/validation/wire-schema.ts b/src/validation/wire-schema.ts new file mode 100644 index 0000000..1cdbed0 --- /dev/null +++ b/src/validation/wire-schema.ts @@ -0,0 +1,452 @@ +// Validates wire JSON-RPC messages against the vendored per-version spec schema +// (src/spec-types/*.schema.json): envelope, then typed defs via method/error.code consts and +// request→result pairs. Known gap: the client-auth scenarios' express mock is not instrumented. + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { Ajv, type ValidateFunction, type ErrorObject } from 'ajv'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { default as addFormats } from 'ajv-formats'; +import { + DRAFT_PROTOCOL_VERSION, + type ConformanceCheck, + type SpecVersion +} from '../types'; +import schema2025_03_26 from '../spec-types/2025-03-26.schema.json'; +import schema2025_06_18 from '../spec-types/2025-06-18.schema.json'; +import schema2025_11_25 from '../spec-types/2025-11-25.schema.json'; +import schemaDraft from '../spec-types/draft.schema.json'; + +export type WireOrigin = 'harness' | 'implementation'; + +export interface WireMessageInfo { + /** Who authored the message: the harness or the implementation-under-test. */ + origin: WireOrigin; + /** Human-readable location, e.g. `stateless request 'tools/call'`. */ + context: string; + /** For responses: the answered request's method, so the result is validated + * against its typed result definition. */ + requestMethod?: string; +} + +export interface WireSchemaViolation { + origin: WireOrigin; + specVersion: SpecVersion; + context: string; + errors: string[]; + message: unknown; +} + +const SCHEMAS: Record> = { + '2025-03-26': schema2025_03_26, + '2025-06-18': schema2025_06_18, + '2025-11-25': schema2025_11_25, + [DRAFT_PROTOCOL_VERSION]: schemaDraft +}; + +/** Spec-repo directory name for a version (the draft is pinned unversioned). */ +export function schemaDirFor(specVersion: SpecVersion): string { + return specVersion === DRAFT_PROTOCOL_VERSION ? 'draft' : specVersion; +} + +/** Union definitions that alias a single concrete type (and so carry a + * `method` const) but are not the canonical definition for that method. */ +const NON_CANONICAL_DEFS = new Set([ + 'ClientRequest', + 'ClientNotification', + 'ClientResult', + 'ClientMessage', + 'ServerRequest', + 'ServerNotification', + 'ServerResult', + 'ServerMessage' +]); + +/** resultType values defined by the core schema (SEP-2322). */ +const CORE_RESULT_TYPES: ReadonlySet = new Set([ + 'complete', + 'input_required' +]); +/** + * resultType values defined by known protocol extensions whose result schema + * is not vendored here yet; validated against the generic result envelope. + * - 'task': io.modelcontextprotocol/tasks CreateTaskResult (SEP-2663). + */ +const EXTENSION_RESULT_TYPES: ReadonlySet = new Set(['task']); + +interface CompiledSpec { + defsKey: '$defs' | 'definitions'; + defs: Record>; + /** JSON-RPC method → canonical request/notification definition name. */ + methodDefs: Map; + /** `error.code` const → typed error-response definition name. */ + errorDefs: Map; + /** JSON-RPC method → typed result definition name. */ + resultDefs: Map; + validatorFor(defName: string): ValidateFunction; +} + +const compiledSpecs = new Map(); + +/** Patch known generator bugs in released spec schema.json files at load time + * so validation matches the schema.ts source of truth; delete each branch (a + * test trips) once the dated schema is fixed upstream and re-vendored. */ +function applySchemaErrata( + specVersion: SpecVersion, + schema: Record +): Record { + if (specVersion !== '2025-11-25' && specVersion !== '2025-06-18') { + return schema; + } + // NumberSchema minimum/maximum (plus default at 2025-11-25) are generated + // as `integer`, contradicting schema.ts (`number`). Fixed for draft in + // modelcontextprotocol#2710; dated fixes proposed in modelcontextprotocol#3139. + const patched = structuredClone(schema); + const defs = (patched.$defs ?? patched.definitions) as Record< + string, + { properties?: Record } + >; + for (const prop of ['minimum', 'maximum', 'default']) { + const p = defs.NumberSchema?.properties?.[prop]; + if (p?.type === 'integer') p.type = 'number'; + } + return patched; +} + +function compileSpec(specVersion: SpecVersion): CompiledSpec { + let compiled = compiledSpecs.get(specVersion); + if (compiled) return compiled; + + const schema = applySchemaErrata(specVersion, SCHEMAS[specVersion]); + const is2020 = + typeof schema.$schema === 'string' && schema.$schema.includes('2020-12'); + // The spec schemas are not authored for ajv strict mode; validate them + // as-is rather than editing vendored files. + const options = { strict: false, allErrors: true } as const; + const ajv = is2020 ? new Ajv2020(options) : new Ajv(options); + addFormats(ajv); + // "byte" (base64) is an OpenAPI format ajv-formats doesn't ship; the spec + // uses it on blob contents. Accept any string rather than reimplementing it. + ajv.addFormat('byte', true); + + const schemaId = `mcp://${schemaDirFor(specVersion)}/schema.json`; + ajv.addSchema({ ...schema, $id: schemaId }); + + const defsKey: '$defs' | 'definitions' = + '$defs' in schema ? '$defs' : 'definitions'; + const defs = schema[defsKey] as Record>; + + const methodDefs = new Map(); + const errorDefs = new Map(); + const resultDefs = new Map(); + for (const [name, def] of Object.entries(defs)) { + if (NON_CANONICAL_DEFS.has(name)) continue; + const props = (def.properties ?? {}) as Record< + string, + Record + >; + const method = props.method?.const; + if (typeof method === 'string') { + methodDefs.set(method, name); + if (name.endsWith('Request')) { + const resultName = `${name.slice(0, -'Request'.length)}Result`; + if (resultName in defs) resultDefs.set(method, resultName); + } + } + const errorProp = props.error as + | { allOf?: { properties?: { code?: { const?: unknown } } }[] } + | undefined; + for (const part of errorProp?.allOf ?? []) { + const code = part.properties?.code?.const; + if (typeof code === 'number') errorDefs.set(code, name); + } + } + + const validators = new Map(); + compiled = { + defsKey, + defs, + methodDefs, + errorDefs, + resultDefs, + validatorFor(defName: string): ValidateFunction { + let v = validators.get(defName); + if (!v) { + v = ajv.compile({ $ref: `${schemaId}#/${defsKey}/${defName}` }); + validators.set(defName, v); + } + return v; + } + }; + compiledSpecs.set(specVersion, compiled); + return compiled; +} + +/** Dispatch maps extracted from a version's schema (method → request/notification def, + * `error.code` const → error def). Exposed so a unit test can pin them: if a schema sync + * restructures what the extraction walks, validation silently degrades to envelope-only. */ +export function specDispatchMaps(specVersion: SpecVersion): { + methodDefs: ReadonlyMap; + errorDefs: ReadonlyMap; + resultDefs: ReadonlyMap; +} { + const spec = compileSpec(specVersion); + return { + methodDefs: spec.methodDefs, + errorDefs: spec.errorDefs, + resultDefs: spec.resultDefs + }; +} + +function formatErrors( + defName: string, + errors: ErrorObject[] | null | undefined +): string[] { + const MAX = 6; + const formatted = (errors ?? []).map( + (e) => + `${defName}${e.instancePath || ''}: ${e.message ?? 'invalid'}` + + (e.keyword === 'const' || e.keyword === 'enum' + ? ` (${JSON.stringify(e.params)})` + : '') + ); + if (formatted.length > MAX) { + return [ + ...formatted.slice(0, MAX), + `... ${formatted.length - MAX} more error(s)` + ]; + } + return formatted.length > 0 ? formatted : [`${defName}: invalid`]; +} + +/** Validate a single JSON-RPC message against the given spec version's schema. + * Returns `[]` when the message is valid. Pure — does not touch the recorder. */ +export function wireSchemaErrors( + specVersion: SpecVersion, + message: unknown, + requestMethod?: string +): string[] { + const spec = compileSpec(specVersion); + const validateAgainst = (defName: string, value: unknown): string[] => { + const validate = spec.validatorFor(defName); + return validate(value) ? [] : formatErrors(defName, validate.errors); + }; + const firstDef = (...names: string[]): string => + names.find((n) => n in spec.defs) ?? names[names.length - 1]; + + if (Array.isArray(message)) { + // A batch: only legal where the envelope union admits arrays (2025-03-26). + // Limitation: `requestMethod` is forwarded to every element, so batched responses all + // validate against one result type — no caller sends batched requests today. + const elementErrors = message.flatMap((m, i) => + wireSchemaErrors(specVersion, m, requestMethod).map((e) => `[${i}] ${e}`) + ); + if (elementErrors.length > 0) return elementErrors; + if (validateAgainst('JSONRPCMessage', message).length > 0) { + return [ + 'JSONRPCMessage: batch arrays are not valid in this spec version' + ]; + } + return []; + } + + const msg = ( + typeof message === 'object' && message !== null ? message : {} + ) as Record; + + // Classify the message and validate against the most specific definition + // the schema has for it. The typed definitions include the envelope + // requirements (jsonrpc, id, method, ...), so they subsume the union check. + if (typeof msg.method === 'string') { + const defName = + spec.methodDefs.get(msg.method) ?? + ('id' in msg ? 'JSONRPCRequest' : 'JSONRPCNotification'); + return validateAgainst(defName, message); + } + + if (msg.error !== undefined && msg.error !== null) { + const code = (msg.error as Record).code; + const defName = + (typeof code === 'number' ? spec.errorDefs.get(code) : undefined) ?? + firstDef('JSONRPCErrorResponse', 'JSONRPCError'); + return validateAgainst(defName, message); + } + + if (msg.result !== undefined) { + // SEP-2322 (MRTR): any request may be answered with an InputRequiredResult + // instead of its method's result type; discriminate on resultType. Results + // introduced by a known extension (e.g. the tasks extension's + // CreateTaskResult, resultType "task") use the same open discriminator and + // are checked against the generic result envelope until their schema is + // vendored here. Any other resultType is validated as the method's own + // result (the discriminator is open, so it may be a private extension), + // and a failure names the unrecognised value so the cause is obvious. + const resultType = (msg.result as Record | null) + ?.resultType; + const inputRequired = + resultType === 'input_required' && 'InputRequiredResult' in spec.defs; + const extensionResult = + typeof resultType === 'string' && EXTENSION_RESULT_TYPES.has(resultType); + const unrecognisedResultType = + typeof resultType === 'string' && + !CORE_RESULT_TYPES.has(resultType) && + !extensionResult + ? resultType + : undefined; + let resultDefName: string | undefined; + if (inputRequired) { + resultDefName = 'InputRequiredResult'; + } else if (!extensionResult && requestMethod !== undefined) { + resultDefName = spec.resultDefs.get(requestMethod); + } + if (resultDefName) { + const hint = unrecognisedResultType + ? `; resultType '${unrecognisedResultType}' is not a core value or a known extension result (${[...EXTENSION_RESULT_TYPES].map((t) => `'${t}'`).join(', ')}), so it was validated as ${resultDefName}` + : ''; + const typed = validateAgainst(resultDefName, msg.result).map( + (e) => `${e} (result of '${requestMethod}')${hint}` + ); + if (typed.length > 0) return typed; + } + return validateAgainst( + firstDef('JSONRPCResultResponse', 'JSONRPCResponse'), + message + ); + } + + return [ + 'JSONRPCMessage: not a valid JSON-RPC request, notification, or response' + ]; +} + +// Recorder: choke points record every message; runners (and the vitest hook) +// drain the accumulated violations per scenario / test. Suite runs execute +// scenarios concurrently, so each scenario gets its own recorder via +// AsyncLocalStorage; code outside any scope falls back to the global recorder. + +interface RecorderState { + violations: WireSchemaViolation[]; + observed: number; +} + +const recorderStorage = new AsyncLocalStorage(); +const globalRecorder: RecorderState = { violations: [], observed: 0 }; + +function recorder(): RecorderState { + return recorderStorage.getStore() ?? globalRecorder; +} + +/** Run `fn` with its own isolated wire recorder. Async work started inside + * `fn` (mock servers, transport hooks) records into that recorder, so + * concurrent scenarios in a suite run cannot steal or wipe each other's + * violations. */ +export function withWireRecorder(fn: () => Promise): Promise { + return recorderStorage.run({ violations: [], observed: 0 }, fn); +} + +/** Validate a wire message and record any violation. Called by choke points. */ +export function validateWireMessage( + specVersion: SpecVersion, + message: unknown, + info: WireMessageInfo +): void { + const state = recorder(); + state.observed++; + const errors = wireSchemaErrors(specVersion, message, info.requestMethod); + if (errors.length > 0) { + state.violations.push({ + origin: info.origin, + specVersion, + context: info.context, + errors, + message + }); + } +} + +export function resetWireValidation(): void { + const state = recorder(); + state.violations = []; + state.observed = 0; +} + +/** Return everything recorded since the last reset, and reset. */ +export function takeWireViolations(): { + violations: WireSchemaViolation[]; + observed: number; +} { + const state = recorder(); + const result = { violations: state.violations, observed: state.observed }; + resetWireValidation(); + return result; +} + +function violationDetails(v: WireSchemaViolation): Record { + return { + origin: v.origin, + specVersion: v.specVersion, + context: v.context, + errors: v.errors, + message: v.message + }; +} + +export function formatWireViolation(v: WireSchemaViolation): string { + return ( + `[${v.origin}] ${v.context} (spec ${v.specVersion}): ` + + `${v.errors.join('; ')} — message: ${JSON.stringify(v.message)}` + ); +} + +/** Drain the recorder and synthesize the per-scenario conformance checks. Returns `[]` + * when no wire traffic was observed (e.g. a scenario asserting only on raw HTTP). */ +export function wireSchemaChecks(specVersion: SpecVersion): ConformanceCheck[] { + const { violations: all, observed: count } = takeWireViolations(); + if (count === 0 && all.length === 0) return []; + + const timestamp = new Date().toISOString(); + const specReferences = [ + { + id: 'MCP-Schema', + url: `https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/${schemaDirFor(specVersion)}/schema.json` + } + ]; + const checks: ConformanceCheck[] = []; + + const implViolations = all.filter((v) => v.origin === 'implementation'); + checks.push({ + id: 'wire-schema-valid', + name: 'WireSchemaValid', + description: + 'Every JSON-RPC message the implementation sent is valid per the spec JSON schema for the negotiated spec version', + status: implViolations.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences, + details: { + messagesValidated: count, + violations: implViolations.map(violationDetails) + }, + errorMessage: + implViolations.length > 0 + ? implViolations.map(formatWireViolation).join('\n') + : undefined + }); + + const harnessViolations = all.filter((v) => v.origin === 'harness'); + if (harnessViolations.length > 0) { + checks.push({ + id: 'wire-schema-harness-error', + name: 'WireSchemaHarnessError', + description: + 'HARNESS ERROR: the conformance harness itself sent a JSON-RPC message the spec JSON schema forbids. This is a bug in the harness or a scenario fixture, not in the implementation under test.', + status: 'FAILURE', + timestamp, + specReferences, + details: { violations: harnessViolations.map(violationDetails) }, + errorMessage: + 'HARNESS ERROR: ' + + harnessViolations.map(formatWireViolation).join('\n') + }); + } + + return checks; +} diff --git a/tsconfig.json b/tsconfig.json index 654a224..21ed5ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "ES2020", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "strict": true, "esModuleInterop": true, diff --git a/vitest.config.ts b/vitest.config.ts index 93242b5..946044f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,8 +4,18 @@ export default defineConfig({ test: { globals: true, environment: 'node', + // Fail any test during which a wire message violated the spec JSON schema. + setupFiles: ['./src/validation/vitest-hooks.ts'], include: ['**/*.test.ts'], - exclude: ['**/node_modules/**', 'dist'], + exclude: [ + '**/node_modules/**', + 'dist', + '.sdk-under-test', + // Local tooling workspaces (Claude worktrees, SDK checkouts) must never + // be collected as test files. + '**/.claude/**', + '**/.sdk-under-test/**' + ], // Run test files sequentially to avoid port conflicts fileParallelism: false, // Increase timeout for server tests in CI