From 595bf4dec8b2e185c2f6afc0730558a3e1209bed Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 14:59:00 +0000 Subject: [PATCH 001/226] bump beta version for release publishing (#21) * use trusted publishing * 0.1.1-beta.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89446a6..92b425c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.0", + "version": "0.1.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.0", + "version": "0.1.1-beta.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/package.json b/package.json index 076ed56..32f90d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.0", + "version": "0.1.1-beta.0", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4b30a171e765dfe523fb4ddcd605e6ddc65d6571 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 16:25:25 +0000 Subject: [PATCH 002/226] 0.1.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 92b425c..44544a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.1-beta.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.1-beta.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/package.json b/package.json index 32f90d8..60a222c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.1-beta.0", + "version": "0.1.1", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 569e86704c124e8163846e1a87631bc7888469cb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 16:32:24 +0000 Subject: [PATCH 003/226] bump npm version in release flow --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cea422..c645e1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,5 +47,7 @@ jobs: cache: npm registry-url: 'https://registry.npmjs.org' + # Ensure npm 11.5.1 or later for trusted publishing + - run: npm install -g npm@11.5.1 - run: npm ci - run: npm publish --provenance --access public From b164f5cdc873f6fe9e05259093a0d5a9b8b02bfd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 16:32:47 +0000 Subject: [PATCH 004/226] 0.1.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44544a1..585f294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/package.json b/package.json index 60a222c..6f7d2d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 113dfaa1c426486df0796ab1d447d416c1ebf891 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 16:52:32 +0000 Subject: [PATCH 005/226] go back to token auth --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c645e1f..99a8f04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: cache: npm registry-url: 'https://registry.npmjs.org' - # Ensure npm 11.5.1 or later for trusted publishing - - run: npm install -g npm@11.5.1 - run: npm ci - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From a359b25b74595d6ced30294c3769988ca876acf8 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 16:52:40 +0000 Subject: [PATCH 006/226] 0.1.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 585f294..9df6e9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/package.json b/package.json index 6f7d2d1..ea9d6f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 207eaed73e4450a587a9676e1ee3602fe4aa395d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 17:07:45 +0000 Subject: [PATCH 007/226] fix url in package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index ea9d6f9..b41986a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/conformance/issues", + "url": "https://github.com/modelcontextprotocol/conformance", "scripts": { "start": "tsx src/index.ts", "test": "jest", From bf9ce3abdc03f8f7798e4f2fc503b359caf7cb94 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 17:08:00 +0000 Subject: [PATCH 008/226] 0.1.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9df6e9e..7c23279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/package.json b/package.json index b41986a..00b580c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 05097028495ea780168c931b5f6e7d50ace2f268 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 17:31:49 +0000 Subject: [PATCH 009/226] fix repo url again --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 00b580c..21eddc8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/conformance/issues", - "url": "https://github.com/modelcontextprotocol/conformance", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/conformance.git" + }, "scripts": { "start": "tsx src/index.ts", "test": "jest", From 091a95d059310df501451456afcae9c1ff6ebf65 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 17:31:54 +0000 Subject: [PATCH 010/226] 0.1.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7c23279..8b68998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/package.json b/package.json index 21eddc8..4370bdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.4", + "version": "0.1.5", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 73af777bc2105302e8c2c5ad61a45c8b0a94bc08 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 11 Nov 2025 17:49:36 +0000 Subject: [PATCH 011/226] Add interactive mode with verbose checking (#23) * add back interactive mode, todo: print instructions for running w/ command and fix optional error message on command * add verbose checking --- src/index.ts | 21 +++++++++++++++++---- src/runner/client.ts | 11 +++++++++-- src/schemas.ts | 20 ++++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index d35c676..4afddff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import { printClientResults, runServerConformanceTest, printServerResults, - printServerSummary + printServerSummary, + runInteractiveMode } from './runner'; import { listScenarios, listClientScenarios } from './scenarios'; import { ConformanceCheck } from './types'; @@ -24,8 +25,10 @@ program // Client command - tests a client implementation against scenarios program .command('client') - .description('Run conformance tests against a client implementation') - .requiredOption('--command ', 'Command to run the client') + .description( + 'Run conformance tests against a client implementation or start interactive mode' + ) + .option('--command ', 'Command to run the client') .requiredOption('--scenario ', 'Scenario to test') .option('--timeout ', 'Timeout in milliseconds', '30000') .option('--verbose', 'Show verbose output') @@ -34,10 +37,20 @@ program // Validate options with Zod const validated = ClientOptionsSchema.parse(options); + // If no command provided, run in interactive mode + if (!validated.command) { + await runInteractiveMode( + validated.scenario, + validated.verbose ?? false + ); + process.exit(0); + } + + // Otherwise run conformance test const result = await runConformanceTest( validated.command, validated.scenario, - validated.timeout + validated.timeout ?? 30000 ); const { failed } = printClientResults( diff --git a/src/runner/client.ts b/src/runner/client.ts index 8f3ff1a..d7f8105 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -170,7 +170,10 @@ export function printClientResults( return { passed, failed, denominator }; } -export async function runInteractiveMode(scenarioName: string): Promise { +export async function runInteractiveMode( + scenarioName: string, + verbose: boolean = false +): Promise { await ensureResultsDir(); const resultDir = createResultDir(scenarioName); await fs.mkdir(resultDir, { recursive: true }); @@ -193,7 +196,11 @@ export async function runInteractiveMode(scenarioName: string): Promise { JSON.stringify(checks, null, 2) ); - console.log(`\nChecks:\n${JSON.stringify(checks, null, 2)}`); + if (verbose) { + console.log(`\nChecks:\n${JSON.stringify(checks, null, 2)}`); + } else { + console.log(`\nChecks:\n${formatPrettyChecks(checks)}`); + } console.log(`\nChecks saved to ${resultDir}/checks.json`); await scenario.stop(); diff --git a/src/schemas.ts b/src/schemas.ts index 5f22fc6..5e81c8c 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -3,7 +3,7 @@ import { getScenario, getClientScenario } from './scenarios'; // Client command options schema export const ClientOptionsSchema = z.object({ - command: z.string().min(1, 'Command cannot be empty'), + command: z.string().min(1, 'Command cannot be empty').optional(), scenario: z .string() .min(1, 'Scenario cannot be empty') @@ -21,7 +21,8 @@ export const ClientOptionsSchema = z.object({ .number() .positive('Timeout must be a positive number') .int('Timeout must be an integer') - ), + ) + .optional(), verbose: z.boolean().optional() }); @@ -42,3 +43,18 @@ export const ServerOptionsSchema = z.object({ }); export type ServerOptions = z.infer; + +// 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}'` + }) + ) +}); + +export type InteractiveOptions = z.infer; From e19918d968a4b35bd9092ccb414d99ea954d2f4e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 11 Nov 2025 19:34:47 +0000 Subject: [PATCH 012/226] Add conformance tests for SEP-1330 elicitation enums (#22) * Add SEP-1330 conformance test for elicitation enum schemas Adds server-side conformance test that validates servers properly request elicitation with SEP-1330 enum schema improvements: - Untitled single-select enum (type: string, enum: [...]) - Titled single-select enum (oneOf with const/title) - Legacy titled enum (enumNames for backward compatibility) - Untitled multi-select enum (type: array, items.enum) - Titled multi-select enum (items.anyOf with const/title) Test expects server to implement `test_elicitation_sep1330_enums` tool. * Add reference server implementation for SEP-1330 conformance test Adds test_elicitation_sep1330_enums tool to everything-server that requests elicitation with all 5 enum schema variants. Also updates SERVER_REQUIREMENTS.md with full specification. * Fix prettier formatting * only test active tests (#24) * refactor maps to pull names from the test * bring names back * separate active from pending * only test active --------- Co-authored-by: Paul Carleton --- SERVER_REQUIREMENTS.md | 91 +++++ .../servers/typescript/everything-server.ts | 99 +++++ src/index.ts | 10 +- src/scenarios/client/auth/basic-dcr.ts | 2 +- .../client/auth/basic-metadata-var1.ts | 2 +- src/scenarios/index.ts | 108 +++-- src/scenarios/server/all-scenarios.test.ts | 4 +- src/scenarios/server/elicitation-enums.ts | 375 ++++++++++++++++++ 8 files changed, 646 insertions(+), 45 deletions(-) create mode 100644 src/scenarios/server/elicitation-enums.ts diff --git a/SERVER_REQUIREMENTS.md b/SERVER_REQUIREMENTS.md index 1c8de1f..9898a22 100644 --- a/SERVER_REQUIREMENTS.md +++ b/SERVER_REQUIREMENTS.md @@ -463,6 +463,97 @@ If no progress token provided, just execute with delays. **Reference**: [SEP-1034](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034) +#### `test_elicitation_sep1330_enums` + +**Arguments**: None + +**Behavior**: Request user input from the client using `elicitation/create` with enum schema improvements (SEP-1330) + +**Elicitation Request**: + +```json +{ + "method": "elicitation/create", + "params": { + "message": "Please select options from the enum fields", + "requestedSchema": { + "type": "object", + "properties": { + "untitledSingle": { + "type": "string", + "description": "Select one option", + "enum": ["option1", "option2", "option3"] + }, + "titledSingle": { + "type": "string", + "description": "Select one option with titles", + "oneOf": [ + { "const": "value1", "title": "First Option" }, + { "const": "value2", "title": "Second Option" }, + { "const": "value3", "title": "Third Option" } + ] + }, + "legacyEnum": { + "type": "string", + "description": "Select one option (legacy)", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": ["Option One", "Option Two", "Option Three"] + }, + "untitledMulti": { + "type": "array", + "description": "Select multiple options", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "string", + "enum": ["option1", "option2", "option3"] + } + }, + "titledMulti": { + "type": "array", + "description": "Select multiple options with titles", + "minItems": 1, + "maxItems": 3, + "items": { + "anyOf": [ + { "const": "value1", "title": "First Choice" }, + { "const": "value2", "title": "Second Choice" }, + { "const": "value3", "title": "Third Choice" } + ] + } + } + }, + "required": [] + } + } +} +``` + +**Returns**: Text content with the elicitation result + +```json +{ + "content": [ + { + "type": "text", + "text": "Elicitation completed: action=, content={...}" + } + ] +} +``` + +**Implementation Note**: This tool tests SEP-1330 support for enum schema improvements including: + +- Untitled single-select enums (type: string with enum array) +- Titled single-select enums (using oneOf with const/title objects) +- Legacy titled enums (using deprecated enumNames array) +- Untitled multi-select enums (type: array with items.enum) +- Titled multi-select enums (using items.anyOf with const/title objects) + +If the client doesn't support elicitation (no `elicitation` capability), return an error. + +**Reference**: [SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) + --- ## 3. Resources Requirements diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 4507bca..be90caf 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -472,6 +472,105 @@ function createMcpServer() { } ); + // SEP-1330: Elicitation with enum schema improvements + mcpServer.registerTool( + 'test_elicitation_sep1330_enums', + { + description: + 'Tests elicitation with enum schema improvements per SEP-1330', + inputSchema: {} + }, + async () => { + try { + // Request user input with all 5 enum schema variants + const result = await mcpServer.server.request( + { + method: 'elicitation/create', + params: { + message: 'Please select options from the enum fields', + requestedSchema: { + type: 'object', + properties: { + // Untitled single-select enum (basic) + untitledSingle: { + type: 'string', + description: 'Select one option', + enum: ['option1', 'option2', 'option3'] + }, + // Titled single-select enum (using oneOf with const/title) + titledSingle: { + type: 'string', + description: 'Select one option with titles', + oneOf: [ + { const: 'value1', title: 'First Option' }, + { const: 'value2', title: 'Second Option' }, + { const: 'value3', title: 'Third Option' } + ] + }, + // Legacy titled enum (using enumNames - deprecated) + legacyEnum: { + type: 'string', + description: 'Select one option (legacy)', + enum: ['opt1', 'opt2', 'opt3'], + enumNames: ['Option One', 'Option Two', 'Option Three'] + }, + // Untitled multi-select enum + untitledMulti: { + type: 'array', + description: 'Select multiple options', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['option1', 'option2', 'option3'] + } + }, + // Titled multi-select enum (using anyOf with const/title) + titledMulti: { + type: 'array', + description: 'Select multiple options with titles', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'value1', title: 'First Choice' }, + { const: 'value2', title: 'Second Choice' }, + { const: 'value3', title: 'Third Choice' } + ] + } + } + }, + required: [] + } + } + }, + z + .object({ method: z.literal('elicitation/create') }) + .passthrough() as any + ); + + const elicitResult = result as any; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error.message}` + } + ] + }; + } + } + ); + // Dynamic tool (registered later via timer) // ===== RESOURCES ===== diff --git a/src/index.ts b/src/index.ts index 4afddff..4dd7f07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,11 @@ import { printServerSummary, runInteractiveMode } from './runner'; -import { listScenarios, listClientScenarios } from './scenarios'; +import { + listScenarios, + listClientScenarios, + listActiveClientScenarios +} from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; import packageJson from '../package.json'; @@ -97,8 +101,8 @@ program const { failed } = printServerResults(result.checks); process.exit(failed > 0 ? 1 : 0); } else { - // Run all scenarios - const scenarios = listClientScenarios(); + // Run all active scenarios + const scenarios = listActiveClientScenarios(); console.log( `Running ${scenarios.length} scenarios against ${validated.url}\n` ); diff --git a/src/scenarios/client/auth/basic-dcr.ts b/src/scenarios/client/auth/basic-dcr.ts index 12d35a2..77524cc 100644 --- a/src/scenarios/client/auth/basic-dcr.ts +++ b/src/scenarios/client/auth/basic-dcr.ts @@ -6,7 +6,7 @@ import { ServerLifecycle } from './helpers/serverLifecycle.js'; import { Request, Response } from 'express'; export class AuthBasicDCRScenario implements Scenario { - name = 'auth-basic-dcr'; + name = 'auth/basic-dcr'; description = 'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required'; private authServer = new ServerLifecycle(() => this.authBaseUrl); diff --git a/src/scenarios/client/auth/basic-metadata-var1.ts b/src/scenarios/client/auth/basic-metadata-var1.ts index 3e8043f..e8ae638 100644 --- a/src/scenarios/client/auth/basic-metadata-var1.ts +++ b/src/scenarios/client/auth/basic-metadata-var1.ts @@ -6,7 +6,7 @@ import { ServerLifecycle } from './helpers/serverLifecycle.js'; export class AuthBasicMetadataVar1Scenario implements Scenario { // TODO: name should match what we put in the scenario map - name = 'auth-basic-metadata-var1'; + name = 'auth/basic-metadata-var1'; description = 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; private authServer = new ServerLifecycle(() => this.authBaseUrl); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 8141b4d..13f4aa1 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -28,6 +28,7 @@ import { } from './server/tools.js'; import { ElicitationDefaultsScenario } from './server/elicitation-defaults.js'; +import { ElicitationEnumsScenario } from './server/elicitation-enums.js'; import { ResourcesListScenario, @@ -46,56 +47,83 @@ import { PromptsGetWithImageScenario } from './server/prompts.js'; -export const scenarios = new Map([ - ['initialize', new InitializeScenario()], - ['tools-call', new ToolsCallScenario()], - ['auth/basic-dcr', new AuthBasicDCRScenario()], - ['auth/basic-metadata-var1', new AuthBasicMetadataVar1Scenario()], - [ - 'elicitation-sep1034-client-defaults', - new ElicitationClientDefaultsScenario() - ] -]); +// Pending client scenarios (not yet fully tested/implemented) +const pendingClientScenariosList: ClientScenario[] = [ + // Elicitation scenarios (SEP-1330) + new ElicitationEnumsScenario() +]; -export const clientScenarios = new Map([ +// All client scenarios +const allClientScenariosList: ClientScenario[] = [ // Lifecycle scenarios - ['server-initialize', new ServerInitializeScenario()], + new ServerInitializeScenario(), // Utilities scenarios - ['logging-set-level', new LoggingSetLevelScenario()], - ['completion-complete', new CompletionCompleteScenario()], + new LoggingSetLevelScenario(), + new CompletionCompleteScenario(), // Tools scenarios - ['tools-list', new ToolsListScenario()], - ['tools-call-simple-text', new ToolsCallSimpleTextScenario()], - ['tools-call-image', new ToolsCallImageScenario()], - ['tools-call-audio', new ToolsCallAudioScenario()], - ['tools-call-embedded-resource', new ToolsCallEmbeddedResourceScenario()], - ['tools-call-mixed-content', new ToolsCallMultipleContentTypesScenario()], - ['tools-call-with-logging', new ToolsCallWithLoggingScenario()], - ['tools-call-error', new ToolsCallErrorScenario()], - ['tools-call-with-progress', new ToolsCallWithProgressScenario()], - ['tools-call-sampling', new ToolsCallSamplingScenario()], - ['tools-call-elicitation', new ToolsCallElicitationScenario()], + new ToolsListScenario(), + new ToolsCallSimpleTextScenario(), + new ToolsCallImageScenario(), + new ToolsCallAudioScenario(), + new ToolsCallEmbeddedResourceScenario(), + new ToolsCallMultipleContentTypesScenario(), + new ToolsCallWithLoggingScenario(), + new ToolsCallErrorScenario(), + new ToolsCallWithProgressScenario(), + new ToolsCallSamplingScenario(), + new ToolsCallElicitationScenario(), // Elicitation scenarios (SEP-1034) - ['elicitation-sep1034-defaults', new ElicitationDefaultsScenario()], + new ElicitationDefaultsScenario(), + + // Elicitation scenarios (SEP-1330) - pending + ...pendingClientScenariosList, // Resources scenarios - ['resources-list', new ResourcesListScenario()], - ['resources-read-text', new ResourcesReadTextScenario()], - ['resources-read-binary', new ResourcesReadBinaryScenario()], - ['resources-templates-read', new ResourcesTemplateReadScenario()], - ['resources-subscribe', new ResourcesSubscribeScenario()], - ['resources-unsubscribe', new ResourcesUnsubscribeScenario()], + new ResourcesListScenario(), + new ResourcesReadTextScenario(), + new ResourcesReadBinaryScenario(), + new ResourcesTemplateReadScenario(), + new ResourcesSubscribeScenario(), + new ResourcesUnsubscribeScenario(), // Prompts scenarios - ['prompts-list', new PromptsListScenario()], - ['prompts-get-simple', new PromptsGetSimpleScenario()], - ['prompts-get-with-args', new PromptsGetWithArgsScenario()], - ['prompts-get-embedded-resource', new PromptsGetEmbeddedResourceScenario()], - ['prompts-get-with-image', new PromptsGetWithImageScenario()] -]); + new PromptsListScenario(), + new PromptsGetSimpleScenario(), + new PromptsGetWithArgsScenario(), + new PromptsGetEmbeddedResourceScenario(), + new PromptsGetWithImageScenario() +]; + +// Active client scenarios (excludes pending) +const activeClientScenariosList: ClientScenario[] = + allClientScenariosList.filter( + (scenario) => + !pendingClientScenariosList.some( + (pending) => pending.name === scenario.name + ) + ); + +// Client scenarios map - built from list +export const clientScenarios = new Map( + allClientScenariosList.map((scenario) => [scenario.name, scenario]) +); + +// Scenario scenarios +const scenariosList: Scenario[] = [ + new InitializeScenario(), + new ToolsCallScenario(), + new AuthBasicDCRScenario(), + new AuthBasicMetadataVar1Scenario(), + new ElicitationClientDefaultsScenario() +]; + +// Scenarios map - built from list +export const scenarios = new Map( + scenariosList.map((scenario) => [scenario.name, scenario]) +); export function registerScenario(name: string, scenario: Scenario): void { scenarios.set(name, scenario); @@ -116,3 +144,7 @@ export function listScenarios(): string[] { export function listClientScenarios(): string[] { return Array.from(clientScenarios.keys()); } + +export function listActiveClientScenarios(): string[] { + return activeClientScenariosList.map((scenario) => scenario.name); +} diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 0ee5b7e..545bf4c 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -1,5 +1,5 @@ import { spawn, ChildProcess } from 'child_process'; -import { listClientScenarios, getClientScenario } from '../index.js'; +import { getClientScenario, listActiveClientScenarios } from '../index.js'; import path from 'path'; describe('Server Scenarios', () => { @@ -76,7 +76,7 @@ describe('Server Scenarios', () => { }); // Generate individual test for each scenario - const scenarios = listClientScenarios(); + const scenarios = listActiveClientScenarios(); for (const scenarioName of scenarios) { it(`${scenarioName}`, async () => { diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts new file mode 100644 index 0000000..93bb44c --- /dev/null +++ b/src/scenarios/server/elicitation-enums.ts @@ -0,0 +1,375 @@ +/** + * SEP-1330: Elicitation enum schema improvements test scenarios for MCP servers + */ + +import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { connectToServer } from './client-helper.js'; +import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +export class ElicitationEnumsScenario implements ClientScenario { + name = 'elicitation-sep1330-enums'; + description = 'Test elicitation with enum schema improvements (SEP-1330)'; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + const connection = await connectToServer(serverUrl); + + let capturedRequest: any = null; + connection.client.setRequestHandler( + ElicitRequestSchema, + async (request) => { + capturedRequest = request; + // Return mock data for all enum types + return { + action: 'accept', + content: { + untitledSingle: 'option1', + titledSingle: 'value1', + legacyEnum: 'opt1', + untitledMulti: ['option1', 'option2'], + titledMulti: ['value1', 'value2'] + } + }; + } + ); + + await connection.client.callTool({ + name: 'test_elicitation_sep1330_enums', + arguments: {} + }); + + // Validate that elicitation was requested + if (!capturedRequest) { + checks.push({ + id: 'elicitation-sep1330-general', + name: 'ElicitationSEP1330General', + description: 'Server requests elicitation with enum schemas', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'Server did not request elicitation from client', + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ] + }); + await connection.close(); + return checks; + } + + const schema = capturedRequest.params?.requestedSchema; + const properties = schema?.properties; + + // Validate untitled single-select enum + const untitledSingleErrors: string[] = []; + if (!properties?.untitledSingle) { + untitledSingleErrors.push( + 'Missing untitled single-select enum field "untitledSingle"' + ); + } else { + if (properties.untitledSingle.type !== 'string') { + untitledSingleErrors.push( + `Expected type "string", got "${properties.untitledSingle.type}"` + ); + } + if ( + !properties.untitledSingle.enum || + !Array.isArray(properties.untitledSingle.enum) + ) { + untitledSingleErrors.push('Missing or invalid enum array'); + } + if (properties.untitledSingle.oneOf) { + untitledSingleErrors.push( + 'Untitled enum should not have oneOf property' + ); + } + if (properties.untitledSingle.enumNames) { + untitledSingleErrors.push( + 'Untitled enum should not have enumNames property' + ); + } + } + + checks.push({ + id: 'elicitation-sep1330-untitled-single', + name: 'ElicitationSEP1330UntitledSingle', + description: 'Untitled single-select enum schema uses enum array', + status: untitledSingleErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + untitledSingleErrors.length > 0 + ? untitledSingleErrors.join('; ') + : undefined, + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ], + details: { + field: 'untitledSingle', + schema: properties?.untitledSingle + } + }); + + // Validate titled single-select enum (using oneOf with const/title) + const titledSingleErrors: string[] = []; + if (!properties?.titledSingle) { + titledSingleErrors.push( + 'Missing titled single-select enum field "titledSingle"' + ); + } else { + if (properties.titledSingle.type !== 'string') { + titledSingleErrors.push( + `Expected type "string", got "${properties.titledSingle.type}"` + ); + } + if ( + !properties.titledSingle.oneOf || + !Array.isArray(properties.titledSingle.oneOf) + ) { + titledSingleErrors.push( + 'Missing or invalid oneOf array for titled enum' + ); + } else { + // Validate oneOf structure has const/title pairs + const invalidItems = properties.titledSingle.oneOf.filter( + (item: any) => + typeof item.const !== 'string' || typeof item.title !== 'string' + ); + if (invalidItems.length > 0) { + titledSingleErrors.push( + `oneOf items must have "const" (string) and "title" (string) properties` + ); + } + } + if (properties.titledSingle.enum) { + titledSingleErrors.push( + 'Titled enum should use oneOf instead of enum array' + ); + } + } + + checks.push({ + id: 'elicitation-sep1330-titled-single', + name: 'ElicitationSEP1330TitledSingle', + description: + 'Titled single-select enum schema uses oneOf with const/title', + status: titledSingleErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + titledSingleErrors.length > 0 + ? titledSingleErrors.join('; ') + : undefined, + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ], + details: { + field: 'titledSingle', + schema: properties?.titledSingle + } + }); + + // Validate legacy titled enum (using enumNames - deprecated) + const legacyEnumErrors: string[] = []; + if (!properties?.legacyEnum) { + legacyEnumErrors.push('Missing legacy titled enum field "legacyEnum"'); + } else { + if (properties.legacyEnum.type !== 'string') { + legacyEnumErrors.push( + `Expected type "string", got "${properties.legacyEnum.type}"` + ); + } + if ( + !properties.legacyEnum.enum || + !Array.isArray(properties.legacyEnum.enum) + ) { + legacyEnumErrors.push('Missing or invalid enum array'); + } + if ( + !properties.legacyEnum.enumNames || + !Array.isArray(properties.legacyEnum.enumNames) + ) { + legacyEnumErrors.push( + 'Missing or invalid enumNames array for legacy titled enum' + ); + } else if ( + properties.legacyEnum.enum && + properties.legacyEnum.enumNames.length !== + properties.legacyEnum.enum.length + ) { + legacyEnumErrors.push( + `enumNames length (${properties.legacyEnum.enumNames.length}) must match enum length (${properties.legacyEnum.enum.length})` + ); + } + } + + checks.push({ + id: 'elicitation-sep1330-legacy-enumnames', + name: 'ElicitationSEP1330LegacyEnumNames', + description: 'Legacy titled enum schema uses enumNames (deprecated)', + status: legacyEnumErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + legacyEnumErrors.length > 0 ? legacyEnumErrors.join('; ') : undefined, + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ], + details: { + field: 'legacyEnum', + schema: properties?.legacyEnum + } + }); + + // Validate untitled multi-select enum + const untitledMultiErrors: string[] = []; + if (!properties?.untitledMulti) { + untitledMultiErrors.push( + 'Missing untitled multi-select enum field "untitledMulti"' + ); + } else { + if (properties.untitledMulti.type !== 'array') { + untitledMultiErrors.push( + `Expected type "array", got "${properties.untitledMulti.type}"` + ); + } + if (!properties.untitledMulti.items) { + untitledMultiErrors.push('Missing items property for array type'); + } else { + if (properties.untitledMulti.items.type !== 'string') { + untitledMultiErrors.push( + `Expected items.type "string", got "${properties.untitledMulti.items.type}"` + ); + } + if ( + !properties.untitledMulti.items.enum || + !Array.isArray(properties.untitledMulti.items.enum) + ) { + untitledMultiErrors.push('Missing or invalid items.enum array'); + } + if (properties.untitledMulti.items.anyOf) { + untitledMultiErrors.push( + 'Untitled multi-select should use items.enum, not items.anyOf' + ); + } + } + } + + checks.push({ + id: 'elicitation-sep1330-untitled-multi', + name: 'ElicitationSEP1330UntitledMulti', + description: + 'Untitled multi-select enum schema uses array with items.enum', + status: untitledMultiErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + untitledMultiErrors.length > 0 + ? untitledMultiErrors.join('; ') + : undefined, + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ], + details: { + field: 'untitledMulti', + schema: properties?.untitledMulti + } + }); + + // Validate titled multi-select enum (using items.anyOf with const/title) + const titledMultiErrors: string[] = []; + if (!properties?.titledMulti) { + titledMultiErrors.push( + 'Missing titled multi-select enum field "titledMulti"' + ); + } else { + if (properties.titledMulti.type !== 'array') { + titledMultiErrors.push( + `Expected type "array", got "${properties.titledMulti.type}"` + ); + } + if (!properties.titledMulti.items) { + titledMultiErrors.push('Missing items property for array type'); + } else { + if ( + !properties.titledMulti.items.anyOf || + !Array.isArray(properties.titledMulti.items.anyOf) + ) { + titledMultiErrors.push( + 'Missing or invalid items.anyOf array for titled multi-select' + ); + } else { + // Validate anyOf structure has const/title pairs + const invalidItems = properties.titledMulti.items.anyOf.filter( + (item: any) => + typeof item.const !== 'string' || typeof item.title !== 'string' + ); + if (invalidItems.length > 0) { + titledMultiErrors.push( + `items.anyOf entries must have "const" (string) and "title" (string) properties` + ); + } + } + if (properties.titledMulti.items.enum) { + titledMultiErrors.push( + 'Titled multi-select should use items.anyOf, not items.enum' + ); + } + } + } + + checks.push({ + id: 'elicitation-sep1330-titled-multi', + name: 'ElicitationSEP1330TitledMulti', + description: + 'Titled multi-select enum schema uses array with items.anyOf', + status: titledMultiErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + titledMultiErrors.length > 0 + ? titledMultiErrors.join('; ') + : undefined, + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ], + details: { + field: 'titledMulti', + schema: properties?.titledMulti + } + }); + + await connection.close(); + } catch (error) { + checks.push({ + id: 'elicitation-sep1330-general', + name: 'ElicitationSEP1330General', + description: 'Server requests elicitation with enum schemas', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'SEP-1330', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330' + } + ] + }); + } + + return checks; + } +} From eed3b222a17c63cd2b8b43700748c7e45d733516 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 12 Nov 2025 14:38:07 +0000 Subject: [PATCH 013/226] Typecheck and lint example client Don't do server yet, since it has its own package.json which breaks checking imports. --- examples/clients/typescript/auth-test-broken1.ts | 2 +- package.json | 4 ++-- tsconfig.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/clients/typescript/auth-test-broken1.ts b/examples/clients/typescript/auth-test-broken1.ts index 6fd6e5c..748cca0 100644 --- a/examples/clients/typescript/auth-test-broken1.ts +++ b/examples/clients/typescript/auth-test-broken1.ts @@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry.js'; import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import { diff --git a/package.json b/package.json index 4370bdf..1a10a54 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "start": "tsx src/index.ts", "test": "jest", "build": "tsdown src/index.ts --minify --clean --target node20", - "lint": "eslint src/ && prettier --check .", - "lint:fix": "eslint src/ --fix && prettier --write .", + "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", "check": "npm run typecheck && npm run lint", "typecheck": "tsgo --noEmit", diff --git a/tsconfig.json b/tsconfig.json index a31599e..5c8ae01 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,6 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "examples/clients/typescript/**/*"], "exclude": ["node_modules"] } From aeceefa9cf514edf49deb22b33324ffe7069b9d2 Mon Sep 17 00:00:00 2001 From: Matt <77928207+mattzcarey@users.noreply.github.com> Date: Thu, 13 Nov 2025 13:32:07 +0000 Subject: [PATCH 014/226] move to vitest (#18) * feat: move to vitest * fix: lock file * fix: tests running sequentially on same port * fix lock file --------- Co-authored-by: Paul Carleton --- jest.config.js | 8 - package-lock.json | 5067 +++++------------ package.json | 11 +- src/scenarios/client/auth/basic-dcr.test.ts | 1 - .../client/auth/basic-metadata-var1.test.ts | 1 - src/scenarios/server/all-scenarios.test.ts | 64 +- tsconfig.json | 3 +- vitest.config.ts | 15 + 8 files changed, 1607 insertions(+), 3563 deletions(-) delete mode 100644 jest.config.js create mode 100644 vitest.config.ts diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 5d88051..0000000 --- a/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -export default { - preset: 'ts-jest/presets/default-esm', - extensionsToTreatAsEsm: ['.ts'], - testEnvironment: 'node', - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1' - } -}; diff --git a/package-lock.json b/package-lock.json index 8b68998..c1df0dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,75 +20,17 @@ }, "devDependencies": { "@eslint/js": "^9.8.0", - "@jest/globals": "^29.7.0", "@types/express": "^5.0.3", - "@types/jest": "^29.5.8", + "@types/node": "^22.10.2", "@typescript/native-preview": "^7.0.0-dev.20251111.1", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", - "jest": "^29.7.0", "prettier": "3.6.2", - "ts-jest": "^29.1.1", "tsdown": "^0.16.2", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "typescript-eslint": "^8.0.0", + "vitest": "^2.1.8" } }, "node_modules/@babel/generator": { @@ -108,75 +50,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.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", @@ -197,30 +70,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", @@ -237,279 +86,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", @@ -524,13 +100,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, "node_modules/@emnapi/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", @@ -1227,415 +796,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1647,17 +807,6 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1687,9 +836,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.0.tgz", - "integrity": "sha512-YFBsXJMFCyI1zP98u7gezMFKX4lgu/XpoZJk7ufI6UlFKXLj2hAMUuRlQX/nrmIPOmhRrG6tw2OQ2ZA/ZlXYpQ==", + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.1.tgz", + "integrity": "sha512-UyLFcJLDvUuZbGnaQqXFT32CpPpGj7VS19roLut6gkQVhb439xUzYWbsUvdI3ZPL+2hnFosuugtYWE0Mcs1rmQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", @@ -1770,9 +919,9 @@ } }, "node_modules/@oxc-project/runtime": { - "version": "0.96.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.96.0.tgz", - "integrity": "sha512-34lh4o9CcSw09Hx6fKihPu85+m+4pmDlkXwJrLvN5nMq5JrcGhhihVM415zDqT8j8IixO1PYYdQZRN4SwQCncg==", + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz", + "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", "dev": true, "license": "MIT", "engines": { @@ -1780,9 +929,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.96.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.96.0.tgz", - "integrity": "sha512-r/xkmoXA0xEpU6UGtn18CNVjXH6erU3KCpCDbpLmbVxBFor1U9MqN5Z2uMmCHJuXjJzlnDR+hWY+yPoLo8oHDw==", + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz", + "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", "dev": true, "license": "MIT", "funding": { @@ -1803,9 +952,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.49.tgz", - "integrity": "sha512-xKQEOmqOet0vFHt/aqcoQGWvoDJhfSO8EBhuST0CDnxQRmnVzbI8keeeX62vi53ZyICKZxczyfx4A8dUY3dqKw==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", "cpu": [ "arm64" ], @@ -1820,9 +969,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.49.tgz", - "integrity": "sha512-kN0N/8m8HUYO13PqlIwxcXD7fu2E6GKu0J4iH7wUJw3T3QK+nvrc20rxtTZ0J6sA1sGCE8UYvvvnurDwMUp0dg==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", "cpu": [ "arm64" ], @@ -1837,9 +986,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.49.tgz", - "integrity": "sha512-29qmvsgY2A4ymfy8sQkFFOFc13m04SLUcYn1iil41gpkYrAspBLkvsOQMHPCs3rQCOImgweT4tFotqTAonwphQ==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", "cpu": [ "x64" ], @@ -1854,9 +1003,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.49.tgz", - "integrity": "sha512-fY+esrHjgt6+RAnDPuUk39RvFNmYhJekGyC6wr0HWXGTBed07Feap9BrYINSh6x5xFlNpOPs6tImKnV0zVDuWQ==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", + "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", "cpu": [ "x64" ], @@ -1871,9 +1020,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.49.tgz", - "integrity": "sha512-oQNAqB+XrRM2AZaSPyudQETsPhzCZqgPICQu80fJuNyBFYoz6nonNNZtm3BJ9uP+HZfUk9NfOn9vPoCNuk6gAw==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", + "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", "cpu": [ "arm" ], @@ -1888,9 +1037,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.49.tgz", - "integrity": "sha512-bJinAiuWUJvlBxPa8ZmRnWkmmAoUlSWtZT4pRkWi/QX3HlgHfUUbhF+d7aZLciai+iFfbiPqOwCL2tqNXXrUsA==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", "cpu": [ "arm64" ], @@ -1905,9 +1054,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.49.tgz", - "integrity": "sha512-gwueY8EJU7afq5tNwKSjYy5JqTR/0MNzZfv6s5dX+rMgeUpTNhwIToLO1F41TPYEa+6LRTXUWG23DO/ONPzUJA==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", "cpu": [ "arm64" ], @@ -1922,9 +1071,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.49.tgz", - "integrity": "sha512-VXYkjzzEZh5N5Ue1IEcBgL8RuJu5jWrIKmg8WY6hhCbnNJ1IOsObT4HFW+rE8ZaKNjoIXzImoiYi1UAkKiQRYA==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", + "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", "cpu": [ "x64" ], @@ -1939,9 +1088,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.49.tgz", - "integrity": "sha512-S5Yw6g/ftiW7MpNpnOM5vSIlDzGuohDY8y7VOI47+92HhO6WqsNfcMkDZXm3G5l6YIfUNStGBV86NWrzasp+sw==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", + "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", "cpu": [ "x64" ], @@ -1956,9 +1105,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.49.tgz", - "integrity": "sha512-bhRoMO2oP46W1UDd/PTrSdoIYfvLS2jiFAned0SOzOO0tcait9u+b9i8h4ZugbT2IK4qUXNezovbHJs7hKJOEQ==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", + "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", "cpu": [ "arm64" ], @@ -1973,9 +1122,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.49.tgz", - "integrity": "sha512-Owp6Y1RQ84UMOV8hrg5e1Fmu8Po1IUXWytAHUtPcc00+ty6Gr9g5GgLLw0oblu7QovBr4848ozvkMcEj3vDKgA==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", + "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", "cpu": [ "wasm32" ], @@ -1990,9 +1139,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.49.tgz", - "integrity": "sha512-dnheX8aXsN9P12uwPOW3TVvqSnQ1cfjKQlYgU2dTkrRpnco0kTGvqE1nEWybGukTyuPdzVvrGElgSGEJ7crcSQ==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", "cpu": [ "arm64" ], @@ -2007,9 +1156,9 @@ } }, "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.49.tgz", - "integrity": "sha512-Blt1aODXiAuEdZBqHYXGJwVFlonXKkVEJy5hhxOgnAVi/0mzFNWDxc8qVlxl7dpQjQdboW/wXdgMHpTDfomicg==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", "cpu": [ "ia32" ], @@ -2024,9 +1173,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.49.tgz", - "integrity": "sha512-sSu4qUlL/62QJrR3P+Bd+EblD8tUpnovUz65qow3PA7YxH+f5NFDbCJMR1m5b8zBuVZwZIHfzbuawz+Vl34/xg==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", + "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", "cpu": [ "x64" ], @@ -2041,38 +1190,319 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.49.tgz", - "integrity": "sha512-HLlu3Qn3ePmNCbfehwKWXQMzX/2rzcL6Jmpo+Dl3xnq46TGMyJAgO+IsS8ka7IDLeD3wcoOhjJwxTdIdbrFhGw==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", + "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", "dev": true, "license": "MIT" }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", @@ -2085,51 +1515,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2183,16 +1568,6 @@ "@types/send": "*" } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -2200,44 +1575,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2253,13 +1590,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", - "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { @@ -2309,42 +1646,18 @@ "@types/node": "*" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.34", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", - "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", - "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/type-utils": "8.46.3", - "@typescript-eslint/utils": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2358,7 +1671,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.3", + "@typescript-eslint/parser": "^8.46.4", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2374,16 +1687,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz", - "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4" }, "engines": { @@ -2399,14 +1713,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz", - "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.3", - "@typescript-eslint/types": "^8.46.3", + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", "debug": "^4.3.4" }, "engines": { @@ -2421,14 +1735,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz", - "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3" + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2439,9 +1753,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz", - "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", "dev": true, "license": "MIT", "engines": { @@ -2456,15 +1770,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz", - "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2481,9 +1795,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz", - "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", "dev": true, "license": "MIT", "engines": { @@ -2495,16 +1809,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz", - "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.3", - "@typescript-eslint/tsconfig-utils": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/visitor-keys": "8.46.3", + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2549,30 +1863,17 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz", - "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.3", - "@typescript-eslint/types": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3" + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2587,13 +1888,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz", - "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.3", + "@typescript-eslint/types": "8.46.4", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2605,28 +1906,29 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-IWafo9qVR7sEl/59AYoy7XTkC+rH62QaIKp7PuMYYcNwqjNlFKszGeJtXadqBpyY61lVhtb3xoZ6otsNotAgMg==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-gxe+j1h5DSnP/rE2+4BGSzfKRg2RdmMtHlPX+oH1iLGOHTWLQ5IVtS7hW2IlGJY2Y1dXXDARh9wRYzePPXyh8g==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251111.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251111.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20251111.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251111.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20251111.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251111.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20251111.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251113.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251113.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20251113.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251113.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20251113.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251113.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20251113.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-swE3/8vfsWGp+TNtmmW+amKd1V1cQrwbLSA2MFTiXZt3HUJPnLR8/aaWTkkrMCoLNSJd+KZV76X9Cw6QLAzdGQ==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-ArzjtsMWWUSRnqv/C7p+Lv7SJbL9EDGBPDgBvyI+xIcTqyD9WW2sMStjc1jTwWsimmQXzQQ8jZYpf+xSGj5boQ==", "cpu": [ "arm64" ], @@ -2638,9 +1940,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-vyToe0VYi+Trd9jNyrFersj0ZDtww/IjyoM8oOQN88K6AZEqxkxSxJC8SovCWqmPhfVhb5k/pnaqBE47hc6GLQ==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-airwguNehqwQhr6LjDcJ0LXfBv2GaOJkpj6fzxMK9gWlrx/tVwvRFoazUesdOnR1DM6zSE4gy+2UROxGeumOSw==", "cpu": [ "x64" ], @@ -2652,9 +1954,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-8WMqKSp3Yq5saVL//BCocUgFnTznAaNajLrFbTOPtfwtlpIUddXM3Imb614NgPb6GX72C44WDB8oNgO7KEBJsQ==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-lTYX9vskfbq3kq2+vpqu2fwJP68iZ4OuWHuGLMzS/AlQ1vzs535su/s6xhL8UuyRP+n10A9z2xHq7UJSOx8u1w==", "cpu": [ "arm" ], @@ -2666,9 +1968,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-o5p3urr5YyrdotWwwqNn/SYJlujICStgqEdukvaWh7vG9UI911Dp7hGFmmn+8UvWubcZm+DBWijkkUJcxIN7Gw==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-EefJd94Hm2507bMQekjoaad32pjGQgldSNjD1UAp6P0EKsn2wZV7H9gxosbZwL3tgMY/HDrQoS1j240/qv528w==", "cpu": [ "arm64" ], @@ -2680,9 +1982,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-0MEDwP3pt1IeG+MwW/qR2r9d7ZMvYv61a9MJJCjtkFGMm23LL/kitaEEYdeQ6efTeUJDWfRleol1dDkh8aoBEg==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-2VLSCSHgnb94BLLeb1LGYTMIRgbCWkpJa74U45Fi9W+ErTW5QJR+b/nzKAzeeL8/6vu5cfnW4Yf5pCU4d+IvTg==", "cpu": [ "x64" ], @@ -2694,9 +1996,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-ko3sx2l2bzcaCIQ+MDXT/b5FVX2NoSuji5IK/DdZvrbYGgNkh2qUb7Td7RqXaTIE1yaUTEv5PK7UGu1C1qg2EQ==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-byymVRxjqyQPBDpTxcfFprfNTN0e8RXE1VRmVrMU4kJeCxC9SRPQAZCIFw5bJKt74iic7Ixk0O+Ehjp+YlH/VQ==", "cpu": [ "arm64" ], @@ -2708,9 +2010,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20251111.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251111.1.tgz", - "integrity": "sha512-XClWd0RpDQ/ouF9d8+gafFHob8XUFAJ49yfLkQqRncRwCXiGfMXyZTbHms+BdyAl5qguv1sgm2YhAWHeaggltQ==", + "version": "7.0.0-dev.20251113.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251113.1.tgz", + "integrity": "sha512-H6dR8nmGmuKduG8kODnb5bRgsTScjJ7lLpG6cch+3MflNaIymMAiQsku2kWKtMiFp94wHgQIFk88ov2wG91FYw==", "cpu": [ "x64" ], @@ -2721,6 +2023,133 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2740,6 +2169,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2790,32 +2220,6 @@ } } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2842,20 +2246,6 @@ "node": ">=14" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2863,6 +2253,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -2880,122 +2280,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3003,16 +2287,6 @@ "dev": true, "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.24", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.24.tgz", - "integrity": "sha512-uUhTRDPXamakPyghwrUcjaGvvBqGrWvBHReoiULMIpOJVM9IYzQh83Xk2Onx5HlGI2o10NNCzcs9TG/S3TkwrQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, "node_modules/birpc": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", @@ -3067,70 +2341,6 @@ "node": ">=8" } }, - "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3189,37 +2399,23 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001753", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", - "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3237,14 +2433,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -3263,62 +2459,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3376,13 +2516,6 @@ "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", @@ -3414,28 +2547,6 @@ "node": ">= 0.10" } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3467,19 +2578,14 @@ } } }, - "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } + "engines": { + "node": ">=6" } }, "node_modules/deep-is": { @@ -3489,16 +2595,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3508,16 +2604,6 @@ "node": ">= 0.8" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/diff": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", @@ -3528,16 +2614,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/dts-resolver": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz", @@ -3579,33 +2655,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/electron-to-chromium": { - "version": "1.5.244", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", - "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -3625,16 +2674,6 @@ "node": ">= 0.8" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3653,6 +2692,13 @@ "node": ">= 0.4" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3707,16 +2753,6 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -3742,6 +2778,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3884,20 +2921,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -3934,6 +2957,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3974,54 +3007,14 @@ "node": ">=18.0.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=12.0.0" } }, "node_modules/express": { @@ -4029,6 +3022,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -4157,14 +3151,22 @@ "reusify": "^1.0.4" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/file-entry-cache": { @@ -4266,13 +3268,6 @@ "node": ">= 0.8" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4297,26 +3292,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4341,16 +3316,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4364,19 +3329,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-tsconfig": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", @@ -4390,28 +3342,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4450,13 +3380,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -4464,28 +3387,6 @@ "dev": true, "license": "MIT" }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4527,13 +3428,6 @@ "dev": true, "license": "MIT" }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4559,16 +3453,6 @@ "node": ">= 0.8" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4608,26 +3492,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4638,18 +3502,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4665,29 +3517,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4698,26 +3527,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4747,716 +3556,16 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5486,13 +3595,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5506,19 +3608,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5529,42 +3618,32 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lefthook": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.0.2.tgz", - "integrity": "sha512-2lrSva53G604ZWjK5kHYvDdwb5GzbhciIPWhebv0A8ceveqSsnG2JgVEt+DnhOPZ4VfNcXvt3/ohFBPNpuAlVw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.0.4.tgz", + "integrity": "sha512-GNCU2vQWM/UWjiEF23601aILi1aMbPke6viortH7wIO/oVGOCW0H6FdLez4XZDyqnHL9XkTnd0BBVrBbYVMLpA==", "hasInstallScript": true, "license": "MIT", "bin": { "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.0.2", - "lefthook-darwin-x64": "2.0.2", - "lefthook-freebsd-arm64": "2.0.2", - "lefthook-freebsd-x64": "2.0.2", - "lefthook-linux-arm64": "2.0.2", - "lefthook-linux-x64": "2.0.2", - "lefthook-openbsd-arm64": "2.0.2", - "lefthook-openbsd-x64": "2.0.2", - "lefthook-windows-arm64": "2.0.2", - "lefthook-windows-x64": "2.0.2" + "lefthook-darwin-arm64": "2.0.4", + "lefthook-darwin-x64": "2.0.4", + "lefthook-freebsd-arm64": "2.0.4", + "lefthook-freebsd-x64": "2.0.4", + "lefthook-linux-arm64": "2.0.4", + "lefthook-linux-x64": "2.0.4", + "lefthook-openbsd-arm64": "2.0.4", + "lefthook-openbsd-x64": "2.0.4", + "lefthook-windows-arm64": "2.0.4", + "lefthook-windows-x64": "2.0.4" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.0.2.tgz", - "integrity": "sha512-x/4AOinpMS2abZyA/krDd50cRPZit/6P670Z1mJjfS0+fPZkFw7AXpjxroiN0rgglg78vD7BwcA5331z4YZa5g==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.0.4.tgz", + "integrity": "sha512-AR63/O5UkM7Sc6x5PhP4vTuztTYRBeBroXApeWGM/8e5uZyoQug/7KTh7xhbCMDf8WJv6vdFeXAQCPSmDyPU3Q==", "cpu": [ "arm64" ], @@ -5575,9 +3654,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.0.2.tgz", - "integrity": "sha512-MSb8XZBfmlNvCpuLiQqrJS+sPiSEAyuoHOMZOHjlceYqO0leVVw9YfePVcb4Vi/PqOYngTdJk83MmYvqhsSNTQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.0.4.tgz", + "integrity": "sha512-618DVUttSzV9egQiqTQoxGfnR240JoPWYmqRVHhiegnQKZ2lp5XJ+7NMxeRk/ih93VVOLzFO5ky3PbpxTmJgjQ==", "cpu": [ "x64" ], @@ -5588,9 +3667,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.0.2.tgz", - "integrity": "sha512-gewPsUPc3J/n2/RrhHLS9jtL3qK4HcTED25vfExhvFRW3eT1SDYaBbXnUUmB8SE0zE8Bl6AfEdT2zzZcPbOFuA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.0.4.tgz", + "integrity": "sha512-mTAQym1BK38fKglHBQ/0GXPznVC4LoStHO5lAI3ZxaEC0FQetqGHYFzhWbIH5sde9JhztE2rL/aBzMHDoAtzSw==", "cpu": [ "arm64" ], @@ -5601,9 +3680,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.0.2.tgz", - "integrity": "sha512-fsLlaChiKAWiSavQO2LXPR8Z9OcBnyMDvmkIlXC0lG3SjBb9xbVdBdDVlcrsUyDCs5YstmGYHuzw6DfJYpAE1g==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.0.4.tgz", + "integrity": "sha512-sy02aSxd8UMd6XmiPFVl/Em0b78jdZcDSsLwg+bweJQQk0l+vJhOfqFiG11mbnpo+EBIZmRe6OH5LkxeSU36+w==", "cpu": [ "x64" ], @@ -5614,9 +3693,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.0.2.tgz", - "integrity": "sha512-vNl3HiZud9T2nGHMngvLw3hSJgutjlN/Lzf5/5jKt/2IIuyd9L3UYktWC9HLUb03Zukr7jeaxG3+VxdAohQwAw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.0.4.tgz", + "integrity": "sha512-W0Nlr/Cz2QTH9n4k5zNrk3LSsg1C4wHiJi8hrAiQVTaAV/N1XrKqd0DevqQuouuapG6pw/6B1xCgiNPebv9oyw==", "cpu": [ "arm64" ], @@ -5627,9 +3706,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.0.2.tgz", - "integrity": "sha512-0ghHMPu4fixIieS8V2k2yZHvcFd9pP0q+sIAIaWo8x7ce/AOQIXFCPHGPAOc8/wi5uVtfyEvCnhxIDKf+lHA2A==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.0.4.tgz", + "integrity": "sha512-N6ySVCtB/DrOZ1ZgPL8WBZTgtoVHvcPKI+LV5wbcGrvA/dzDZFvniadrbDWZg7Tm705efiQzyENjwhhqNkwiww==", "cpu": [ "x64" ], @@ -5640,9 +3719,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.0.2.tgz", - "integrity": "sha512-qfXnDM8jffut9rylvi3T+HOqlNRkFYqIDUXeVXlY7dmwCW4u2K46p0W4M3BmAVUeL/MRxBRnjze//Yy6aCbGQw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.0.4.tgz", + "integrity": "sha512-VmOhJO3pYzZ/1C2WFXtL/n5pq4/eYOroqJJpwTJfmCHyw4ceLACu8MDyU5AMJhGMkbL8mPxGInJKxg5xhYgGRw==", "cpu": [ "arm64" ], @@ -5653,9 +3732,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.0.2.tgz", - "integrity": "sha512-RXqR0FiDTwsQv1X3QVsuBFneWeNXS+tmPFIX8F6Wz9yDPHF8+vBnkWCju6HdkTVTY71Ba5HbYGKEVDvscJkU7Q==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.0.4.tgz", + "integrity": "sha512-U8MZz1xlHUdflkQQ2hkMQsei6fSZbs8tuE4EjCIHWnNdnAF4V8sZ6n1KbxsJcoZXPyBZqxZSMu1o/Ye8IAMVKg==", "cpu": [ "x64" ], @@ -5666,9 +3745,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.0.2.tgz", - "integrity": "sha512-KfLKhiUPHP9Aea+9D7or2hgL9wtKEV+GHpx7LBg82ZhCXkAml6rop7mWsBgL80xPYLqMahKolZGO+8z5H6W4HQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.0.4.tgz", + "integrity": "sha512-543H3y2JAwNdvwUQ6nlNBG7rdKgoOUgzAa6pYcl6EoqicCRrjRmGhkJu7vUudkkrD2Wjm7tr9hU9poP2g5fRFQ==", "cpu": [ "arm64" ], @@ -5679,9 +3758,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.0.2.tgz", - "integrity": "sha512-TdysWxGRNtuRg5bN6Uj00tZJIsHTrF/7FavoR5rp1sq21QJhJi36M4I3UVlmOKAUCKhibAIAauZWmX7yaW3eHA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.0.4.tgz", + "integrity": "sha512-UDEPK9RWKm60xsNOdS/DQOdFba0SFa4w3tpFMXK1AJzmRHhosoKrorXGhtTr6kcM0MGKOtYi8GHsm++ArZ9wvQ==", "cpu": [ "x64" ], @@ -5691,16 +3770,6 @@ "win32" ] }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5715,13 +3784,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5738,13 +3800,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5752,15 +3807,12 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } + "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", @@ -5772,52 +3824,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5848,13 +3854,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5879,6 +3878,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5900,16 +3912,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5923,22 +3925,31 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5955,50 +3966,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6020,6 +3987,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-1.0.0.tgz", + "integrity": "sha512-WKcS43Yl6YPJekid7KiRdT6CHMSmYWVfJiSFbTaGxWQlC+cEBPxHa9jR1uS2cMiQmXd8Hsa2ipAKErQ/GLhSpg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "peerDependencies": { + "ms": "^2.0.0" + }, + "peerDependenciesMeta": { + "ms": { + "optional": true + } + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6041,22 +4027,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6107,16 +4077,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6130,25 +4090,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6168,16 +4109,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6187,13 +4118,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -6211,6 +4135,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6219,28 +4153,19 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -6250,73 +4175,33 @@ "node": ">=16.20.0" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "find-up": "^4.0.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" + "node": "^10 || ^12 || >=14" } }, "node_modules/prelude-ls": { @@ -6345,48 +4230,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6410,23 +4253,6 @@ "node": ">=6" } }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -6520,13 +4346,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -6541,16 +4360,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6560,50 +4369,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6624,16 +4389,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6646,14 +4401,15 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-beta.49", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.49.tgz", - "integrity": "sha512-Bfmdn3ZqyCwi1LxG39KBrSlil9a/xnrOrAj+jqqN2YTR/WJIEOOfwNKgDALQvr0xlO9bG/i1C883KGd4nd7SrA==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz", + "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@oxc-project/types": "=0.96.0", - "@rolldown/pluginutils": "1.0.0-beta.49" + "@oxc-project/types": "=0.97.0", + "@rolldown/pluginutils": "1.0.0-beta.50" }, "bin": { "rolldown": "bin/cli.mjs" @@ -6662,26 +4418,26 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.49", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.49", - "@rolldown/binding-darwin-x64": "1.0.0-beta.49", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.49", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.49", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.49", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.49", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.49", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.49", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.49", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.49", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.49", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.49", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.49" + "@rolldown/binding-android-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", + "@rolldown/binding-darwin-x64": "1.0.0-beta.50", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" } }, "node_modules/rolldown-plugin-dts": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.5.tgz", - "integrity": "sha512-dYzjLdhgsSIPmOCPJdDXiD6AUotAHVkGLNlSMdi3VmvTqk7O9H7uGzG2WGzgnDxZq6kZh31vZUbp9ZFoOhpySA==", + "version": "0.17.7", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.7.tgz", + "integrity": "sha512-ZGgXMhzCItmznNzbJlTcC/KdM6bIwcZoYUykJ2q14HOGvnMhnl2RXU+XrIrdjA2Hyzq3nWqDH7qWaM5a4uCVnw==", "dev": true, "license": "MIT", "dependencies": { @@ -6690,10 +4446,10 @@ "@babel/types": "^7.28.5", "ast-kit": "^2.2.0", "birpc": "^2.8.0", - "debug": "^4.4.3", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.0", - "magic-string": "^0.30.21" + "magic-string": "^0.30.21", + "obug": "^1.0.0" }, "engines": { "node": ">=20.18.0" @@ -6723,6 +4479,48 @@ } } }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "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.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -6790,13 +4588,16 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -6935,80 +4736,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/statuses": { "version": "2.0.2", @@ -7019,67 +4769,12 @@ "node": ">= 0.8" } }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/strip-json-comments": { "version": "3.1.1", @@ -7107,33 +4802,12 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/tinyexec": { "version": "1.0.2", @@ -7162,43 +4836,35 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=14.0.0" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, - "license": "BSD-3-Clause" + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -7245,107 +4911,28 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tsdown": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.16.2.tgz", - "integrity": "sha512-ZyHFO5DduN2TxY47hHPGZRZz5XSQbDYFl8L4BkLJlzYeHuX0cx5mZkonq7Xm4OC/z8BxWY+ai4kwvevup4Lwlw==", + "version": "0.16.4", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.16.4.tgz", + "integrity": "sha512-tdhy+EQpZSVrVkDRnjKEKVfh1git2HrliGp3SylRNg7kk+lOx3SvT7NLakKX5grPAg8WrZrXWLPUrCegWupqgg==", "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", "empathic": "^2.0.0", "hookable": "^5.5.3", - "rolldown": "1.0.0-beta.49", - "rolldown-plugin-dts": "^0.17.5", + "obug": "^1.0.0", + "rolldown": "1.0.0-beta.50", + "rolldown-plugin-dts": "^0.17.7", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.4.1", - "unrun": "^0.2.7" + "unrun": "^0.2.9" }, "bin": { "tsdown": "dist/run.mjs" @@ -7385,19 +4972,6 @@ } } }, - "node_modules/tsdown/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7439,29 +5013,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -7482,6 +5033,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7491,16 +5043,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.3", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.3.tgz", - "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==", + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", + "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.3", - "@typescript-eslint/parser": "8.46.3", - "@typescript-eslint/typescript-estree": "8.46.3", - "@typescript-eslint/utils": "8.46.3" + "@typescript-eslint/eslint-plugin": "8.46.4", + "@typescript-eslint/parser": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7514,20 +5066,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/unconfig-core": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.4.1.tgz", @@ -7543,9 +5081,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": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -7559,14 +5097,14 @@ } }, "node_modules/unrun": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.8.tgz", - "integrity": "sha512-2HnCvemvMN7qQm0/1uhhi8JoVHQxfkAhZTCdFNWAK35dXKJlOHpcJ6ayN23JKJZyAXjAwpVZZr878flfRiaiFQ==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.9.tgz", + "integrity": "sha512-Cta7uGK/08OqH2O0HLYXs1AfIBp2+2v11ZoeAIqJLUCb9CN+7uxj+CldHCzqAw30b8MJEmWe+BFgK2sl4lJXlw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "^0.96.0", - "rolldown": "1.0.0-beta.49" + "@oxc-project/runtime": "^0.97.0", + "rolldown": "1.0.0-beta.50" }, "bin": { "unrun": "dist/cli.mjs" @@ -7586,37 +5124,6 @@ } } }, - "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -7627,21 +5134,6 @@ "punycode": "^2.1.0" } }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -7651,16 +5143,606 @@ "node": ">= 0.8" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "makeerror": "1.0.12" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7676,6 +5758,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7686,97 +5785,12 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7795,6 +5809,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1a10a54..1e3ce10 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ }, "scripts": { "start": "tsx src/index.ts", - "test": "jest", + "test": "vitest run", + "test:watch": "vitest", "build": "tsdown src/index.ts --minify --clean --target node20", "lint": "eslint src/ examples/ && prettier --check .", "lint:fix": "eslint src/ examples/ --fix && prettier --write .", @@ -29,19 +30,17 @@ }, "devDependencies": { "@eslint/js": "^9.8.0", - "@jest/globals": "^29.7.0", "@types/express": "^5.0.3", - "@types/jest": "^29.5.8", + "@types/node": "^22.10.2", "@typescript/native-preview": "^7.0.0-dev.20251111.1", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", - "jest": "^29.7.0", "prettier": "3.6.2", - "ts-jest": "^29.1.1", "tsdown": "^0.16.2", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.0.0" + "typescript-eslint": "^8.0.0", + "vitest": "^2.1.8" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", diff --git a/src/scenarios/client/auth/basic-dcr.test.ts b/src/scenarios/client/auth/basic-dcr.test.ts index 3f18e1f..6470d15 100644 --- a/src/scenarios/client/auth/basic-dcr.test.ts +++ b/src/scenarios/client/auth/basic-dcr.test.ts @@ -1,4 +1,3 @@ -import { describe, test } from '@jest/globals'; import { runClientAgainstScenario, SpawnedClientRunner diff --git a/src/scenarios/client/auth/basic-metadata-var1.test.ts b/src/scenarios/client/auth/basic-metadata-var1.test.ts index 4cdbb01..ee5bac4 100644 --- a/src/scenarios/client/auth/basic-metadata-var1.test.ts +++ b/src/scenarios/client/auth/basic-metadata-var1.test.ts @@ -1,4 +1,3 @@ -import { describe, test } from '@jest/globals'; import { runClientAgainstScenario, SpawnedClientRunner diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 545bf4c..6eab252 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -3,13 +3,13 @@ import { getClientScenario, listActiveClientScenarios } from '../index.js'; import path from 'path'; describe('Server Scenarios', () => { - let serverProcess: ChildProcess; + let serverProcess: ChildProcess | null = null; const TEST_PORT = 3001; const SERVER_URL = `http://localhost:${TEST_PORT}/mcp`; - const SERVER_STARTUP_TIMEOUT = 10000; // 10 seconds to start + const SERVER_STARTUP_TIMEOUT = 30000; // 30 seconds for CI beforeAll(async () => { - // Start the everything-server once for all scenarios + // Start the everything-server once for all scenarios in this file const serverPath = path.join( process.cwd(), 'examples/servers/typescript/everything-server.ts' @@ -17,61 +17,85 @@ describe('Server Scenarios', () => { serverProcess = spawn('npx', ['tsx', serverPath], { env: { ...process.env, PORT: TEST_PORT.toString() }, - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], + detached: false + }); + + // Capture output for debugging + let stdoutData = ''; + let stderrData = ''; + + serverProcess.stdout?.on('data', (data) => { + stdoutData += data.toString(); + }); + + serverProcess.stderr?.on('data', (data) => { + stderrData += data.toString(); }); // Wait for server to be ready await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (serverProcess) { + serverProcess.kill('SIGKILL'); + } reject( new Error(`Server failed to start within ${SERVER_STARTUP_TIMEOUT}ms`) ); }, SERVER_STARTUP_TIMEOUT); - serverProcess.stdout?.on('data', (data) => { + let resolved = false; + + serverProcess!.stdout?.on('data', (data) => { const output = data.toString(); if (output.includes('running on')) { clearTimeout(timeout); + resolved = true; resolve(); } }); - serverProcess.on('error', (error) => { + serverProcess!.on('error', (error) => { clearTimeout(timeout); reject(new Error(`Failed to start server: ${error.message}`)); }); - serverProcess.on('exit', (code) => { - if (code !== null && code !== 0) { + serverProcess!.on('exit', (code) => { + // Only reject if server exits unexpectedly during startup + if (!resolved && code !== null && code !== 0) { clearTimeout(timeout); - reject(new Error(`Server exited prematurely with code ${code}`)); + reject( + new Error( + `Server exited prematurely with code ${code}. STDOUT: ${stdoutData}, STDERR: ${stderrData}` + ) + ); } }); }); }, SERVER_STARTUP_TIMEOUT + 5000); afterAll(async () => { - // Stop the server - if (serverProcess) { + // Stop the server and clean up + if (serverProcess && !serverProcess.killed) { + // Try graceful shutdown first serverProcess.kill('SIGTERM'); - // Wait for graceful shutdown + // Wait for graceful shutdown with timeout await new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - if (!serverProcess.killed) { + const killTimeout = setTimeout(() => { + if (serverProcess && !serverProcess.killed) { serverProcess.kill('SIGKILL'); } resolve(); }, 5000); - const cleanUp = () => { - clearTimeout(timeoutHandle); - serverProcess.removeListener('exit', cleanUp); + serverProcess!.once('exit', () => { + clearTimeout(killTimeout); resolve(); - }; - - serverProcess.on('exit', cleanUp); + }); }); + + serverProcess = null; } }); diff --git a/tsconfig.json b/tsconfig.json index 5c8ae01..654a224 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "types": ["node", "vitest/globals"] }, "include": ["src/**/*", "examples/clients/typescript/**/*"], "exclude": ["node_modules"] diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..436dd00 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['**/*.test.ts'], + exclude: ['node_modules', 'dist'], + // Run test files sequentially to avoid port conflicts + fileParallelism: false, + // Increase timeout for server tests in CI + testTimeout: 15000, + hookTimeout: 30000 + } +}); From f2339987df1f45aaf36fea5f2d580726118f2be3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:28:37 +0000 Subject: [PATCH 015/226] Upgrade vitest to v4.0.5 (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates vitest from v2.1.8 to v4.0.5. All tests pass successfully with no code changes required. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- package-lock.json | 771 +++++++++------------------------------------- package.json | 2 +- 2 files changed, 140 insertions(+), 633 deletions(-) diff --git a/package-lock.json b/package-lock.json index c1df0dd..105246b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "tsx": "^4.7.0", "typescript": "^5.5.4", "typescript-eslint": "^8.0.0", - "vitest": "^2.1.8" + "vitest": "^4.0.5" } }, "node_modules/@babel/generator": { @@ -1504,6 +1504,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1526,6 +1533,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1536,6 +1554,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2024,38 +2049,40 @@ ] }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", + "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.8", + "@vitest/utils": "4.0.8", + "chai": "^6.2.0", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", + "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.0.8", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2067,84 +2094,66 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", + "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", + "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.0.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", + "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.0.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", + "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", + "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.0.8", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2400,18 +2409,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2433,16 +2435,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2578,16 +2570,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3807,13 +3789,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4135,16 +4110,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4836,30 +4801,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -4986,6 +4931,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -5144,21 +5090,25 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -5167,19 +5117,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -5200,511 +5156,60 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", + "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "@vitest/expect": "4.0.8", + "@vitest/mocker": "4.0.8", + "@vitest/pretty-format": "4.0.8", + "@vitest/runner": "4.0.8", + "@vitest/snapshot": "4.0.8", + "@vitest/spy": "4.0.8", + "@vitest/utils": "4.0.8", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.8", + "@vitest/browser-preview": "4.0.8", + "@vitest/browser-webdriverio": "4.0.8", + "@vitest/ui": "4.0.8", "happy-dom": "*", "jsdom": "*" }, @@ -5712,10 +5217,19 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { @@ -5729,13 +5243,6 @@ } } }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/vitest/node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", diff --git a/package.json b/package.json index 1e3ce10..8c92e7e 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "tsx": "^4.7.0", "typescript": "^5.5.4", "typescript-eslint": "^8.0.0", - "vitest": "^2.1.8" + "vitest": "^4.0.5" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.20.1", From ee60717a060dc2edade961095687b61207970d54 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:49:58 +0000 Subject: [PATCH 016/226] Update dependencies in package.json and package-lock.json (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downgrade @typescript/native-preview and tsdown to previous versions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- package-lock.json | 1377 +++++++++++++++++++++------------------------ package.json | 6 +- 2 files changed, 629 insertions(+), 754 deletions(-) diff --git a/package-lock.json b/package-lock.json index 105246b..98aa27c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,14 +22,14 @@ "@eslint/js": "^9.8.0", "@types/express": "^5.0.3", "@types/node": "^22.10.2", - "@typescript/native-preview": "^7.0.0-dev.20251111.1", + "@typescript/native-preview": "^7.0.0-dev.20251030.1", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "prettier": "3.6.2", - "tsdown": "^0.16.2", + "tsdown": "^0.15.12", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.0.0", + "typescript-eslint": "8.46.2", "vitest": "^4.0.5" } }, @@ -101,9 +101,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", - "integrity": "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", "dev": true, "license": "MIT", "optional": true, @@ -113,9 +113,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", - "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", "dev": true, "license": "MIT", "optional": true, @@ -135,9 +135,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "cpu": [ "ppc64" ], @@ -152,9 +152,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "cpu": [ "arm" ], @@ -169,9 +169,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "cpu": [ "arm64" ], @@ -186,9 +186,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "cpu": [ "x64" ], @@ -203,9 +203,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], @@ -220,9 +220,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", "cpu": [ "x64" ], @@ -237,9 +237,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "cpu": [ "arm64" ], @@ -254,9 +254,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "cpu": [ "x64" ], @@ -271,9 +271,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "cpu": [ "arm" ], @@ -288,9 +288,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "cpu": [ "arm64" ], @@ -305,9 +305,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "cpu": [ "ia32" ], @@ -322,9 +322,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "cpu": [ "loong64" ], @@ -339,9 +339,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "cpu": [ "mips64el" ], @@ -356,9 +356,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "cpu": [ "ppc64" ], @@ -373,9 +373,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "cpu": [ "riscv64" ], @@ -390,9 +390,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "cpu": [ "s390x" ], @@ -407,9 +407,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", "cpu": [ "x64" ], @@ -424,9 +424,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "cpu": [ "arm64" ], @@ -441,9 +441,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "cpu": [ "x64" ], @@ -458,9 +458,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "cpu": [ "arm64" ], @@ -475,9 +475,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "cpu": [ "x64" ], @@ -492,9 +492,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", "cpu": [ "arm64" ], @@ -509,9 +509,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], @@ -526,9 +526,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "cpu": [ "arm64" ], @@ -543,9 +543,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], @@ -560,9 +560,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.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", "cpu": [ "x64" ], @@ -646,7 +646,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { + "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", @@ -659,6 +659,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", @@ -683,34 +696,10 @@ "url": "https://opencollective.com/eslint" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", "dev": true, "license": "MIT", "engines": { @@ -744,6 +733,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -836,13 +838,11 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.21.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.21.1.tgz", - "integrity": "sha512-UyLFcJLDvUuZbGnaQqXFT32CpPpGj7VS19roLut6gkQVhb439xUzYWbsUvdI3ZPL+2hnFosuugtYWE0Mcs1rmQ==", - "license": "MIT", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", + "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -857,14 +857,6 @@ }, "engines": { "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - } } }, "node_modules/@napi-rs/wasm-runtime": { @@ -918,20 +910,10 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.97.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz", - "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.97.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz", - "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", + "version": "0.95.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.95.0.tgz", + "integrity": "sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==", "dev": true, "license": "MIT", "funding": { @@ -952,9 +934,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "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==", "cpu": [ "arm64" ], @@ -969,9 +951,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "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==", "cpu": [ "arm64" ], @@ -986,9 +968,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "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==", "cpu": [ "x64" ], @@ -1003,9 +985,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "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==", "cpu": [ "x64" ], @@ -1020,9 +1002,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", - "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "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==", "cpu": [ "arm" ], @@ -1037,9 +1019,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "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==", "cpu": [ "arm64" ], @@ -1054,9 +1036,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "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==", "cpu": [ "arm64" ], @@ -1071,9 +1053,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "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==", "cpu": [ "x64" ], @@ -1088,9 +1070,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "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==", "cpu": [ "x64" ], @@ -1105,9 +1087,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "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==", "cpu": [ "arm64" ], @@ -1122,9 +1104,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", - "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", + "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==", "cpu": [ "wasm32" ], @@ -1139,9 +1121,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "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==", "cpu": [ "arm64" ], @@ -1156,9 +1138,9 @@ } }, "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", + "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" ], @@ -1173,9 +1155,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "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==", "cpu": [ "x64" ], @@ -1190,16 +1172,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", - "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", + "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==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "cpu": [ "arm" ], @@ -1211,9 +1193,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", "cpu": [ "arm64" ], @@ -1225,9 +1207,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "cpu": [ "arm64" ], @@ -1239,9 +1221,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", "cpu": [ "x64" ], @@ -1253,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", "cpu": [ "arm64" ], @@ -1267,9 +1249,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", "cpu": [ "x64" ], @@ -1281,9 +1263,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", "cpu": [ "arm" ], @@ -1295,9 +1277,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", "cpu": [ "arm" ], @@ -1309,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", "cpu": [ "arm64" ], @@ -1323,9 +1305,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", "cpu": [ "arm64" ], @@ -1337,9 +1319,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", "cpu": [ "loong64" ], @@ -1351,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", "cpu": [ "ppc64" ], @@ -1365,9 +1347,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", "cpu": [ "riscv64" ], @@ -1379,9 +1361,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", "cpu": [ "riscv64" ], @@ -1393,9 +1375,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", "cpu": [ "s390x" ], @@ -1407,9 +1389,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "cpu": [ "x64" ], @@ -1421,9 +1403,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", "cpu": [ "x64" ], @@ -1435,9 +1417,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", "cpu": [ "arm64" ], @@ -1449,9 +1431,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", "cpu": [ "arm64" ], @@ -1463,9 +1445,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", "cpu": [ "ia32" ], @@ -1477,9 +1459,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", "cpu": [ "x64" ], @@ -1491,9 +1473,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", "cpu": [ "x64" ], @@ -1569,21 +1551,21 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" + "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1604,22 +1586,13 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "version": "22.18.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", + "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", "dev": true, - "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } @@ -1639,9 +1612,9 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1649,40 +1622,28 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1696,7 +1657,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1712,17 +1673,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" }, "engines": { @@ -1738,14 +1699,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" }, "engines": { @@ -1760,14 +1721,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1778,9 +1739,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", "dev": true, "license": "MIT", "engines": { @@ -1795,15 +1756,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1820,9 +1781,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", "dev": true, "license": "MIT", "engines": { @@ -1834,16 +1795,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1889,16 +1850,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1913,13 +1874,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1931,9 +1892,9 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-gxe+j1h5DSnP/rE2+4BGSzfKRg2RdmMtHlPX+oH1iLGOHTWLQ5IVtS7hW2IlGJY2Y1dXXDARh9wRYzePPXyh8g==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-LKDTZxo2D2U75EZ+8xw0AJmG3VEqUVjlWu43IJLdl+Hjyhh2eFmfufhXw3mwPRAF6hEy+E3ldUtglmmliN/CiA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1941,19 +1902,19 @@ "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251113.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251113.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20251113.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251113.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20251113.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251113.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20251113.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251030.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251030.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20251030.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251030.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20251030.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251030.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20251030.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-ArzjtsMWWUSRnqv/C7p+Lv7SJbL9EDGBPDgBvyI+xIcTqyD9WW2sMStjc1jTwWsimmQXzQQ8jZYpf+xSGj5boQ==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-9kSrvvp7/T5kTIFtXn3ZDcAsL6Afao04iq7v4ItZyMF85e0L1zjcUQfZ5+iZsQAvlKsDff0yF26OW1jPdKPRQg==", "cpu": [ "arm64" ], @@ -1965,9 +1926,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-airwguNehqwQhr6LjDcJ0LXfBv2GaOJkpj6fzxMK9gWlrx/tVwvRFoazUesdOnR1DM6zSE4gy+2UROxGeumOSw==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-n6whSU1ylbgmnREklaZo5xgXWfRGpzGmmRykPpr518CWdiZTK+Nz7Yc3rrKNky19XB53duAsOccbb7aMbjSxKg==", "cpu": [ "x64" ], @@ -1979,9 +1940,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-lTYX9vskfbq3kq2+vpqu2fwJP68iZ4OuWHuGLMzS/AlQ1vzs535su/s6xhL8UuyRP+n10A9z2xHq7UJSOx8u1w==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-KV/vpl+o5TWlc4Al0c6DWJ1DI1e/PVLnjZ7yvuJcKpsHKDZnOYY5PVm2lrspJppIAszYkrLnxkvwKJaEqM2Zag==", "cpu": [ "arm" ], @@ -1993,9 +1954,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-EefJd94Hm2507bMQekjoaad32pjGQgldSNjD1UAp6P0EKsn2wZV7H9gxosbZwL3tgMY/HDrQoS1j240/qv528w==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-diTTlmweclqYGntZkgGBCcJFU7YsILiP4DJPeXTf453QBlXHOyeFiPT/1rPXfcLzvk2q9Q/oyAA6aKHrJcCf8w==", "cpu": [ "arm64" ], @@ -2007,9 +1968,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-2VLSCSHgnb94BLLeb1LGYTMIRgbCWkpJa74U45Fi9W+ErTW5QJR+b/nzKAzeeL8/6vu5cfnW4Yf5pCU4d+IvTg==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-Yn43Ut3LQFEXQOL+fO/mOrq/MyhBplfucHCvD2T1P7DmSD/90XgWxz5DCIOJ+o/qmFCkBF6oXQkco4JcHJ8fPg==", "cpu": [ "x64" ], @@ -2021,9 +1982,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-byymVRxjqyQPBDpTxcfFprfNTN0e8RXE1VRmVrMU4kJeCxC9SRPQAZCIFw5bJKt74iic7Ixk0O+Ehjp+YlH/VQ==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-1RSrPGwdKbGEsgjgj7HiKiN1KjcZNuQCSEIZ8fbQFIcdfIeC2hUvSlzwI6a4hGx2SOSdA5kl6rUcZOE3hPqzPA==", "cpu": [ "arm64" ], @@ -2035,9 +1996,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20251113.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251113.1.tgz", - "integrity": "sha512-H6dR8nmGmuKduG8kODnb5bRgsTScjJ7lLpG6cch+3MflNaIymMAiQsku2kWKtMiFp94wHgQIFk88ov2wG91FYw==", + "version": "7.0.0-dev.20251030.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251030.1.tgz", + "integrity": "sha512-Q7kKUgnjUkk/wxmghLi+hokqo58e+H8wjWaV020JTtW63U8DbwS9vyDDPKdU3MQoV2/Jzlqu0TIXsdnERSb/qw==", "cpu": [ "x64" ], @@ -2049,17 +2010,17 @@ ] }, "node_modules/@vitest/expect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", - "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.5.tgz", + "integrity": "sha512-DJctLVlKoddvP/G389oGmKWNG6GD9frm2FPXARziU80Rjo7SIYxQzb2YFzmQ4fVD3Q5utUYY8nUmWrqsuIlIXQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", - "chai": "^6.2.0", + "@vitest/spy": "4.0.5", + "@vitest/utils": "4.0.5", + "chai": "^6.0.1", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2067,15 +2028,15 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.5.tgz", + "integrity": "sha512-iYHIy72LfbK+mL5W8zXROp6oOcJKXWeKcNjcPPsqoa18qIEDrhB6/Z08o0wRajTd6SSSDNw8NCSIHVNOMpz0mw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.8", + "@vitest/spy": "4.0.5", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^0.30.19" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2094,9 +2055,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.5.tgz", + "integrity": "sha512-t1T/sSdsYyNc5AZl0EMeD0jW9cpJe2cODP0R++ZQe1kTkpgrwEfxGFR/yCG4w8ZybizbXRTHU7lE8sTDD/QsGw==", "dev": true, "license": "MIT", "dependencies": { @@ -2107,13 +2068,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", - "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.5.tgz", + "integrity": "sha512-CQVVe+YEeKSiFBD5gBAmRDQglm4PnMBYzeTmt06t5iWtsUN9StQeeKhYCea/oaqBYilf8sARG6fSctUcEL/UmQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.8", + "@vitest/utils": "4.0.5", "pathe": "^2.0.3" }, "funding": { @@ -2121,14 +2082,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", - "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.5.tgz", + "integrity": "sha512-jfmSAeR6xYNEvcD+/RxFGA1bzpqHtkVhgxo2cxXia+Q3xX7m6GpZij07rz+WyQcA/xEGn4eIS1OItkMyWsGBmQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.8", - "magic-string": "^0.30.21", + "@vitest/pretty-format": "4.0.5", + "magic-string": "^0.30.19", "pathe": "^2.0.3" }, "funding": { @@ -2136,9 +2097,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.5.tgz", + "integrity": "sha512-TUmVQpAQign7r8+EnZsgTF3vY9BdGofTUge1rGNbnHn2IN3FChiQoT9lrPz7A7AVUZJU2LAZXl4v66HhsNMhoA==", "dev": true, "license": "MIT", "funding": { @@ -2146,13 +2107,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.5.tgz", + "integrity": "sha512-V5RndUgCB5/AfNvK9zxGCrRs99IrPYtMTIdUzJMMFs9nrmE5JXExIEfjVtUteyTRiLfCm+dCRMHf/Uu7Mm8/dg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.8", + "@vitest/pretty-format": "4.0.5", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2191,44 +2152,26 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "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": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2273,13 +2216,13 @@ } }, "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": "2.1.3", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.3.tgz", + "integrity": "sha512-TH+b3Lv6pUjy/Nu0m6A2JULtdzLpmqF9x1Dhj00ZoEiML8qvVA9j1flkzTKNYgdEhWrjDwtWNpyyCUbfQe514g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", + "@babel/parser": "^7.28.4", "pathe": "^2.0.3" }, "engines": { @@ -2289,6 +2232,24 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-generator-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-generator-function/-/async-generator-function-1.0.0.tgz", + "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2297,9 +2258,9 @@ "license": "MIT" }, "node_modules/birpc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", - "integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz", + "integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==", "dev": true, "license": "MIT", "funding": { @@ -2409,9 +2370,9 @@ } }, "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", "dev": true, "license": "MIT", "engines": { @@ -2577,6 +2538,13 @@ "dev": true, "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2597,13 +2565,13 @@ } }, "node_modules/dts-resolver": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz", - "integrity": "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.2.tgz", + "integrity": "sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.19.0" + "node": ">=20.18.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" @@ -2694,9 +2662,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.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2707,32 +2675,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.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" } }, "node_modules/escape-html": { @@ -2755,21 +2723,20 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2861,30 +2828,6 @@ "url": "https://opencollective.com/eslint" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3003,7 +2946,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", "peer": true, "dependencies": { "accepts": "^2.0.0", @@ -3097,7 +3039,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -3107,22 +3048,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3156,7 +3081,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, @@ -3255,7 +3179,6 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -3274,17 +3197,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.1.tgz", + "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==", "license": "MIT", "dependencies": { + "async-function": "^1.0.0", + "async-generator-function": "^1.0.0", "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", + "generator-function": "^2.0.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", @@ -3544,10 +3479,20 @@ "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/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", "dependencies": { @@ -3578,9 +3523,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -3601,31 +3546,30 @@ } }, "node_modules/lefthook": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.0.4.tgz", - "integrity": "sha512-GNCU2vQWM/UWjiEF23601aILi1aMbPke6viortH7wIO/oVGOCW0H6FdLez4XZDyqnHL9XkTnd0BBVrBbYVMLpA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.0.2.tgz", + "integrity": "sha512-2lrSva53G604ZWjK5kHYvDdwb5GzbhciIPWhebv0A8ceveqSsnG2JgVEt+DnhOPZ4VfNcXvt3/ohFBPNpuAlVw==", "hasInstallScript": true, - "license": "MIT", "bin": { "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.0.4", - "lefthook-darwin-x64": "2.0.4", - "lefthook-freebsd-arm64": "2.0.4", - "lefthook-freebsd-x64": "2.0.4", - "lefthook-linux-arm64": "2.0.4", - "lefthook-linux-x64": "2.0.4", - "lefthook-openbsd-arm64": "2.0.4", - "lefthook-openbsd-x64": "2.0.4", - "lefthook-windows-arm64": "2.0.4", - "lefthook-windows-x64": "2.0.4" + "lefthook-darwin-arm64": "2.0.2", + "lefthook-darwin-x64": "2.0.2", + "lefthook-freebsd-arm64": "2.0.2", + "lefthook-freebsd-x64": "2.0.2", + "lefthook-linux-arm64": "2.0.2", + "lefthook-linux-x64": "2.0.2", + "lefthook-openbsd-arm64": "2.0.2", + "lefthook-openbsd-x64": "2.0.2", + "lefthook-windows-arm64": "2.0.2", + "lefthook-windows-x64": "2.0.2" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.0.4.tgz", - "integrity": "sha512-AR63/O5UkM7Sc6x5PhP4vTuztTYRBeBroXApeWGM/8e5uZyoQug/7KTh7xhbCMDf8WJv6vdFeXAQCPSmDyPU3Q==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-x/4AOinpMS2abZyA/krDd50cRPZit/6P670Z1mJjfS0+fPZkFw7AXpjxroiN0rgglg78vD7BwcA5331z4YZa5g==", "cpu": [ "arm64" ], @@ -3636,9 +3580,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.0.4.tgz", - "integrity": "sha512-618DVUttSzV9egQiqTQoxGfnR240JoPWYmqRVHhiegnQKZ2lp5XJ+7NMxeRk/ih93VVOLzFO5ky3PbpxTmJgjQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.0.2.tgz", + "integrity": "sha512-MSb8XZBfmlNvCpuLiQqrJS+sPiSEAyuoHOMZOHjlceYqO0leVVw9YfePVcb4Vi/PqOYngTdJk83MmYvqhsSNTQ==", "cpu": [ "x64" ], @@ -3649,9 +3593,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.0.4.tgz", - "integrity": "sha512-mTAQym1BK38fKglHBQ/0GXPznVC4LoStHO5lAI3ZxaEC0FQetqGHYFzhWbIH5sde9JhztE2rL/aBzMHDoAtzSw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.0.2.tgz", + "integrity": "sha512-gewPsUPc3J/n2/RrhHLS9jtL3qK4HcTED25vfExhvFRW3eT1SDYaBbXnUUmB8SE0zE8Bl6AfEdT2zzZcPbOFuA==", "cpu": [ "arm64" ], @@ -3662,9 +3606,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.0.4.tgz", - "integrity": "sha512-sy02aSxd8UMd6XmiPFVl/Em0b78jdZcDSsLwg+bweJQQk0l+vJhOfqFiG11mbnpo+EBIZmRe6OH5LkxeSU36+w==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-fsLlaChiKAWiSavQO2LXPR8Z9OcBnyMDvmkIlXC0lG3SjBb9xbVdBdDVlcrsUyDCs5YstmGYHuzw6DfJYpAE1g==", "cpu": [ "x64" ], @@ -3675,9 +3619,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.0.4.tgz", - "integrity": "sha512-W0Nlr/Cz2QTH9n4k5zNrk3LSsg1C4wHiJi8hrAiQVTaAV/N1XrKqd0DevqQuouuapG6pw/6B1xCgiNPebv9oyw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.0.2.tgz", + "integrity": "sha512-vNl3HiZud9T2nGHMngvLw3hSJgutjlN/Lzf5/5jKt/2IIuyd9L3UYktWC9HLUb03Zukr7jeaxG3+VxdAohQwAw==", "cpu": [ "arm64" ], @@ -3688,9 +3632,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.0.4.tgz", - "integrity": "sha512-N6ySVCtB/DrOZ1ZgPL8WBZTgtoVHvcPKI+LV5wbcGrvA/dzDZFvniadrbDWZg7Tm705efiQzyENjwhhqNkwiww==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.0.2.tgz", + "integrity": "sha512-0ghHMPu4fixIieS8V2k2yZHvcFd9pP0q+sIAIaWo8x7ce/AOQIXFCPHGPAOc8/wi5uVtfyEvCnhxIDKf+lHA2A==", "cpu": [ "x64" ], @@ -3701,9 +3645,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.0.4.tgz", - "integrity": "sha512-VmOhJO3pYzZ/1C2WFXtL/n5pq4/eYOroqJJpwTJfmCHyw4ceLACu8MDyU5AMJhGMkbL8mPxGInJKxg5xhYgGRw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.0.2.tgz", + "integrity": "sha512-qfXnDM8jffut9rylvi3T+HOqlNRkFYqIDUXeVXlY7dmwCW4u2K46p0W4M3BmAVUeL/MRxBRnjze//Yy6aCbGQw==", "cpu": [ "arm64" ], @@ -3714,9 +3658,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.0.4.tgz", - "integrity": "sha512-U8MZz1xlHUdflkQQ2hkMQsei6fSZbs8tuE4EjCIHWnNdnAF4V8sZ6n1KbxsJcoZXPyBZqxZSMu1o/Ye8IAMVKg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.0.2.tgz", + "integrity": "sha512-RXqR0FiDTwsQv1X3QVsuBFneWeNXS+tmPFIX8F6Wz9yDPHF8+vBnkWCju6HdkTVTY71Ba5HbYGKEVDvscJkU7Q==", "cpu": [ "x64" ], @@ -3727,9 +3671,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.0.4.tgz", - "integrity": "sha512-543H3y2JAwNdvwUQ6nlNBG7rdKgoOUgzAa6pYcl6EoqicCRrjRmGhkJu7vUudkkrD2Wjm7tr9hU9poP2g5fRFQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.0.2.tgz", + "integrity": "sha512-KfLKhiUPHP9Aea+9D7or2hgL9wtKEV+GHpx7LBg82ZhCXkAml6rop7mWsBgL80xPYLqMahKolZGO+8z5H6W4HQ==", "cpu": [ "arm64" ], @@ -3740,9 +3684,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.0.4.tgz", - "integrity": "sha512-UDEPK9RWKm60xsNOdS/DQOdFba0SFa4w3tpFMXK1AJzmRHhosoKrorXGhtTr6kcM0MGKOtYi8GHsm++ArZ9wvQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.0.2.tgz", + "integrity": "sha512-TdysWxGRNtuRg5bN6Uj00tZJIsHTrF/7FavoR5rp1sq21QJhJi36M4I3UVlmOKAUCKhibAIAauZWmX7yaW3eHA==", "cpu": [ "x64" ], @@ -3786,8 +3730,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/magic-string": { "version": "0.30.21", @@ -3962,25 +3905,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obug": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/obug/-/obug-1.0.0.tgz", - "integrity": "sha512-WKcS43Yl6YPJekid7KiRdT6CHMSmYWVfJiSFbTaGxWQlC+cEBPxHa9jR1uS2cMiQmXd8Hsa2ipAKErQ/GLhSpg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "peerDependencies": { - "ms": "^2.0.0" - }, - "peerDependenciesMeta": { - "ms": { - "optional": true - } - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4184,7 +4108,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -4212,7 +4135,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4325,15 +4247,6 @@ "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", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4366,15 +4279,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz", - "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "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==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { - "@oxc-project/types": "=0.97.0", - "@rolldown/pluginutils": "1.0.0-beta.50" + "@oxc-project/types": "=0.95.0", + "@rolldown/pluginutils": "1.0.0-beta.45" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4383,38 +4295,38 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-x64": "1.0.0-beta.50", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + "@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" } }, "node_modules/rolldown-plugin-dts": { - "version": "0.17.7", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.7.tgz", - "integrity": "sha512-ZGgXMhzCItmznNzbJlTcC/KdM6bIwcZoYUykJ2q14HOGvnMhnl2RXU+XrIrdjA2Hyzq3nWqDH7qWaM5a4uCVnw==", + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.3.tgz", + "integrity": "sha512-8mGnNUVNrqEdTnrlcaDxs4sAZg0No6njO+FuhQd4L56nUbJO1tHxOoKDH3mmMJg7f/BhEj/1KjU5W9kZ9zM/kQ==", "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", - "dts-resolver": "^2.1.3", + "ast-kit": "^2.1.3", + "birpc": "^2.6.1", + "debug": "^4.4.3", + "dts-resolver": "^2.1.2", "get-tsconfig": "^4.13.0", - "magic-string": "^0.30.21", - "obug": "^1.0.0" + "magic-string": "^0.30.21" }, "engines": { "node": ">=20.18.0" @@ -4445,9 +4357,9 @@ } }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", "dependencies": { @@ -4461,28 +4373,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" } }, @@ -4521,7 +4433,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -4612,7 +4523,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4775,14 +4685,11 @@ "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.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4848,7 +4755,6 @@ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18.12" }, @@ -4857,27 +4763,26 @@ } }, "node_modules/tsdown": { - "version": "0.16.4", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.16.4.tgz", - "integrity": "sha512-tdhy+EQpZSVrVkDRnjKEKVfh1git2HrliGp3SylRNg7kk+lOx3SvT7NLakKX5grPAg8WrZrXWLPUrCegWupqgg==", + "version": "0.15.12", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.15.12.tgz", + "integrity": "sha512-c8VLlQm8/lFrOAg5VMVeN4NAbejZyVQkzd+ErjuaQgJFI/9MhR9ivr0H/CM7UlOF1+ELlF6YaI7sU/4itgGQ8w==", "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", "empathic": "^2.0.0", "hookable": "^5.5.3", - "obug": "^1.0.0", - "rolldown": "1.0.0-beta.50", - "rolldown-plugin-dts": "^0.17.7", + "rolldown": "1.0.0-beta.45", + "rolldown-plugin-dts": "^0.17.2", "semver": "^7.7.3", - "tinyexec": "^1.0.2", + "tinyexec": "^1.0.1", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", - "unconfig-core": "^7.4.1", - "unrun": "^0.2.9" + "unconfig": "^7.3.3" }, "bin": { "tsdown": "dist/run.mjs" @@ -4890,19 +4795,16 @@ }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", - "@vitejs/devtools": "^0.0.0-alpha.17", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", - "unplugin-unused": "^0.5.0" + "unplugin-unused": "^0.5.0", + "unrun": "^0.2.1" }, "peerDependenciesMeta": { "@arethetypeswrong/core": { "optional": true }, - "@vitejs/devtools": { - "optional": true - }, "publint": { "optional": true }, @@ -4914,6 +4816,9 @@ }, "unplugin-unused": { "optional": true + }, + "unrun": { + "optional": true } } }, @@ -4978,7 +4883,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", @@ -4989,16 +4893,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", - "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz", + "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4" + "@typescript-eslint/eslint-plugin": "8.46.2", + "@typescript-eslint/parser": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5012,14 +4916,15 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/unconfig-core": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.4.1.tgz", - "integrity": "sha512-Bp/bPZjV2Vl/fofoA2OYLSnw1Z0MOhCX7zHnVCYrazpfZvseBbGhwcNQMxsg185Mqh7VZQqK3C8hFG/Dyng+yA==", + "node_modules/unconfig": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.3.3.tgz", + "integrity": "sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==", "dev": true, - "license": "MIT", "dependencies": { "@quansync/fs": "^0.1.5", + "defu": "^6.1.4", + "jiti": "^2.5.1", "quansync": "^0.2.11" }, "funding": { @@ -5042,39 +4947,10 @@ "node": ">= 0.8" } }, - "node_modules/unrun": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.9.tgz", - "integrity": "sha512-Cta7uGK/08OqH2O0HLYXs1AfIBp2+2v11ZoeAIqJLUCb9CN+7uxj+CldHCzqAw30b8MJEmWe+BFgK2sl4lJXlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/runtime": "^0.97.0", - "rolldown": "1.0.0-beta.50" - }, - "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", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -5090,11 +4966,10 @@ } }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "esbuild": "^0.25.0", @@ -5166,26 +5041,26 @@ } }, "node_modules/vitest": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", - "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.5.tgz", + "integrity": "sha512-4H+J28MI5oeYgGg3h5BFSkQ1g/2GKK1IR8oorH3a6EQQbb7CwjbnyBjH4PGxw9/6vpwAPNzaeUMp4Js4WJmdXQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.8", - "@vitest/mocker": "4.0.8", - "@vitest/pretty-format": "4.0.8", - "@vitest/runner": "4.0.8", - "@vitest/snapshot": "4.0.8", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", + "@vitest/expect": "4.0.5", + "@vitest/mocker": "4.0.5", + "@vitest/pretty-format": "4.0.5", + "@vitest/runner": "4.0.5", + "@vitest/snapshot": "4.0.5", + "@vitest/spy": "4.0.5", + "@vitest/utils": "4.0.5", "debug": "^4.4.3", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", - "magic-string": "^0.30.21", + "magic-string": "^0.30.19", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", @@ -5206,10 +5081,10 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.8", - "@vitest/browser-preview": "4.0.8", - "@vitest/browser-webdriverio": "4.0.8", - "@vitest/ui": "4.0.8", + "@vitest/browser-playwright": "4.0.5", + "@vitest/browser-preview": "4.0.5", + "@vitest/browser-webdriverio": "4.0.5", + "@vitest/ui": "4.0.5", "happy-dom": "*", "jsdom": "*" }, @@ -5295,7 +5170,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "license": "ISC" }, "node_modules/yocto-queue": { diff --git a/package.json b/package.json index 8c92e7e..7925234 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,14 @@ "@eslint/js": "^9.8.0", "@types/express": "^5.0.3", "@types/node": "^22.10.2", - "@typescript/native-preview": "^7.0.0-dev.20251111.1", + "@typescript/native-preview": "^7.0.0-dev.20251030.1", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "prettier": "3.6.2", - "tsdown": "^0.16.2", + "tsdown": "^0.15.12", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.0.0", + "typescript-eslint": "8.46.2", "vitest": "^4.0.5" }, "dependencies": { From 38443d1a435059485791311a593a852a8d8a518f Mon Sep 17 00:00:00 2001 From: Inna Harper Date: Fri, 14 Nov 2025 16:50:07 +0000 Subject: [PATCH 017/226] remove server requirements, move requirements to schenario description (#30) --- SERVER_REQUIREMENTS.md | 1120 ------------------ package-lock.json | 11 - src/index.ts | 5 +- src/runner/server.ts | 27 +- src/scenarios/server/elicitation-defaults.ts | 25 +- src/scenarios/server/elicitation-enums.ts | 25 +- src/scenarios/server/lifecycle.ts | 13 +- src/scenarios/server/prompts.ts | 119 +- src/scenarios/server/resources.ts | 108 +- src/scenarios/server/tools.ts | 281 ++++- src/scenarios/server/utils.ts | 64 +- 11 files changed, 633 insertions(+), 1165 deletions(-) delete mode 100644 SERVER_REQUIREMENTS.md diff --git a/SERVER_REQUIREMENTS.md b/SERVER_REQUIREMENTS.md deleted file mode 100644 index 9898a22..0000000 --- a/SERVER_REQUIREMENTS.md +++ /dev/null @@ -1,1120 +0,0 @@ -# MCP Server Conformance Requirements - -This document specifies the requirements for building an MCP "Everything Server" for conformance testing. SDK maintainers should implement a server meeting these requirements to enable automated conformance testing. - -## Purpose - -The Everything Server is a reference implementation that: - -- Demonstrates all MCP server features in a single, testable server -- Uses standardized naming conventions for tools, resources, and prompts -- Enables automated conformance testing across different SDK implementations -- Serves as a working example for SDK users - -## Protocol Version - - - -**Target MCP Specification**: `2025-06-18` - -## Transport - -**Required Transport**: Streamable HTTP (for initial conformance testing) - -## Specification Compliance - -This document specifies requirements based on the MCP specification. All features and behaviors described are mandated by the MCP specification itself and enable automated conformance testing. - ---- - -## Server Information - -### Server Identity - -Your server MUST provide (can substitute your SDK name as the server name): - -```json -{ - "name": "mcp-conformance-test-server", - "version": "1.0.0" -} -``` - -### Capabilities Declaration - -Your server MUST declare these capabilities during initialization: - -```json -{ - "capabilities": { - "tools": { - "listChanged": true - }, - "resources": { - "subscribe": true, - "listChanged": true - }, - "prompts": { - "listChanged": true - }, - "logging": {}, - "completions": {} - } -} -``` - -**Note**: All capabilities listed are required for conformance testing. The MCP specification also supports optional `experimental` capabilities for non-standard features, but these are not required for conformance. - ---- - -## 1. Lifecycle Requirements - -### 1.1. Initialize Handshake - -**Endpoint**: `initialize` - -**Requirements**: - -- Accept `initialize` request with client info and capabilities -- Return server info, protocol version, and capabilities -- Protocol version MUST be `"2025-06-18"` - -**Example Response**: - -```json -{ - "protocolVersion": "2025-06-18", - "serverInfo": { - "name": "mcp-conformance-test-server", - "version": "1.0.0" - }, - "capabilities": { - /* as above */ - } -} -``` - -### 1.2. Initialized Notification - -**Notification**: `initialized` - -**Requirements**: - -- Accept `initialized` notification from client after handshake -- No response required (it's a notification) -- Server should be ready for requests after receiving this - ---- - -## 2. Tools Requirements - -### 2.1. List Tools - -**Endpoint**: `tools/list` - -**Requirements**: - -- Return array of all available tools -- Each tool MUST have: - - `name` (string) - - `description` (string) - - `inputSchema` (valid JSON Schema object) - -### 2.2. Call Tool - -**Endpoint**: `tools/call` - -**Requirements**: - -- Accept tool name and arguments -- Execute tool and return result -- Result MUST have `content` array -- Support `_meta.progressToken` for progress reporting (if provided) - -### 2.3. Required Tools - -Implement these tools with exact names: - -#### `test_simple_text` - -**Arguments**: None - -**Returns**: Text content - -```json -{ - "content": [ - { - "type": "text", - "text": "This is a simple text response for testing." - } - ] -} -``` - -#### `test_image_content` - -**Arguments**: None - -**Returns**: Image content with base64 data - -```json -{ - "content": [ - { - "type": "image", - "data": "", - "mimeType": "image/png" - } - ] -} -``` - -**Implementation Note**: Use a minimal test image (e.g., 1x1 red pixel PNG) - -#### `test_audio_content` - -**Arguments**: None - -**Returns**: Audio content with base64 data - -```json -{ - "content": [ - { - "type": "audio", - "data": "", - "mimeType": "audio/wav" - } - ] -} -``` - -**Implementation Note**: Use a minimal test audio file - -#### `test_embedded_resource` - -**Arguments**: None - -**Returns**: Embedded resource content - -```json -{ - "content": [ - { - "type": "resource", - "resource": { - "uri": "test://embedded-resource", - "mimeType": "text/plain", - "text": "This is an embedded resource content." - } - } - ] -} -``` - -#### `test_multiple_content_types` - -**Arguments**: None - -**Returns**: Multiple content items (text + image + resource) - -```json -{ - "content": [ - { - "type": "text", - "text": "Multiple content types test:" - }, - { - "type": "image", - "data": "", - "mimeType": "image/png" - }, - { - "type": "resource", - "resource": { - "uri": "test://mixed-content-resource", - "mimeType": "application/json", - "text": "{\"test\":\"data\",\"value\":123}" - } - } - ] -} -``` - -#### `test_tool_with_logging` - -**Arguments**: None - -**Behavior**: During execution, send 3 log notifications at info level: - -1. "Tool execution started" -2. "Tool processing data" (after ~50ms delay) -3. "Tool execution completed" (after another ~50ms delay) - -**Returns**: Text content confirming execution - -**Implementation Note**: The delays are important to test that clients can receive multiple log notifications during tool execution - -#### `test_tool_with_progress` - -**Arguments**: None - -**Behavior**: If `_meta.progressToken` is provided in request: - -- Send progress notification: `0/100` -- Wait ~50ms -- Send progress notification: `50/100` -- Wait ~50ms -- Send progress notification: `100/100` - -If no progress token provided, just execute with delays. - -**Returns**: Text content confirming execution - -**Progress Notification Format**: - -```json -{ - "method": "notifications/progress", - "params": { - "progressToken": "", - "progress": 50, - "total": 100 - } -} -``` - -#### `test_error_handling` - -**Arguments**: None - -**Behavior**: Always throw an error - -**Returns**: JSON-RPC response with `isError: true` - -```json -{ - "isError": true, - "content": [ - { - "type": "text", - "text": "This tool intentionally returns an error for testing" - } - ] -} -``` - -#### `test_sampling` - -**Arguments**: - -- `prompt` (string, required) - The prompt to send to the LLM - -**Behavior**: Request LLM sampling from the client using `sampling/createMessage` - -**Sampling Request**: - -```json -{ - "method": "sampling/createMessage", - "params": { - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "" - } - } - ], - "maxTokens": 100 - } -} -``` - -**Returns**: Text content with the LLM's response - -```json -{ - "content": [ - { - "type": "text", - "text": "LLM response: " - } - ] -} -``` - -**Implementation Note**: If the client doesn't support sampling (no `sampling` capability), return an error. - -#### `test_elicitation` - -**Arguments**: - -- `message` (string, required) - The message to show the user - -**Behavior**: Request user input from the client using `elicitation/create` - -**Elicitation Request**: - -```json -{ - "method": "elicitation/create", - "params": { - "message": "", - "requestedSchema": { - "type": "object", - "properties": { - "username": { - "type": "string", - "description": "User's response" - }, - "email": { - "type": "string", - "description": "User's email address" - } - }, - "required": ["username", "email"] - } - } -} -``` - -**Returns**: Text content with the user's response - -```json -{ - "content": [ - { - "type": "text", - "text": "User response: " - } - ] -} -``` - -**Implementation Note**: If the client doesn't support elicitation (no `elicitation` capability), return an error. - -#### `test_elicitation_sep1034_defaults` - -**Arguments**: None - -**Behavior**: Request user input from the client using `elicitation/create` with default values for all primitive types (SEP-1034) - -**Elicitation Request**: - -```json -{ - "method": "elicitation/create", - "params": { - "message": "Please review and update the form fields with defaults", - "requestedSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "User name", - "default": "John Doe" - }, - "age": { - "type": "integer", - "description": "User age", - "default": 30 - }, - "score": { - "type": "number", - "description": "User score", - "default": 95.5 - }, - "status": { - "type": "string", - "description": "User status", - "enum": ["active", "inactive", "pending"], - "default": "active" - }, - "verified": { - "type": "boolean", - "description": "Verification status", - "default": true - } - }, - "required": [] - } - } -} -``` - -**Returns**: Text content with the elicitation result - -```json -{ - "content": [ - { - "type": "text", - "text": "Elicitation completed: action=, content={...}" - } - ] -} -``` - -**Implementation Note**: This tool tests SEP-1034 support for default values across all primitive types (string, integer, number, enum, boolean). If the client doesn't support elicitation (no `elicitation` capability), return an error. - -**Reference**: [SEP-1034](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1034) - -#### `test_elicitation_sep1330_enums` - -**Arguments**: None - -**Behavior**: Request user input from the client using `elicitation/create` with enum schema improvements (SEP-1330) - -**Elicitation Request**: - -```json -{ - "method": "elicitation/create", - "params": { - "message": "Please select options from the enum fields", - "requestedSchema": { - "type": "object", - "properties": { - "untitledSingle": { - "type": "string", - "description": "Select one option", - "enum": ["option1", "option2", "option3"] - }, - "titledSingle": { - "type": "string", - "description": "Select one option with titles", - "oneOf": [ - { "const": "value1", "title": "First Option" }, - { "const": "value2", "title": "Second Option" }, - { "const": "value3", "title": "Third Option" } - ] - }, - "legacyEnum": { - "type": "string", - "description": "Select one option (legacy)", - "enum": ["opt1", "opt2", "opt3"], - "enumNames": ["Option One", "Option Two", "Option Three"] - }, - "untitledMulti": { - "type": "array", - "description": "Select multiple options", - "minItems": 1, - "maxItems": 3, - "items": { - "type": "string", - "enum": ["option1", "option2", "option3"] - } - }, - "titledMulti": { - "type": "array", - "description": "Select multiple options with titles", - "minItems": 1, - "maxItems": 3, - "items": { - "anyOf": [ - { "const": "value1", "title": "First Choice" }, - { "const": "value2", "title": "Second Choice" }, - { "const": "value3", "title": "Third Choice" } - ] - } - } - }, - "required": [] - } - } -} -``` - -**Returns**: Text content with the elicitation result - -```json -{ - "content": [ - { - "type": "text", - "text": "Elicitation completed: action=, content={...}" - } - ] -} -``` - -**Implementation Note**: This tool tests SEP-1330 support for enum schema improvements including: - -- Untitled single-select enums (type: string with enum array) -- Titled single-select enums (using oneOf with const/title objects) -- Legacy titled enums (using deprecated enumNames array) -- Untitled multi-select enums (type: array with items.enum) -- Titled multi-select enums (using items.anyOf with const/title objects) - -If the client doesn't support elicitation (no `elicitation` capability), return an error. - -**Reference**: [SEP-1330](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1330) - ---- - -## 3. Resources Requirements - -### 3.1. List Resources - -**Endpoint**: `resources/list` - -**Requirements**: - -- Return array of all available **direct resources** (not templates) -- Support optional cursor-based pagination (see Section 6.1) -- Each resource MUST have: - - `uri` (string) - - `name` (string) - - `description` (string) - - `mimeType` (string, optional) - -**Note**: Resource templates are listed via separate `resources/templates/list` endpoint (see 3.1a) - -### 3.1a. List Resource Templates - -**Endpoint**: `resources/templates/list` - -**Requirements**: - -- Return array of all available **resource templates** -- Support optional cursor-based pagination (see Section 6.1) -- Each template MUST have: - - `uriTemplate` (string) - RFC 6570 URI template - - `name` (string) - - `description` (string) - - `mimeType` (string, optional) - -### 3.2. Read Resource - -**Endpoint**: `resources/read` - -**Requirements**: - -- Accept resource URI -- Return resource contents -- Response MUST have `contents` array -- Each content item MUST have `uri`, `mimeType`, and either `text` or `blob` - -### 3.3. Required Resources - -Implement these resources with exact URIs: - -#### `test://static-text` - -**Type**: Static text resource - -**Metadata** (for `resources/list`): - -```json -{ - "uri": "test://static-text", - "name": "Static Text Resource", - "description": "A static text resource for testing", - "mimeType": "text/plain" -} -``` - -**Content** (for `resources/read`): - -```json -{ - "contents": [ - { - "uri": "test://static-text", - "mimeType": "text/plain", - "text": "This is the content of the static text resource." - } - ] -} -``` - -#### `test://static-binary` - -**Type**: Static binary resource (image) - -**Metadata**: - -```json -{ - "uri": "test://static-binary", - "name": "Static Binary Resource", - "description": "A static binary resource (image) for testing", - "mimeType": "image/png" -} -``` - -**Content**: - -```json -{ - "contents": [ - { - "uri": "test://static-binary", - "mimeType": "image/png", - "blob": "" - } - ] -} -``` - -#### `test://template/{id}/data` - -**Type**: Resource template with parameter - -**Metadata** (for `resources/list`): - -```json -{ - "uriTemplate": "test://template/{id}/data", - "name": "Resource Template", - "description": "A resource template with parameter substitution", - "mimeType": "application/json" -} -``` - -**Behavior**: When client requests `test://template/123/data`, substitute `{id}` with `123` - -**Content** (for `resources/read` with `uri: "test://template/123/data"`): - -```json -{ - "contents": [ - { - "uri": "test://template/123/data", - "mimeType": "application/json", - "text": "{\"id\":\"123\",\"templateTest\":true,\"data\":\"Data for ID: 123\"}" - } - ] -} -``` - -**Implementation Note**: Use RFC 6570 URI template syntax - -#### `test://watched-resource` - -**Type**: Subscribable resource - -**Metadata**: - -```json -{ - "uri": "test://watched-resource", - "name": "Watched Resource", - "description": "A resource that can be subscribed to", - "mimeType": "text/plain" -} -``` - -**Content**: - -```json -{ - "contents": [ - { - "uri": "test://watched-resource", - "mimeType": "text/plain", - "text": "Watched resource content" - } - ] -} -``` - -### 3.4. Resource Subscription - -**Endpoint**: `resources/subscribe` - -**Requirements**: - -- Accept subscription request with URI -- Track subscribed URIs -- Send `notifications/resources/updated` when subscribed resources change -- Return empty object `{}` - -**Example Request**: - -```json -{ - "method": "resources/subscribe", - "params": { - "uri": "test://watched-resource" - } -} -``` - -**Endpoint**: `resources/unsubscribe` - -**Requirements**: - -- Accept unsubscribe request with URI -- Remove URI from subscriptions -- Stop sending update notifications for that URI -- Return empty object `{}` - ---- - -## 4. Prompts Requirements - -### 4.1. List Prompts - -**Endpoint**: `prompts/list` - -**Requirements**: - -- Return array of all available prompts -- Each prompt MUST have: - - `name` (string) - - `description` (string) - - `arguments` (array, optional) - list of required arguments - -### 4.2. Get Prompt - -**Endpoint**: `prompts/get` - -**Requirements**: - -- Accept prompt name and arguments -- Return prompt messages -- Response MUST have `messages` array -- Each message MUST have `role` and `content` - -### 4.3. Required Prompts - -Implement these prompts with exact names: - -#### `test_simple_prompt` - -**Arguments**: None - -**Returns**: - -```json -{ - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "This is a simple prompt for testing." - } - } - ] -} -``` - -#### `test_prompt_with_arguments` - -**Arguments**: - -- `arg1` (string, required) - First test argument -- `arg2` (string, required) - Second test argument - -**Metadata** (for `prompts/list`): - -```json -{ - "name": "test_prompt_with_arguments", - "description": "A prompt with required arguments", - "arguments": [ - { - "name": "arg1", - "description": "First test argument", - "required": true - }, - { - "name": "arg2", - "description": "Second test argument", - "required": true - } - ] -} -``` - -**Returns** (with args `{arg1: "hello", arg2: "world"}`): - -```json -{ - "messages": [ - { - "role": "user", - "content": { - "type": "text", - "text": "Prompt with arguments: arg1='hello', arg2='world'" - } - } - ] -} -``` - -#### `test_prompt_with_embedded_resource` - -**Arguments**: - -- `resourceUri` (string, required) - URI of the resource to embed - -**Returns**: - -```json -{ - "messages": [ - { - "role": "user", - "content": { - "type": "resource", - "resource": { - "uri": "", - "mimeType": "text/plain", - "text": "Embedded resource content for testing." - } - } - }, - { - "role": "user", - "content": { - "type": "text", - "text": "Please process the embedded resource above." - } - } - ] -} -``` - -#### `test_prompt_with_image` - -**Arguments**: None - -**Returns**: - -```json -{ - "messages": [ - { - "role": "user", - "content": { - "type": "image", - "data": "", - "mimeType": "image/png" - } - }, - { - "role": "user", - "content": { - "type": "text", - "text": "Please analyze the image above." - } - } - ] -} -``` - ---- - -## 5. Logging Requirements - -### 5.1. Set Log Level - -**Endpoint**: `logging/setLevel` - -**Requirements**: - -- Accept log level setting -- Filter subsequent log notifications based on level -- Return empty object `{}` - -**Log Levels** (in order of severity): - -- `debug` -- `info` -- `notice` -- `warning` -- `error` -- `critical` -- `alert` -- `emergency` - -### 5.2. Log Notifications - -**Notification**: `notifications/message` - -**Requirements**: - -- Send log notifications during operations -- Each log MUST have: - - `level` (string) - one of the log levels above - - `data` (any) - log message or structured data - - `logger` (string, optional) - logger name - -**Example**: - -```json -{ - "method": "notifications/message", - "params": { - "level": "info", - "logger": "conformance-test-server", - "data": "Tool execution started" - } -} -``` - -**Implementation Note**: When no client is connected, log notifications may fail. Handle this gracefully by catching promise rejections: - -```typescript -mcpServer.server - .notification({ - method: 'notifications/message', - params: { level, logger, data } - }) - .catch(() => { - // Ignore error if no client is connected - }); -``` - ---- - -## 6. Pagination (Utility Feature) - -### 6.1. Cursor-Based Pagination - -**Purpose**: Allow servers to return large result sets in manageable chunks. - -**Applies To**: - -- `tools/list` -- `resources/list` -- `resources/templates/list` -- `prompts/list` - -**Requirements**: - -**Request Format**: - -```json -{ - "method": "tools/list", - "params": { - "cursor": "optional-opaque-token" - } -} -``` - -**Response Format**: - -```json -{ - "tools": [], - "nextCursor": "optional-opaque-token" -} -``` - -**Implementation Requirements**: - -- If `cursor` parameter is provided, return results starting after that position -- If more results are available, include `nextCursor` in response -- Cursor tokens MUST be opaque strings (format is server-defined) -- Page size is determined by server (clients MUST NOT assume fixed page size) -- For conformance testing, pagination is optional (all items can be returned in single response) - -**Example**: - -```ts -// First request (no cursor) -Request: { "method": "tools/list" } -Response: { - "tools": [/* first 10 tools */], - "nextCursor": "page2token" -} - -// Second request (with cursor) -Request: { "method": "tools/list", "params": { "cursor": "page2token" } } -Response: { - "tools": [/* next 10 tools */], - "nextCursor": "page3token" -} - -// Last page (no nextCursor) -Request: { "method": "tools/list", "params": { "cursor": "page3token" } } -Response: { - "tools": [/* remaining tools */] - // No nextCursor = end of results -} -``` - ---- - -## 7. Completion Requirements - -### 6.1. Complete Request - -**Endpoint**: `completion/complete` - -**Requirements**: - -- Accept completion requests for prompt or resource template arguments -- Provide contextual suggestions based on partial input -- Return array of completion values ranked by relevance - -**Request Format**: - -```json -{ - "method": "completion/complete", - "params": { - "ref": { - "type": "ref/prompt", - "name": "test_prompt_with_arguments" - }, - "argument": { - "name": "arg1", - "value": "par" - } - } -} -``` - -**Response Format**: - -```json -{ - "completion": { - "values": ["paris", "park", "party"], - "total": 150, - "hasMore": true - } -} -``` - -**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. - ---- - -## 8. Testing Your Server - -### 8.1. Starting Your Server - -```bash -# Example for TypeScript -cd examples/servers/typescript -npm install -npm start -``` - -Server should output: - -``` -MCP Conformance Test Server running on http://localhost:3000 - - MCP endpoint: http://localhost:3000/mcp -``` - -### 8.2. Running Conformance Tests - -```bash -# Single test -npm run start -- server --url http://localhost:3000/mcp --scenario server-initialize - -# All tests -npm run start -- server --url http://localhost:3000/mcp -``` diff --git a/package-lock.json b/package-lock.json index 98aa27c..899e0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1678,7 +1678,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1897,7 +1896,6 @@ "integrity": "sha512-LKDTZxo2D2U75EZ+8xw0AJmG3VEqUVjlWu43IJLdl+Hjyhh2eFmfufhXw3mwPRAF6hEy+E3ldUtglmmliN/CiA==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsgo": "bin/tsgo.js" }, @@ -2139,7 +2137,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2727,7 +2724,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2946,7 +2942,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -4047,7 +4042,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4283,7 +4277,6 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, - "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -4836,7 +4829,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4883,7 +4875,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4970,7 +4961,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5191,7 +5181,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/index.ts b/src/index.ts index 4dd7f07..427b4b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,7 +98,10 @@ program validated.scenario ); - const { failed } = printServerResults(result.checks); + const { failed } = printServerResults( + result.checks, + result.scenarioDescription + ); process.exit(failed > 0 ? 1 : 0); } else { // Run all active scenarios diff --git a/src/runner/server.ts b/src/runner/server.ts index e153d54..1ca5200 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -4,12 +4,26 @@ import { ConformanceCheck } from '../types'; import { getClientScenario } from '../scenarios'; import { ensureResultsDir, createResultDir } from './utils'; +/** + * Format markdown-style text for terminal output using ANSI codes + */ +function formatMarkdown(text: string): string { + return ( + text + // Bold text: **text** -> bold + .replace(/\*\*([^*]+)\*\*/g, '\x1b[1m$1\x1b[0m') + // Inline code: `code` -> dim/gray + .replace(/`([^`]+)`/g, '\x1b[2m$1\x1b[0m') + ); +} + export async function runServerConformanceTest( serverUrl: string, scenarioName: string ): Promise<{ checks: ConformanceCheck[]; resultDir: string; + scenarioDescription: string; }> { await ensureResultsDir(); const resultDir = createResultDir(scenarioName, 'server'); @@ -33,11 +47,15 @@ export async function runServerConformanceTest( return { checks, - resultDir + resultDir, + scenarioDescription: scenario.description }; } -export function printServerResults(checks: ConformanceCheck[]): { +export function printServerResults( + checks: ConformanceCheck[], + scenarioDescription: string +): { passed: number; failed: number; denominator: number; @@ -54,14 +72,15 @@ export function printServerResults(checks: ConformanceCheck[]): { console.log(`Passed: ${passed}/${denominator}, ${failed} failed`); if (failed > 0) { - console.log('\nFailed Checks:'); + console.log('\n=== Failed Checks ==='); checks .filter((c) => c.status === 'FAILURE') .forEach((c) => { - console.log(` - ${c.name}: ${c.description}`); + console.log(`\n - ${c.name}: ${c.description}`); if (c.errorMessage) { console.log(` Error: ${c.errorMessage}`); } + console.log(`\n${formatMarkdown(scenarioDescription)}`); }); } diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index 742c89d..bd54475 100644 --- a/src/scenarios/server/elicitation-defaults.ts +++ b/src/scenarios/server/elicitation-defaults.ts @@ -8,8 +8,29 @@ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationDefaultsScenario implements ClientScenario { name = 'elicitation-sep1034-defaults'; - description = - 'Test elicitation with default values for all primitive types (SEP-1034)'; + description = `Test elicitation with default values for all primitive types (SEP-1034). + +**Server Implementation Requirements:** + +Implement a tool named \`test_elicitation_sep1034_defaults\` (no arguments) that requests \`elicitation/create\` from the client with a schema containing default values for all primitive types: +- \`name\` (string): default "John Doe" +- \`age\` (integer): default 30 +- \`score\` (number): default 95.5 +- \`status\` (string enum: ["active", "inactive", "pending"]): default "active" +- \`verified\` (boolean): default true + +**Returns**: Text content with the elicitation result + +\`\`\`json +{ + "content": [ + { + "type": "text", + "text": "Elicitation completed: action=, content={...}" + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts index 93bb44c..fa35b08 100644 --- a/src/scenarios/server/elicitation-enums.ts +++ b/src/scenarios/server/elicitation-enums.ts @@ -8,7 +8,30 @@ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationEnumsScenario implements ClientScenario { name = 'elicitation-sep1330-enums'; - description = 'Test elicitation with enum schema improvements (SEP-1330)'; + description = `Test elicitation with enum schema improvements (SEP-1330). + +**Server Implementation Requirements:** + +Implement a tool named \`test_elicitation_sep1330_enums\` (no arguments) that requests \`elicitation/create\` from the client with a schema containing all 5 enum variants: + +1. **Untitled single-select**: \`{ type: "string", enum: ["option1", "option2", "option3"] }\` +2. **Titled single-select**: \`{ type: "string", oneOf: [{ const: "value1", title: "First Option" }, ...] }\` +3. **Legacy titled (deprecated)**: \`{ type: "string", enum: ["opt1", "opt2", "opt3"], enumNames: ["Option One", "Option Two", "Option Three"] }\` +4. **Untitled multi-select**: \`{ type: "array", items: { type: "string", enum: ["option1", "option2", "option3"] } }\` +5. **Titled multi-select**: \`{ type: "array", items: { anyOf: [{ const: "value1", title: "First Choice" }, ...] } }\` + +**Returns**: Text content with the elicitation result + +\`\`\`json +{ + "content": [ + { + "type": "text", + "text": "Elicitation completed: action=, content={...}" + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 6619eda..357c5b3 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -7,7 +7,18 @@ import { connectToServer } from './client-helper.js'; export class ServerInitializeScenario implements ClientScenario { name = 'server-initialize'; - description = 'Test basic server initialization handshake'; + description = `Test basic server initialization handshake. + +**Server Implementation Requirements:** + +**Endpoint**: \`initialize\` + +**Requirements**: +- 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 + +This test verifies the server can complete the two-phase initialization handshake successfully.`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; diff --git a/src/scenarios/server/prompts.ts b/src/scenarios/server/prompts.ts index 2eee80b..1bbb378 100644 --- a/src/scenarios/server/prompts.ts +++ b/src/scenarios/server/prompts.ts @@ -7,7 +7,18 @@ import { connectToServer } from './client-helper.js'; export class PromptsListScenario implements ClientScenario { name = 'prompts-list'; - description = 'Test listing available prompts'; + description = `Test listing available prompts. + +**Server Implementation Requirements:** + +**Endpoint**: \`prompts/list\` + +**Requirements**: +- Return array of all available prompts +- Each prompt MUST have: + - \`name\` (string) + - \`description\` (string) + - \`arguments\` (array, optional) - list of required arguments`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -76,7 +87,25 @@ export class PromptsListScenario implements ClientScenario { export class PromptsGetSimpleScenario implements ClientScenario { name = 'prompts-get-simple'; - description = 'Test getting a simple prompt without arguments'; + description = `Test getting a simple prompt without arguments. + +**Server Implementation Requirements:** + +Implement a prompt named \`test_simple_prompt\` with no arguments that returns: + +\`\`\`json +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "This is a simple prompt for testing." + } + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -142,7 +171,29 @@ export class PromptsGetSimpleScenario implements ClientScenario { export class PromptsGetWithArgsScenario implements ClientScenario { name = 'prompts-get-with-args'; - description = 'Test parameterized prompt'; + description = `Test parameterized prompt. + +**Server Implementation Requirements:** + +Implement a prompt named \`test_prompt_with_arguments\` with arguments: +- \`arg1\` (string, required) - First test argument +- \`arg2\` (string, required) - Second test argument + +Returns (with args \`{arg1: "hello", arg2: "world"}\`): + +\`\`\`json +{ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Prompt with arguments: arg1='hello', arg2='world'" + } + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -215,7 +266,39 @@ export class PromptsGetWithArgsScenario implements ClientScenario { export class PromptsGetEmbeddedResourceScenario implements ClientScenario { name = 'prompts-get-embedded-resource'; - description = 'Test prompt with embedded resource content'; + description = `Test prompt with embedded resource content. + +**Server Implementation Requirements:** + +Implement a prompt named \`test_prompt_with_embedded_resource\` with argument: +- \`resourceUri\` (string, required) - URI of the resource to embed + +Returns: + +\`\`\`json +{ + "messages": [ + { + "role": "user", + "content": { + "type": "resource", + "resource": { + "uri": "", + "mimeType": "text/plain", + "text": "Embedded resource content for testing." + } + } + }, + { + "role": "user", + "content": { + "type": "text", + "text": "Please process the embedded resource above." + } + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -288,7 +371,33 @@ export class PromptsGetEmbeddedResourceScenario implements ClientScenario { export class PromptsGetWithImageScenario implements ClientScenario { name = 'prompts-get-with-image'; - description = 'Test prompt with image content'; + description = `Test prompt with image content. + +**Server Implementation Requirements:** + +Implement a prompt named \`test_prompt_with_image\` with no arguments that returns: + +\`\`\`json +{ + "messages": [ + { + "role": "user", + "content": { + "type": "image", + "data": "", + "mimeType": "image/png" + } + }, + { + "role": "user", + "content": { + "type": "text", + "text": "Please analyze the image above." + } + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index 5d6fd73..b410bb2 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -7,7 +7,19 @@ import { connectToServer } from './client-helper.js'; export class ResourcesListScenario implements ClientScenario { name = 'resources-list'; - description = 'Test listing available resources'; + description = `Test listing available resources. + +**Server Implementation Requirements:** + +**Endpoint**: \`resources/list\` + +**Requirements**: +- Return array of all available **direct resources** (not templates) +- Each resource MUST have: + - \`uri\` (string) + - \`name\` (string) + - \`description\` (string) + - \`mimeType\` (string, optional)`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -75,7 +87,23 @@ export class ResourcesListScenario implements ClientScenario { export class ResourcesReadTextScenario implements ClientScenario { name = 'resources-read-text'; - description = 'Test reading text resource'; + description = `Test reading text resource. + +**Server Implementation Requirements:** + +Implement resource \`test://static-text\` that returns: + +\`\`\`json +{ + "contents": [ + { + "uri": "test://static-text", + "mimeType": "text/plain", + "text": "This is the content of the static text resource." + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -145,7 +173,23 @@ export class ResourcesReadTextScenario implements ClientScenario { export class ResourcesReadBinaryScenario implements ClientScenario { name = 'resources-read-binary'; - description = 'Test reading binary resource'; + description = `Test reading binary resource. + +**Server Implementation Requirements:** + +Implement resource \`test://static-binary\` that returns: + +\`\`\`json +{ + "contents": [ + { + "uri": "test://static-binary", + "mimeType": "image/png", + "blob": "" + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -213,7 +257,27 @@ export class ResourcesReadBinaryScenario implements ClientScenario { export class ResourcesTemplateReadScenario implements ClientScenario { name = 'resources-templates-read'; - description = 'Test reading resource from template'; + description = `Test reading resource from template. + +**Server Implementation Requirements:** + +Implement resource template \`test://template/{id}/data\` that substitutes parameters. + +**Behavior**: When client requests \`test://template/123/data\`, substitute \`{id}\` with \`123\` + +Returns (for \`uri: "test://template/123/data"\`): + +\`\`\`json +{ + "contents": [ + { + "uri": "test://template/123/data", + "mimeType": "application/json", + "text": "{"id":"123","templateTest":true,"data":"Data for ID: 123"}" + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -286,7 +350,27 @@ export class ResourcesTemplateReadScenario implements ClientScenario { export class ResourcesSubscribeScenario implements ClientScenario { name = 'resources-subscribe'; - description = 'Test subscribing to resource updates'; + description = `Test subscribing to resource updates. + +**Server Implementation Requirements:** + +**Endpoint**: \`resources/subscribe\` + +**Requirements**: +- Accept subscription request with URI +- Track subscribed URIs +- Return empty object \`{}\` + +Example request: + +\`\`\`json +{ + "method": "resources/subscribe", + "params": { + "uri": "test://watched-resource" + } +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -336,7 +420,17 @@ export class ResourcesSubscribeScenario implements ClientScenario { export class ResourcesUnsubscribeScenario implements ClientScenario { name = 'resources-unsubscribe'; - description = 'Test unsubscribing from resource'; + description = `Test unsubscribing from resource. + +**Server Implementation Requirements:** + +**Endpoint**: \`resources/unsubscribe\` + +**Requirements**: +- Accept unsubscribe request with URI +- Remove URI from subscriptions +- Stop sending update notifications for that URI +- Return empty object \`{}\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -363,7 +457,7 @@ export class ResourcesUnsubscribeScenario implements ClientScenario { specReferences: [ { id: 'MCP-Resources-Subscribe', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/resources#resource-subscriptions' + url: 'https://modelcontextprotocol.io/specification/2025-06-18/schema#unsubscriberequest' } ] }); diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index db6495c..656df0a 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -13,7 +13,18 @@ import { export class ToolsListScenario implements ClientScenario { name = 'tools-list'; - description = 'Test listing available tools'; + description = `Test listing available tools. + +**Server Implementation Requirements:** + +**Endpoint**: \`tools/list\` + +**Requirements**: +- Return array of all available tools +- Each tool MUST have: + - \`name\` (string) + - \`description\` (string) + - \`inputSchema\` (valid JSON Schema object)`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -84,7 +95,22 @@ export class ToolsListScenario implements ClientScenario { export class ToolsCallSimpleTextScenario implements ClientScenario { name = 'tools-call-simple-text'; - description = 'Test calling a tool that returns simple text'; + description = `Test calling a tool that returns simple text. + +**Server Implementation Requirements:** + +Implement tool \`test_simple_text\` with no arguments that returns: + +\`\`\`json +{ + "content": [ + { + "type": "text", + "text": "This is a simple text response for testing." + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -153,7 +179,25 @@ export class ToolsCallSimpleTextScenario implements ClientScenario { export class ToolsCallImageScenario implements ClientScenario { name = 'tools-call-image'; - description = 'Test calling a tool that returns image content'; + description = `Test calling a tool that returns image content. + +**Server Implementation Requirements:** + +Implement tool \`test_image_content\` with no arguments that returns: + +\`\`\`json +{ + "content": [ + { + "type": "image", + "data": "", + "mimeType": "image/png" + } + ] +} +\`\`\` + +**Implementation Note**: Use a minimal test image (e.g., 1x1 red pixel PNG)`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -222,7 +266,35 @@ export class ToolsCallImageScenario implements ClientScenario { export class ToolsCallMultipleContentTypesScenario implements ClientScenario { name = 'tools-call-mixed-content'; - description = 'Test tool returning multiple content types'; + description = `Test tool returning multiple content types. + +**Server Implementation Requirements:** + +Implement tool \`test_multiple_content_types\` with no arguments that returns: + +\`\`\`json +{ + "content": [ + { + "type": "text", + "text": "Multiple content types test:" + }, + { + "type": "image", + "data": "", + "mimeType": "image/png" + }, + { + "type": "resource", + "resource": { + "uri": "test://mixed-content-resource", + "mimeType": "application/json", + "text": "{"test":"data","value":123}" + } + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -294,7 +366,20 @@ export class ToolsCallMultipleContentTypesScenario implements ClientScenario { export class ToolsCallWithLoggingScenario implements ClientScenario { name = 'tools-call-with-logging'; - description = 'Test tool that sends log messages during execution'; + description = `Test tool that sends log messages during execution. + +**Server Implementation Requirements:** + +Implement tool \`test_tool_with_logging\` with no arguments. + +**Behavior**: During execution, send 3 log notifications at info level: +1. "Tool execution started" +2. "Tool processing data" (after ~50ms delay) +3. "Tool execution completed" (after another ~50ms delay) + +**Returns**: Text content confirming execution + +**Implementation Note**: The delays are important to test that clients can receive multiple log notifications during tool execution`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -366,7 +451,27 @@ export class ToolsCallWithLoggingScenario implements ClientScenario { export class ToolsCallErrorScenario implements ClientScenario { name = 'tools-call-error'; - description = 'Test tool error reporting'; + description = `Test tool error reporting. + +**Server Implementation Requirements:** + +Implement tool \`test_error_handling\` with no arguments. + +**Behavior**: Always throw an error + +**Returns**: JSON-RPC response with \`isError: true\` + +\`\`\`json +{ + "isError": true, + "content": [ + { + "type": "text", + "text": "This tool intentionally returns an error for testing" + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -430,7 +535,35 @@ export class ToolsCallErrorScenario implements ClientScenario { export class ToolsCallWithProgressScenario implements ClientScenario { name = 'tools-call-with-progress'; - description = 'Test tool that reports progress notifications'; + description = `Test tool that reports progress notifications. + +**Server Implementation Requirements:** + +Implement tool \`test_tool_with_progress\` with no arguments. + +**Behavior**: If \`_meta.progressToken\` is provided in request: +- Send progress notification: \`0/100\` +- Wait ~50ms +- Send progress notification: \`50/100\` +- Wait ~50ms +- Send progress notification: \`100/100\` + +If no progress token provided, just execute with delays. + +**Returns**: Text content confirming execution + +**Progress Notification Format**: + +\`\`\`json +{ + "method": "notifications/progress", + "params": { + "progressToken": "", + "progress": 50, + "total": 100 + } +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -521,7 +654,49 @@ export class ToolsCallWithProgressScenario implements ClientScenario { export class ToolsCallSamplingScenario implements ClientScenario { name = 'tools-call-sampling'; - description = 'Test tool that requests LLM sampling from client'; + description = `Test tool that requests LLM sampling from client. + +**Server Implementation Requirements:** + +Implement tool \`test_sampling\` with argument: +- \`prompt\` (string, required) - The prompt to send to the LLM + +**Behavior**: Request LLM sampling from the client using \`sampling/createMessage\` + +**Sampling Request**: + +\`\`\`json +{ + "method": "sampling/createMessage", + "params": { + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "" + } + } + ], + "maxTokens": 100 + } +} +\`\`\` + +**Returns**: Text content with the LLM's response + +\`\`\`json +{ + "content": [ + { + "type": "text", + "text": "LLM response: " + } + ] +} +\`\`\` + +**Implementation Note**: If the client doesn't support sampling (no \`sampling\` capability), return an error.`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -606,7 +781,54 @@ export class ToolsCallSamplingScenario implements ClientScenario { export class ToolsCallElicitationScenario implements ClientScenario { name = 'tools-call-elicitation'; - description = 'Test tool that requests user input (elicitation) from client'; + description = `Test tool that requests user input (elicitation) from client. + +**Server Implementation Requirements:** + +Implement tool \`test_elicitation\` with argument: +- \`message\` (string, required) - The message to show the user + +**Behavior**: Request user input from the client using \`elicitation/create\` + +**Elicitation Request**: + +\`\`\`json +{ + "method": "elicitation/create", + "params": { + "message": "", + "requestedSchema": { + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "User's response" + }, + "email": { + "type": "string", + "description": "User's email address" + } + }, + "required": ["username", "email"] + } + } +} +\`\`\` + +**Returns**: Text content with the user's response + +\`\`\`json +{ + "content": [ + { + "type": "text", + "text": "User response: " + } + ] +} +\`\`\` + +**Implementation Note**: If the client doesn't support elicitation (no \`elicitation\` capability), return an error.`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -689,7 +911,25 @@ export class ToolsCallElicitationScenario implements ClientScenario { export class ToolsCallAudioScenario implements ClientScenario { name = 'tools-call-audio'; - description = 'Test calling a tool that returns audio content'; + description = `Test calling a tool that returns audio content. + +**Server Implementation Requirements:** + +Implement tool \`test_audio_content\` with no arguments that returns: + +\`\`\`json +{ + "content": [ + { + "type": "audio", + "data": "", + "mimeType": "audio/wav" + } + ] +} +\`\`\` + +**Implementation Note**: Use a minimal test audio file`; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -765,7 +1005,26 @@ export class ToolsCallAudioScenario implements ClientScenario { export class ToolsCallEmbeddedResourceScenario implements ClientScenario { name = 'tools-call-embedded-resource'; - description = 'Test calling a tool that returns embedded resource content'; + description = `Test calling a tool that returns embedded resource content. + +**Server Implementation Requirements:** + +Implement tool \`test_embedded_resource\` with no arguments that returns: + +\`\`\`json +{ + "content": [ + { + "type": "resource", + "resource": { + "uri": "test://embedded-resource", + "mimeType": "text/plain", + "text": "This is an embedded resource content." + } + } + ] +} +\`\`\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; diff --git a/src/scenarios/server/utils.ts b/src/scenarios/server/utils.ts index 23e5213..70de452 100644 --- a/src/scenarios/server/utils.ts +++ b/src/scenarios/server/utils.ts @@ -7,7 +7,26 @@ import { connectToServer } from './client-helper.js'; export class LoggingSetLevelScenario implements ClientScenario { name = 'logging-set-level'; - description = 'Test setting logging level'; + description = `Test setting logging level. + +**Server Implementation Requirements:** + +**Endpoint**: \`logging/setLevel\` + +**Requirements**: +- Accept log level setting +- Filter subsequent log notifications based on level +- Return empty object \`{}\` + +**Log Levels** (in order of severity): +- \`debug\` +- \`info\` +- \`notice\` +- \`warning\` +- \`error\` +- \`critical\` +- \`alert\` +- \`emergency\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -66,7 +85,48 @@ export class LoggingSetLevelScenario implements ClientScenario { export class CompletionCompleteScenario implements ClientScenario { name = 'completion-complete'; - description = 'Test completion endpoint'; + description = `Test completion endpoint. + +**Server Implementation Requirements:** + +**Endpoint**: \`completion/complete\` + +**Requirements**: +- Accept completion requests for prompt or resource template arguments +- Provide contextual suggestions based on partial input +- Return array of completion values ranked by relevance + +**Request Format**: + +\`\`\`json +{ + "method": "completion/complete", + "params": { + "ref": { + "type": "ref/prompt", + "name": "test_prompt_with_arguments" + }, + "argument": { + "name": "arg1", + "value": "par" + } + } +} +\`\`\` + +**Response Format**: + +\`\`\`json +{ + "completion": { + "values": ["paris", "park", "party"], + "total": 150, + "hasMore": false + } +} +\`\`\` + +**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 { const checks: ConformanceCheck[] = []; From f7c8c4460f165c1df807aeed037d2e57f9571138 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 14 Nov 2025 18:36:07 +0000 Subject: [PATCH 018/226] [scenario] authz metadata discovery tests: compatibility with 2025-03-26 spec, prm root, and www-authenticate (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * clean up constructor calls for server lifecycle * add back compat test * fix dup logging, rm prm * avoid fallbacks * better error from example client * add final endpoint method * add test for OASM priority order * s/prepack/prepare/ for npx * try switching to .js * make tsdown version happy default * fix lock * fix prmPath issue * back to main package-lock * fix bin path * Add comprehensive auth scenario test suite - Add index.test.ts to test all auth scenarios with auth-test.ts client - Upgrade @modelcontextprotocol/sdk from 1.20.1 to 1.22.0 - All 5 auth scenarios now pass including auth/basic-metadata-var2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * consolidate tests into index.test.ts * rename metadata * fix comments * rework auth scenario listing * add 3rd variation of metadata * fix some type issues introduced by version bump * add reusable spec references * more refs * fix up spec references --------- Co-authored-by: Claude --- .../helpers/ConformanceOAuthProvider.ts | 4 +- .../typescript/helpers/withOAuthRetry.ts | 1 - package-lock.json | 113 +++++++++- package.json | 4 +- src/scenarios/client/auth/basic-dcr.test.ts | 28 --- src/scenarios/client/auth/basic-dcr.ts | 25 +-- .../client/auth/basic-metadata-var1.test.ts | 16 -- .../client/auth/basic-metadata-var1.ts | 73 ------ src/scenarios/client/auth/basic-metadata.ts | 210 ++++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 67 +++--- .../client/auth/helpers/createServer.ts | 56 +++-- .../client/auth/helpers/mockTokenVerifier.ts | 15 +- .../client/auth/helpers/serverLifecycle.ts | 9 +- src/scenarios/client/auth/index.test.ts | 37 +++ src/scenarios/client/auth/index.ts | 20 ++ .../client/auth/march-spec-backcompat.ts | 192 ++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 44 ++++ src/scenarios/index.ts | 9 +- src/scenarios/request-logger.ts | 3 +- src/scenarios/server/resources.ts | 26 ++- 20 files changed, 723 insertions(+), 229 deletions(-) delete mode 100644 src/scenarios/client/auth/basic-dcr.test.ts delete mode 100644 src/scenarios/client/auth/basic-metadata-var1.test.ts delete mode 100644 src/scenarios/client/auth/basic-metadata-var1.ts create mode 100644 src/scenarios/client/auth/basic-metadata.ts create mode 100644 src/scenarios/client/auth/index.test.ts create mode 100644 src/scenarios/client/auth/index.ts create mode 100644 src/scenarios/client/auth/march-spec-backcompat.ts create mode 100644 src/scenarios/client/auth/spec-references.ts diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index 46fdd21..2db99d9 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -67,7 +67,9 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { throw new Error('No auth code in redirect URL'); } } else { - throw new Error('No redirect location received'); + throw new Error( + `No redirect location received, from '${authorizationUrl.toString()}'` + ); } } catch (error) { console.error('Failed to fetch authorization URL:', error); diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index fbdda08..f2f78ae 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -14,7 +14,6 @@ export const handle401 = async ( serverUrl: string | URL ): Promise => { const resourceMetadataUrl = extractResourceMetadataUrl(response); - let result = await auth(provider, { serverUrl, resourceMetadataUrl, diff --git a/package-lock.json b/package-lock.json index 899e0f1..334302d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "0.1.5", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", "express": "^5.1.0", "lefthook": "^2.0.2", "zod": "^3.25.76" }, "bin": { - "conformance": "dist/index.mjs" + "conformance": "dist/index.js" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -838,11 +838,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz", - "integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", + "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", + "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -857,8 +859,38 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } } }, + "node_modules/@modelcontextprotocol/sdk/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==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", @@ -2157,6 +2189,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -2169,6 +2202,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/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==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3034,6 +3106,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -3043,6 +3116,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3521,6 +3610,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -4129,6 +4219,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4241,6 +4332,15 @@ "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", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4942,6 +5042,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 7925234..cedad3c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dist" ], "bin": { - "conformance": "dist/index.mjs" + "conformance": "dist/index.js" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -43,7 +43,7 @@ "vitest": "^4.0.5" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", "express": "^5.1.0", "lefthook": "^2.0.2", diff --git a/src/scenarios/client/auth/basic-dcr.test.ts b/src/scenarios/client/auth/basic-dcr.test.ts deleted file mode 100644 index 6470d15..0000000 --- a/src/scenarios/client/auth/basic-dcr.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - runClientAgainstScenario, - SpawnedClientRunner -} from './test_helpers/testClient.js'; -import path from 'path'; - -describe('PRM Path-Based Discovery', () => { - test('client discovers PRM at path-based location before root', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test.ts' - ); - const runner = new SpawnedClientRunner(clientPath); - await runClientAgainstScenario(runner, 'auth/basic-dcr'); - }); - - test('bad client requests root PRM location', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test-broken1.ts' - ); - const runner = new SpawnedClientRunner(clientPath); - await runClientAgainstScenario(runner, 'auth/basic-dcr', [ - // There will be other failures, but this is the one that matters - 'prm-priority-order' - ]); - }); -}); diff --git a/src/scenarios/client/auth/basic-dcr.ts b/src/scenarios/client/auth/basic-dcr.ts index 77524cc..902fbdb 100644 --- a/src/scenarios/client/auth/basic-dcr.ts +++ b/src/scenarios/client/auth/basic-dcr.ts @@ -4,27 +4,26 @@ import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; import { Request, Response } from 'express'; +import { SpecReferences } from './spec-references.js'; export class AuthBasicDCRScenario implements Scenario { name = 'auth/basic-dcr'; description = 'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required'; - private authServer = new ServerLifecycle(() => this.authBaseUrl); - private server = new ServerLifecycle(() => this.baseUrl); + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - private baseUrl: string = ''; - private authBaseUrl: string = ''; async start(): Promise { this.checks = []; - const authApp = createAuthServer(this.checks, () => this.authBaseUrl); - this.authBaseUrl = await this.authServer.start(authApp); + const authApp = createAuthServer(this.checks, this.authServer.getUrl); + await this.authServer.start(authApp); const app = createServer( this.checks, - () => this.baseUrl, - () => this.authBaseUrl + this.server.getUrl, + this.authServer.getUrl ); // For this scenario, reject PRM requests at root location since we have the path-based PRM. @@ -39,10 +38,8 @@ export class AuthBasicDCRScenario implements Scenario { status: 'FAILURE', timestamp: new Date().toISOString(), specReferences: [ - { - id: 'mcp-authorization-prm', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements' - } + SpecReferences.RFC_PRM_DISCOVERY, + SpecReferences.MCP_PRM_DISCOVERY ], details: { url: req.url, @@ -58,9 +55,9 @@ export class AuthBasicDCRScenario implements Scenario { } ); - this.baseUrl = await this.server.start(app); + await this.server.start(app); - return { serverUrl: `${this.baseUrl}/mcp` }; + return { serverUrl: `${this.server.getUrl()}/mcp` }; } async stop() { diff --git a/src/scenarios/client/auth/basic-metadata-var1.test.ts b/src/scenarios/client/auth/basic-metadata-var1.test.ts deleted file mode 100644 index ee5bac4..0000000 --- a/src/scenarios/client/auth/basic-metadata-var1.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - runClientAgainstScenario, - SpawnedClientRunner -} from './test_helpers/testClient.js'; -import path from 'path'; - -describe('OAuth Metadata at OpenID Configuration Path', () => { - test('client discovers OAuth metadata at OpenID configuration path', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test.ts' - ); - const runner = new SpawnedClientRunner(clientPath); - await runClientAgainstScenario(runner, 'auth/basic-metadata-var1'); - }); -}); diff --git a/src/scenarios/client/auth/basic-metadata-var1.ts b/src/scenarios/client/auth/basic-metadata-var1.ts deleted file mode 100644 index e8ae638..0000000 --- a/src/scenarios/client/auth/basic-metadata-var1.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Scenario, ConformanceCheck } 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'; - -export class AuthBasicMetadataVar1Scenario implements Scenario { - // TODO: name should match what we put in the scenario map - name = 'auth/basic-metadata-var1'; - description = - 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; - private authServer = new ServerLifecycle(() => this.authBaseUrl); - private server = new ServerLifecycle(() => this.baseUrl); - private checks: ConformanceCheck[] = []; - private baseUrl: string = ''; - private authBaseUrl: string = ''; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, () => this.authBaseUrl, { - metadataPath: '/.well-known/openid-configuration', - isOpenIdConfiguration: true - }); - this.authBaseUrl = await this.authServer.start(authApp); - - const app = createServer( - this.checks, - () => this.baseUrl, - () => this.authBaseUrl, - { - // TODO: this will put this path in the WWW-Authenticate header - // but RFC 9728 states that in that case, the resource in the PRM - // must match the URL used to make the request to the resource server. - // We'll need to establish an opinion on whether that means the - // URL for the metadata fetch, or the URL for the MCP endpoint, - // or more generally what are the valid scenarios / combos. - prmPath: '/.well-known/oauth-protected-resource' - } - ); - this.baseUrl = await this.server.start(app); - - return { serverUrl: `${this.baseUrl}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} diff --git a/src/scenarios/client/auth/basic-metadata.ts b/src/scenarios/client/auth/basic-metadata.ts new file mode 100644 index 0000000..828f756 --- /dev/null +++ b/src/scenarios/client/auth/basic-metadata.ts @@ -0,0 +1,210 @@ +import type { Scenario, ConformanceCheck } 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'; + +export class AuthBasicMetadataVar1Scenario implements Scenario { + name = 'auth/basic-metadata-var1'; + description = + 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + metadataPath: '/.well-known/openid-configuration', + isOpenIdConfiguration: true + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} + +export class AuthBasicMetadataVar2Scenario implements Scenario { + name = 'auth/basic-metadata-var2'; + description = + 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at path-based OAuth discovery path'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + metadataPath: '/tenant1/.well-known/openid-configuration', + isOpenIdConfiguration: true, + routePrefix: '/tenant1' + }); + + authApp.get('/.well-known/oauth-authorization-server', (req, res) => { + this.checks.push({ + id: 'authorization-server-metadata-wrong-path', + name: 'AuthorizationServerMetadataWrongPath', + description: + 'Client requested authorization server at the root path when the AS URL has a path-based location', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY + ], + details: { + url: req.url + } + }); + res.status(404).send('Not Found'); + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + () => `${this.authServer.getUrl()}/tenant1`, + { + prmPath: '/.well-known/oauth-protected-resource' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} + +export class AuthBasicMetadataVar3Scenario implements Scenario { + name = 'auth/basic-metadata-var3'; + description = + 'Tests Basic OAuth flow with DCR, PRM at custom location listed in WWW-Authenticate header, OAuth metadata is at nested OpenID discovery path, and no scopes required'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + metadataPath: '/tenant1/.well-known/openid-configuration', + isOpenIdConfiguration: true, + routePrefix: '/tenant1' + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + () => { + return `${this.authServer.getUrl()}/tenant1`; + }, + { + // This is a custom path, so unable to get via probing, it's only available + // via following the `resource_metadata_url` in the WWW-Authenticate header. + // The resource must match the original request URL per RFC 9728. + prmPath: '/custom/metadata/location.json' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 4b0128d..67a88e4 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,10 +1,13 @@ import express, { Request, Response } from 'express'; import type { ConformanceCheck } from '../../../../types.js'; import { createRequestLogger } from '../../../request-logger.js'; +import { SpecReferences } from '../spec-references.js'; export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; + loggingEnabled?: boolean; + routePrefix?: string; } export function createAuthServer( @@ -14,18 +17,29 @@ export function createAuthServer( ): express.Application { const { metadataPath = '/.well-known/oauth-authorization-server', - isOpenIdConfiguration = false + isOpenIdConfiguration = false, + loggingEnabled = true, + routePrefix = '' } = options; + + const authRoutes = { + authorization_endpoint: `${routePrefix}/authorize`, + token_endpoint: `${routePrefix}/token`, + registration_endpoint: `${routePrefix}/register` + }; + const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); - app.use( - createRequestLogger(checks, { - incomingId: 'incoming-auth-request', - outgoingId: 'outgoing-auth-response' - }) - ); + if (loggingEnabled) { + app.use( + createRequestLogger(checks, { + incomingId: 'incoming-auth-request', + outgoingId: 'outgoing-auth-response' + }) + ); + } app.get(metadataPath, (req: Request, res: Response) => { checks.push({ @@ -35,10 +49,8 @@ export function createAuthServer( status: 'SUCCESS', timestamp: new Date().toISOString(), specReferences: [ - { - id: 'RFC-8414', - url: 'https://tools.ietf.org/html/rfc8414' - } + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY ], details: { url: req.url, @@ -48,9 +60,9 @@ export function createAuthServer( const metadata: any = { issuer: getAuthBaseUrl(), - authorization_endpoint: `${getAuthBaseUrl()}/authorize`, - token_endpoint: `${getAuthBaseUrl()}/token`, - registration_endpoint: `${getAuthBaseUrl()}/register`, + authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, + token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, + registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], @@ -67,19 +79,14 @@ export function createAuthServer( res.json(metadata); }); - app.get('/authorize', (req: Request, res: Response) => { + app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => { checks.push({ id: 'authorization-request', name: 'AuthorizationRequest', description: 'Client made authorization request', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-6749-4.1.1', - url: 'https://tools.ietf.org/html/rfc6749#section-4.1.1' - } - ], + specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT], details: { response_type: req.query.response_type, client_id: req.query.client_id, @@ -101,19 +108,14 @@ export function createAuthServer( res.redirect(redirectUrl.toString()); }); - app.post('/token', (req: Request, res: Response) => { + app.post(authRoutes.token_endpoint, (req: Request, res: Response) => { checks.push({ id: 'token-request', name: 'TokenRequest', description: 'Client requested access token', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-6749-4.1.3', - url: 'https://tools.ietf.org/html/rfc6749#section-4.1.3' - } - ], + specReferences: [SpecReferences.OAUTH_2_1_TOKEN], details: { endpoint: '/token', grantType: req.body.grant_type @@ -127,19 +129,14 @@ export function createAuthServer( }); }); - app.post('/register', (req: Request, res: Response) => { + app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => { checks.push({ id: 'client-registration', name: 'ClientRegistration', description: 'Client registered with authorization server', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-7591-2', - url: 'https://tools.ietf.org/html/rfc7591#section-2' - } - ], + specReferences: [SpecReferences.MCP_DCR], details: { endpoint: '/register', clientName: req.body.client_name diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index a73e38d..35417fb 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -6,9 +6,10 @@ import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types.js'; import { createRequestLogger } from '../../../request-logger.js'; import { MockTokenVerifier } from './mockTokenVerifier.js'; +import { SpecReferences } from '../spec-references.js'; export interface ServerOptions { - prmPath?: string; + prmPath?: string | null; } export function createServer( @@ -47,30 +48,37 @@ export function createServer( }) ); - app.get(prmPath, (req: Request, res: Response) => { - checks.push({ - id: 'prm-pathbased-requested', - name: 'PRMPathBasedRequested', - description: 'Client requested PRM metadata at path-based location', - status: 'SUCCESS', - timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'RFC-9728-3', - url: 'https://tools.ietf.org/html/rfc9728#section-3' + if (prmPath !== null) { + app.get(prmPath, (req: Request, res: Response) => { + checks.push({ + id: 'prm-pathbased-requested', + name: 'PRMPathBasedRequested', + description: 'Client requested PRM metadata at path-based location', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_PRM_DISCOVERY, + SpecReferences.MCP_PRM_DISCOVERY + ], + details: { + url: req.url, + path: req.path } - ], - details: { - url: req.url, - path: req.path - } - }); + }); - res.json({ - resource: getBaseUrl(), - authorization_servers: [getAuthServerUrl()] + // Resource is usually $baseUrl/mcp, but if PRM is at the root, + // the resource identifier is the root. + const resource = + prmPath === '/.well-known/oauth-protected-resource' + ? getBaseUrl() + : `${getBaseUrl()}/mcp`; + + res.json({ + resource, + authorization_servers: [getAuthServerUrl()] + }); }); - }); + } app.post('/mcp', async (req: Request, res: Response, next: NextFunction) => { // Apply bearer token auth per-request in order to delay setting PRM URL @@ -79,7 +87,9 @@ export function createServer( const authMiddleware = requireBearerAuth({ verifier: new MockTokenVerifier(checks), requiredScopes: [], - resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + ...(prmPath !== null && { + resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + }) }); authMiddleware(req, res, async (err?: any) => { diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index 251d620..a636ec3 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -1,6 +1,7 @@ import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import type { ConformanceCheck } from '../../../../types.js'; +import { SpecReferences } from '../spec-references.js'; export class MockTokenVerifier implements OAuthTokenVerifier { constructor(private checks: ConformanceCheck[]) {} @@ -13,12 +14,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier { description: 'Client provided valid bearer token', status: 'SUCCESS', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'MCP-Authorization', - url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization' - } - ], + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE], details: { token: token.substring(0, 10) + '...' } @@ -37,12 +33,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier { description: 'Client provided invalid bearer token', status: 'FAILURE', timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'MCP-Authorization', - url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization' - } - ], + specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE], details: { message: 'Token verification failed', token: token ? token.substring(0, 10) + '...' : 'missing' diff --git a/src/scenarios/client/auth/helpers/serverLifecycle.ts b/src/scenarios/client/auth/helpers/serverLifecycle.ts index 74ca986..7fb3886 100644 --- a/src/scenarios/client/auth/helpers/serverLifecycle.ts +++ b/src/scenarios/client/auth/helpers/serverLifecycle.ts @@ -5,7 +5,10 @@ export class ServerLifecycle { private httpServer: any = null; private baseUrl: string = ''; - constructor(private getBaseUrl: () => string) {} + // Arrow function to avoid needing lots of .bind(this) + getUrl = (): string => { + return this.baseUrl; + }; async start(app: express.Application): Promise { this.app = app; @@ -25,8 +28,4 @@ export class ServerLifecycle { } this.app = null; } - - getUrl(): string { - return this.baseUrl; - } } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts new file mode 100644 index 0000000..87c99d5 --- /dev/null +++ b/src/scenarios/client/auth/index.test.ts @@ -0,0 +1,37 @@ +import { authScenariosList } from './index.js'; +import { + runClientAgainstScenario, + SpawnedClientRunner +} from './test_helpers/testClient.js'; +import path from 'path'; + +describe('Client Auth Scenarios', () => { + const clientPath = path.join( + process.cwd(), + 'examples/clients/typescript/auth-test.ts' + ); + + // Generate individual test for each auth scenario + for (const scenario of authScenariosList) { + test(`${scenario.name} passes`, async () => { + const runner = new SpawnedClientRunner(clientPath); + await runClientAgainstScenario(runner, scenario.name); + }); + } +}); + +describe('Negative tests', () => { + test('bad client requests root PRM location', async () => { + const clientPath = path.join( + process.cwd(), + 'examples/clients/typescript/auth-test-broken1.ts' + ); + const runner = new SpawnedClientRunner(clientPath); + await runClientAgainstScenario(runner, 'auth/basic-dcr', [ + // There will be other failures, but this is the one that matters + 'prm-priority-order' + ]); + }); + + // TODO: Add more negative tests here +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts new file mode 100644 index 0000000..04b64b0 --- /dev/null +++ b/src/scenarios/client/auth/index.ts @@ -0,0 +1,20 @@ +import { Scenario } from '../../../types'; +import { AuthBasicDCRScenario } from './basic-dcr.js'; +import { + AuthBasicMetadataVar1Scenario, + AuthBasicMetadataVar2Scenario, + AuthBasicMetadataVar3Scenario +} from './basic-metadata.js'; +import { + Auth20250326OAuthMetadataBackcompatScenario, + Auth20250326OEndpointFallbackScenario +} from './march-spec-backcompat.js'; + +export const authScenariosList: Scenario[] = [ + new AuthBasicDCRScenario(), + new AuthBasicMetadataVar1Scenario(), + new AuthBasicMetadataVar2Scenario(), + new AuthBasicMetadataVar3Scenario(), + new Auth20250326OAuthMetadataBackcompatScenario(), + new Auth20250326OEndpointFallbackScenario() +]; diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts new file mode 100644 index 0000000..0999bdf --- /dev/null +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -0,0 +1,192 @@ +import type { Scenario, ConformanceCheck } 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 express, { Request, Response } from 'express'; +import { SpecReferences } from './spec-references.js'; + +export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { + name = 'auth/2025-03-26-oauth-metadata-backcompat'; + 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 { + 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, { + // 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' + }); + const app = createServer( + this.checks, + this.server.getUrl, + this.server.getUrl, + // Explicitly set to null to indicate no PRM available + { prmPath: null } + ); + app.use(authApp); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_DISCOVERY] + }); + } + } + + return this.checks; + } +} + +export class Auth20250326OEndpointFallbackScenario implements Scenario { + name = 'auth/2025-03-26-oauth-endpoint-fallback'; + 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 { + this.checks = []; + + const app = createServer( + this.checks, + this.server.getUrl, + this.server.getUrl, + { prmPath: null } + ); + + // needed for /token endpoint + app.use(express.urlencoded({ extended: true })); + + app.get('/authorize', (req: Request, res: Response) => { + this.checks.push({ + id: 'authorization-request', + name: 'AuthorizationRequest', + description: 'Client made authorization request to fallback endpoint', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK], + details: { + response_type: req.query.response_type, + client_id: req.query.client_id, + redirect_uri: req.query.redirect_uri, + state: req.query.state, + code_challenge: req.query.code_challenge ? 'present' : 'missing', + code_challenge_method: req.query.code_challenge_method + } + }); + + const redirectUri = req.query.redirect_uri as string; + const state = req.query.state as string; + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.set('code', 'test-auth-code'); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + res.redirect(redirectUrl.toString()); + }); + + app.post('/token', (req: Request, res: Response) => { + this.checks.push({ + id: 'token-request', + name: 'TokenRequest', + description: 'Client requested access token from fallback endpoint', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK], + details: { + endpoint: '/token', + grantType: req.body.grant_type + } + }); + + res.json({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + }); + + app.post('/register', (req: Request, res: Response) => { + this.checks.push({ + id: 'client-registration', + name: 'ClientRegistration', + description: + 'Client registered with authorization server at fallback endpoint', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK], + details: { + endpoint: '/register', + clientName: req.body.client_name + } + }); + + res.status(201).json({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_name: req.body.client_name || 'test-client', + redirect_uris: req.body.redirect_uris || [] + }); + }); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts new file mode 100644 index 0000000..e81e140 --- /dev/null +++ b/src/scenarios/client/auth/spec-references.ts @@ -0,0 +1,44 @@ +import { SpecReference } from '../../../types'; + +export const SpecReferences: { [key: string]: SpecReference } = { + RFC_PRM_DISCOVERY: { + id: 'RFC-9728', + url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.1' + }, + RFC_AUTH_SERVER_METADATA_REQUEST: { + id: 'RFC-8414-metadata-request', + url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1' + }, + LEGACY_2025_03_26_AUTH_DISCOVERY: { + id: 'MCP-2025-03-26-Authorization-metadata-discovery', + url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery' + }, + LEGACY_2025_03_26_AUTH_URL_FALLBACK: { + id: 'MCP-2025-03-26-Authorization-metadata-url-fallback', + url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery' + }, + MCP_PRM_DISCOVERY: { + id: 'MCP-2025-06-18-PRM-discovery', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements' + }, + MCP_AUTH_DISCOVERY: { + id: 'MCP-Authorization-metadata-discovery', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery' + }, + MCP_DCR: { + id: 'MCP-Dynamic-client-registration', + url: 'https://modelcontextprotocol.io/specification/draft/basic/client#dynamic-client-registration' + }, + OAUTH_2_1_AUTHORIZATION_ENDPOINT: { + id: 'OAUTH-2.1-authorization-endpoint', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-authorization-endpoint' + }, + OAUTH_2_1_TOKEN: { + id: 'OAUTH-2.1-token-request', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-token-request' + }, + MCP_ACCESS_TOKEN_USAGE: { + id: 'MCP-Access-token-usage', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage' + } +}; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 13f4aa1..5d50dff 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -1,8 +1,6 @@ import { Scenario, ClientScenario } from '../types.js'; import { InitializeScenario } from './client/initialize.js'; import { ToolsCallScenario } from './client/tools_call.js'; -import { AuthBasicDCRScenario } from './client/auth/basic-dcr.js'; -import { AuthBasicMetadataVar1Scenario } from './client/auth/basic-metadata-var1.js'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js'; // Import all new server test scenarios @@ -47,6 +45,8 @@ import { PromptsGetWithImageScenario } from './server/prompts.js'; +import { authScenariosList } from './client/auth/index.js'; + // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1330) @@ -115,9 +115,8 @@ export const clientScenarios = new Map( const scenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), - new AuthBasicDCRScenario(), - new AuthBasicMetadataVar1Scenario(), - new ElicitationClientDefaultsScenario() + new ElicitationClientDefaultsScenario(), + ...authScenariosList ]; // Scenarios map - built from list diff --git a/src/scenarios/request-logger.ts b/src/scenarios/request-logger.ts index 742db4e..6f63837 100644 --- a/src/scenarios/request-logger.ts +++ b/src/scenarios/request-logger.ts @@ -16,7 +16,8 @@ export function createRequestLogger( let requestDescription = `Received ${req.method} request for ${req.path}`; const requestDetails: any = { method: req.method, - path: req.path + path: req.path, + body: req.body }; // Add query parameters to details if they exist diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index b410bb2..c66e425 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -4,6 +4,10 @@ import { ClientScenario, ConformanceCheck } from '../../types.js'; import { connectToServer } from './client-helper.js'; +import { + TextResourceContents, + BlobResourceContents +} from '@modelcontextprotocol/sdk/types.js'; export class ResourcesListScenario implements ClientScenario { name = 'resources-list'; @@ -122,7 +126,7 @@ Implement resource \`test://static-text\` that returns: errors.push('contents is not an array'); if (result.contents.length === 0) errors.push('contents array is empty'); - const content = result.contents[0]; + const content = result.contents[0] as TextResourceContents | undefined; if (content) { if (!content.uri) errors.push('Content missing uri'); if (!content.mimeType) errors.push('Content missing mimeType'); @@ -206,7 +210,7 @@ Implement resource \`test://static-binary\` that returns: if (!result.contents) errors.push('Missing contents array'); if (result.contents.length === 0) errors.push('contents array is empty'); - const content = result.contents[0]; + const content = result.contents[0] as BlobResourceContents | undefined; if (content) { if (!content.uri) errors.push('Content missing uri'); if (!content.mimeType) errors.push('Content missing mimeType'); @@ -297,11 +301,15 @@ Returns (for \`uri: "test://template/123/data"\`): const content = result.contents[0]; if (content) { if (!content.uri) errors.push('Content missing uri'); - if (!content.text && !content.blob) - errors.push('Content missing text or blob'); + const hasText = 'text' in content; + const hasBlob = 'blob' in content; + if (!hasText && !hasBlob) errors.push('Content missing text or blob'); - // Check if parameter was substituted - const text = content.text || (content.blob ? '[binary]' : ''); + const text = hasText + ? (content as TextResourceContents).text + : hasBlob + ? '[binary]' + : ''; if (typeof text === 'string' && !text.includes('123')) { errors.push('Parameter substitution not reflected in content'); } @@ -322,7 +330,11 @@ Returns (for \`uri: "test://template/123/data"\`): ], details: { uri: content?.uri, - content: content?.text || content?.blob + content: content + ? 'text' in content + ? (content as TextResourceContents).text + : (content as BlobResourceContents).blob + : undefined } }); From 253ede03ad04cd9f049b9e32782bccc6777d7b97 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:19:16 +0000 Subject: [PATCH 019/226] Bump js-yaml in the npm_and_yarn group across 1 directory (#31) Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml). Updates `js-yaml` from 4.1.0 to 4.1.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.1.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 334302d..2232591 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3574,9 +3574,9 @@ } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { From 5c151133bb454d92723754ec5b1813bdb71aa660 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 18 Nov 2025 17:16:23 +0000 Subject: [PATCH 020/226] [auth] Scenarios for scope selection (#36) * wip scope change * rm useless bool * fix-me: package changes for linking * negative test * negative test for multiple scopes * fix displays of warnings * wip step-up * negative test for step up auth * move warning into main request flow * clean up more checks * cleanup step-up scenario * cleanup and simplify * cleanup middleware * inline negative tests * inline the other test * refactor logging and inlining for standalone execution too * skip pending tests --- .../clients/typescript/auth-test-bad-prm.ts | 79 ++++ .../clients/typescript/auth-test-broken1.ts | 99 ---- .../typescript/auth-test-ignore-403.ts | 87 ++++ .../typescript/auth-test-ignore-scope.ts | 77 ++++ .../typescript/auth-test-partial-scopes.ts | 78 ++++ examples/clients/typescript/auth-test.ts | 40 +- .../clients/typescript/helpers/cliRunner.ts | 28 ++ examples/clients/typescript/helpers/logger.ts | 27 ++ .../typescript/helpers/withOAuthRetry.ts | 10 +- package-lock.json | 1 + package.json | 1 + src/runner/client.ts | 30 +- src/runner/server.ts | 8 +- src/runner/utils.ts | 2 + .../client/auth/helpers/createAuthServer.ts | 75 +++- .../client/auth/helpers/createServer.ts | 73 ++- .../client/auth/helpers/mockTokenVerifier.ts | 22 +- src/scenarios/client/auth/index.test.ts | 72 ++- src/scenarios/client/auth/index.ts | 12 +- src/scenarios/client/auth/scope-handling.ts | 423 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 12 + .../client/auth/test_helpers/testClient.ts | 4 +- 22 files changed, 1074 insertions(+), 186 deletions(-) create mode 100644 examples/clients/typescript/auth-test-bad-prm.ts delete mode 100644 examples/clients/typescript/auth-test-broken1.ts create mode 100644 examples/clients/typescript/auth-test-ignore-403.ts create mode 100644 examples/clients/typescript/auth-test-ignore-scope.ts create mode 100644 examples/clients/typescript/auth-test-partial-scopes.ts create mode 100644 examples/clients/typescript/helpers/cliRunner.ts create mode 100644 examples/clients/typescript/helpers/logger.ts create mode 100644 src/scenarios/client/auth/scope-handling.ts diff --git a/examples/clients/typescript/auth-test-bad-prm.ts b/examples/clients/typescript/auth-test-bad-prm.ts new file mode 100644 index 0000000..9ef7e49 --- /dev/null +++ b/examples/clients/typescript/auth-test-bad-prm.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + auth, + UnauthorizedError +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runAsCli } from './helpers/cliRunner.js'; +import { logger } from './helpers/logger.js'; + +/** + * Broken client that always uses root-based PRM discovery. + * BUG: Ignores the resource_metadata URL from WWW-Authenticate header. + */ +export async function runClient(serverUrl: string): Promise { + const handle401Broken = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL + ): Promise => { + // BUG: Use root-based PRM discovery exclusively + const resourceMetadataUrl = new URL( + '/.well-known/oauth-protected-resource', + typeof serverUrl === 'string' ? serverUrl : serverUrl.origin + ); + + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + fetchFn: next + }); + + if (result === 'REDIRECT') { + const authorizationCode = await provider.getAuthCode(); + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + authorizationCode, + fetchFn: next + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + }; + + const client = new Client( + { name: 'test-auth-client-broken', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetry( + 'test-auth-client-broken', + new URL(serverUrl), + handle401Broken + )(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-bad-prm '); diff --git a/examples/clients/typescript/auth-test-broken1.ts b/examples/clients/typescript/auth-test-broken1.ts deleted file mode 100644 index 748cca0..0000000 --- a/examples/clients/typescript/auth-test-broken1.ts +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; -import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; -import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { - auth, - UnauthorizedError -} from '@modelcontextprotocol/sdk/client/auth.js'; - -export const handle401Broken = async ( - response: Response, - provider: ConformanceOAuthProvider, - next: FetchLike, - serverUrl: string | URL -): Promise => { - // BROKEN: Use root-based PRM discovery exclusively, regardless of input. - const resourceMetadataUrl = new URL( - '/.well-known/oauth-protected-resource', - typeof serverUrl === 'string' ? serverUrl : serverUrl.origin - ); - - let result = await auth(provider, { - serverUrl, - resourceMetadataUrl, - fetchFn: next - }); - - if (result === 'REDIRECT') { - // Ordinarily, we'd wait for the callback to be handled here, - // but in our conformance provider, we get the authorization code - // during the redirect handling, so we can go straight to - // retrying the auth step. - // await provider.waitForCallback(); - - const authorizationCode = await provider.getAuthCode(); - - // TODO: this retry logic should be incorporated into the typescript SDK - result = await auth(provider, { - serverUrl, - resourceMetadataUrl, - authorizationCode, - fetchFn: next - }); - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError( - `Authentication failed with result: ${result}` - ); - } - } -}; - -async function main(): Promise { - const serverUrl = process.argv[2]; - - if (!serverUrl) { - console.error('Usage: auth-test '); - process.exit(1); - } - - console.log(`Connecting to MCP server at: ${serverUrl}`); - - const client = new Client( - { - name: 'test-auth-client', - version: '1.0.0' - }, - { - capabilities: {} - } - ); - - // Create a custom fetch that uses the OAuth middleware with retry logic - const oauthFetch = withOAuthRetry( - 'test-auth-client', - new URL(serverUrl), - handle401Broken - )(fetch); - - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - fetch: oauthFetch - }); - - // Connect to the server - OAuth is handled automatically by the middleware - await client.connect(transport); - console.log('✅ Successfully connected to MCP server'); - - await client.listTools(); - console.log('✅ Successfully listed tools'); - - await transport.close(); - console.log('✅ Connection closed successfully'); - - process.exit(0); -} - -main(); diff --git a/examples/clients/typescript/auth-test-ignore-403.ts b/examples/clients/typescript/auth-test-ignore-403.ts new file mode 100644 index 0000000..e78ab28 --- /dev/null +++ b/examples/clients/typescript/auth-test-ignore-403.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + auth, + extractWWWAuthenticateParams, + UnauthorizedError +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runAsCli } from './helpers/cliRunner.js'; +import { logger } from './helpers/logger.js'; + +/** + * Broken client that only responds to 401, not 403. + * BUG: Ignores 403 responses which are used for step-up auth. + */ +export async function runClient(serverUrl: string): Promise { + const handle401Broken = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL + ): Promise => { + // BUG: Only respond to 401, not 403 + if (response.status !== 401) { + return; + } + + 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 + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + }; + + const client = new Client( + { name: 'test-auth-client-broken', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetry( + 'test-auth-client-broken', + new URL(serverUrl), + handle401Broken + )(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'); + + // Call tool to trigger step-up auth + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('✅ Successfully called tool'); + + await transport.close(); + logger.debug('✅ Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-ignore-403 '); diff --git a/examples/clients/typescript/auth-test-ignore-scope.ts b/examples/clients/typescript/auth-test-ignore-scope.ts new file mode 100644 index 0000000..e516625 --- /dev/null +++ b/examples/clients/typescript/auth-test-ignore-scope.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + auth, + extractWWWAuthenticateParams, + UnauthorizedError +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runAsCli } from './helpers/cliRunner.js'; +import { logger } from './helpers/logger.js'; + +/** + * Broken client that ignores the scope from WWW-Authenticate header. + * BUG: Doesn't pass the scope parameter from the 401 response. + */ +export async function runClient(serverUrl: string): Promise { + const handle401Broken = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL + ): Promise => { + // BUG: Don't read the scope from the header + const { resourceMetadataUrl } = extractWWWAuthenticateParams(response); + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + // scope deliberately omitted + fetchFn: next + }); + + if (result === 'REDIRECT') { + const authorizationCode = await provider.getAuthCode(); + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + authorizationCode, + fetchFn: next + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + }; + + const client = new Client( + { name: 'test-auth-client-broken', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetry( + 'test-auth-client-broken', + new URL(serverUrl), + handle401Broken + )(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-ignore-scope '); diff --git a/examples/clients/typescript/auth-test-partial-scopes.ts b/examples/clients/typescript/auth-test-partial-scopes.ts new file mode 100644 index 0000000..fc3b3ab --- /dev/null +++ b/examples/clients/typescript/auth-test-partial-scopes.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + auth, + extractWWWAuthenticateParams, + UnauthorizedError +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runAsCli } from './helpers/cliRunner.js'; +import { logger } from './helpers/logger.js'; + +/** + * Broken client that only requests a subset of scopes. + * BUG: Hardcodes a single scope instead of using all from scopes_supported. + */ +export async function runClient(serverUrl: string): Promise { + const handle401Broken = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL + ): Promise => { + const { resourceMetadataUrl } = extractWWWAuthenticateParams(response); + // BUG: Only request one scope instead of all from scopes_supported + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope: 'mcp:basic', + fetchFn: next + }); + + if (result === 'REDIRECT') { + const authorizationCode = await provider.getAuthCode(); + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope: 'mcp:basic', + authorizationCode, + fetchFn: next + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + }; + + const client = new Client( + { name: 'test-auth-client-broken', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetry( + 'test-auth-client-broken', + new URL(serverUrl), + handle401Broken + )(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-partial-scopes '); diff --git a/examples/clients/typescript/auth-test.ts b/examples/clients/typescript/auth-test.ts index cb0fd2d..29e62a5 100644 --- a/examples/clients/typescript/auth-test.ts +++ b/examples/clients/typescript/auth-test.ts @@ -3,28 +3,18 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { runAsCli } from './helpers/cliRunner.js'; +import { logger } from './helpers/logger.js'; -async function main(): Promise { - const serverUrl = process.argv[2]; - - if (!serverUrl) { - console.error('Usage: auth-test '); - process.exit(1); - } - - console.log(`Connecting to MCP server at: ${serverUrl}`); - +/** + * Well-behaved auth client that follows all OAuth protocols correctly. + */ +export async function runClient(serverUrl: string): Promise { const client = new Client( - { - name: 'test-auth-client', - version: '1.0.0' - }, - { - capabilities: {} - } + { name: 'test-auth-client', version: '1.0.0' }, + { capabilities: {} } ); - // Create a custom fetch that uses the OAuth middleware with retry logic const oauthFetch = withOAuthRetry( 'test-auth-client', new URL(serverUrl) @@ -34,17 +24,17 @@ async function main(): Promise { fetch: oauthFetch }); - // Connect to the server - OAuth is handled automatically by the middleware await client.connect(transport); - console.log('✅ Successfully connected to MCP server'); + logger.debug('✅ Successfully connected to MCP server'); await client.listTools(); - console.log('✅ Successfully listed tools'); + logger.debug('✅ Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('✅ Successfully called tool'); await transport.close(); - console.log('✅ Connection closed successfully'); - - process.exit(0); + logger.debug('✅ Connection closed successfully'); } -main(); +runAsCli(runClient, import.meta.url, 'auth-test '); diff --git a/examples/clients/typescript/helpers/cliRunner.ts b/examples/clients/typescript/helpers/cliRunner.ts new file mode 100644 index 0000000..7a6e8bf --- /dev/null +++ b/examples/clients/typescript/helpers/cliRunner.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from 'url'; + +/** + * Helper to run a client function as a CLI command. + * Only runs if the file is executed directly (not imported). + * Handles argv parsing and exit codes. + */ +export function runAsCli( + clientFn: (serverUrl: string) => Promise, + importMetaUrl: string, + usage: string = 'client ' +): void { + // Check if this file is being run directly + const isMain = process.argv[1] === fileURLToPath(importMetaUrl); + if (!isMain) return; + + const serverUrl = process.argv[2]; + if (!serverUrl) { + console.error(`Usage: ${usage}`); + process.exit(1); + } + clientFn(serverUrl) + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/examples/clients/typescript/helpers/logger.ts b/examples/clients/typescript/helpers/logger.ts new file mode 100644 index 0000000..3b1ff5c --- /dev/null +++ b/examples/clients/typescript/helpers/logger.ts @@ -0,0 +1,27 @@ +/** + * Simple logger with configurable log levels. + * Set to 'error' in tests to suppress debug output. + */ + +export type LogLevel = 'debug' | 'error'; + +let currentLogLevel: LogLevel = 'debug'; + +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLogLevel; +} + +export const logger = { + debug: (...args: unknown[]): void => { + if (currentLogLevel === 'debug') { + console.log(...args); + } + }, + error: (...args: unknown[]): void => { + console.error(...args); + } +}; diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index f2f78ae..c95ab96 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -1,6 +1,6 @@ import { auth, - extractResourceMetadataUrl, + extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; @@ -13,10 +13,11 @@ export const handle401 = async ( next: FetchLike, serverUrl: string | URL ): Promise => { - const resourceMetadataUrl = extractResourceMetadataUrl(response); + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); let result = await auth(provider, { serverUrl, resourceMetadataUrl, + scope, fetchFn: next }); @@ -33,6 +34,7 @@ export const handle401 = async ( result = await auth(provider, { serverUrl, resourceMetadataUrl, + scope, authorizationCode, fetchFn: next }); @@ -87,7 +89,7 @@ export const withOAuthRetry = ( let response = await makeRequest(); // Handle 401 responses by attempting re-authentication - if (response.status === 401) { + if (response.status === 401 || response.status === 403) { const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); @@ -97,7 +99,7 @@ export const withOAuthRetry = ( } // If we still have a 401 after re-auth attempt, throw an error - if (response.status === 401) { + if (response.status === 401 || response.status === 403) { const url = typeof input === 'string' ? input : input.toString(); throw new UnauthorizedError(`Authentication failed for ${url}`); } diff --git a/package-lock.json b/package-lock.json index 2232591..fc75a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/express": "^5.0.3", "@types/node": "^22.10.2", "@typescript/native-preview": "^7.0.0-dev.20251030.1", + "cors": "^2.8.5", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "prettier": "3.6.2", diff --git a/package.json b/package.json index cedad3c..006754b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/express": "^5.0.3", "@types/node": "^22.10.2", "@typescript/native-preview": "^7.0.0-dev.20251030.1", + "cors": "^2.8.5", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "prettier": "3.6.2", diff --git a/src/runner/client.ts b/src/runner/client.ts index d7f8105..07454ff 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -86,10 +86,10 @@ export async function runConformanceTest( // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; - console.log(`Starting scenario: ${scenarioName}`); + console.error(`Starting scenario: ${scenarioName}`); const urls = await scenario.start(); - console.log(`Executing client: ${clientCommand} ${urls.serverUrl}`); + console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); try { const clientOutput = await executeClient( @@ -124,7 +124,7 @@ export async function runConformanceTest( await fs.writeFile(path.join(resultDir, 'stderr.txt'), clientOutput.stderr); - console.log(`Results saved to ${resultDir}`); + console.error(`Results saved to ${resultDir}`); return { checks, @@ -139,35 +139,41 @@ export async function runConformanceTest( export function printClientResults( checks: ConformanceCheck[], verbose: boolean = false -): { passed: number; failed: number; denominator: number } { +): { 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(`Checks:\n${JSON.stringify(checks, null, 2)}`); + // Verbose mode: JSON goes to stdout for piping to jq/jless + console.log(JSON.stringify(checks, null, 2)); } else { - console.log(`Checks:\n${formatPrettyChecks(checks)}`); + // Non-verbose: Pretty checks go to stderr + console.error(`Checks:\n${formatPrettyChecks(checks)}`); } - console.log(`\nTest Results:`); - console.log(`Passed: ${passed}/${denominator}, ${failed} failed`); + // Test results summary goes to stderr + console.error(`\nTest Results:`); + console.error( + `Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings` + ); if (failed > 0) { - console.log('\nFailed Checks:'); + console.error('\nFailed Checks:'); checks .filter((c) => c.status === 'FAILURE') .forEach((c) => { - console.log(` - ${c.name}: ${c.description}`); + console.error(` - ${c.name}: ${c.description}`); if (c.errorMessage) { - console.log(` Error: ${c.errorMessage}`); + console.error(` Error: ${c.errorMessage}`); } }); } - return { passed, failed, denominator }; + return { passed, failed, denominator, warnings }; } export async function runInteractiveMode( diff --git a/src/runner/server.ts b/src/runner/server.ts index 1ca5200..46be558 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -59,17 +59,21 @@ export function printServerResults( 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; console.log(`Checks:\n${JSON.stringify(checks, null, 2)}`); console.log(`\nTest Results:`); - console.log(`Passed: ${passed}/${denominator}, ${failed} failed`); + console.log( + `Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings` + ); if (failed > 0) { console.log('\n=== Failed Checks ==='); @@ -84,7 +88,7 @@ export function printServerResults( }); } - return { passed, failed, denominator }; + return { passed, failed, denominator, warnings }; } export function printServerSummary( diff --git a/src/runner/utils.ts b/src/runner/utils.ts index 3b2b0ae..6841bc6 100644 --- a/src/runner/utils.ts +++ b/src/runner/utils.ts @@ -18,6 +18,8 @@ export function getStatusColor(status: string): string { return COLORS.GREEN; case 'FAILURE': return COLORS.RED; + case 'WARNING': + return COLORS.YELLOW; case 'INFO': return COLORS.BLUE; default: diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 67a88e4..7828990 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -2,12 +2,24 @@ import express, { Request, Response } from 'express'; import type { ConformanceCheck } from '../../../../types.js'; import { createRequestLogger } from '../../../request-logger.js'; import { SpecReferences } from '../spec-references.js'; +import { MockTokenVerifier } from './mockTokenVerifier.js'; export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; loggingEnabled?: boolean; routePrefix?: string; + scopesSupported?: string[]; + tokenVerifier?: MockTokenVerifier; + onTokenRequest?: (requestData: { + scope?: string; + grantType: string; + timestamp: string; + }) => { token: string; scopes: string[] }; + onAuthorizationRequest?: (requestData: { + scope?: string; + timestamp: string; + }) => void; } export function createAuthServer( @@ -19,9 +31,16 @@ export function createAuthServer( metadataPath = '/.well-known/oauth-authorization-server', isOpenIdConfiguration = false, loggingEnabled = true, - routePrefix = '' + routePrefix = '', + scopesSupported, + tokenVerifier, + onTokenRequest, + onAuthorizationRequest } = options; + // Track scopes from the most recent authorization request + let lastAuthorizationScopes: string[] = []; + const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, token_endpoint: `${routePrefix}/token`, @@ -69,6 +88,11 @@ export function createAuthServer( token_endpoint_auth_methods_supported: ['none'] }; + // Add scopes_supported if provided + if (scopesSupported !== undefined) { + metadata.scopes_supported = scopesSupported; + } + // Add OpenID Configuration specific fields if (isOpenIdConfiguration) { metadata.jwks_uri = `${getAuthBaseUrl()}/.well-known/jwks.json`; @@ -80,23 +104,30 @@ export function createAuthServer( }); app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => { + const timestamp = new Date().toISOString(); checks.push({ id: 'authorization-request', name: 'AuthorizationRequest', description: 'Client made authorization request', status: 'SUCCESS', - timestamp: new Date().toISOString(), + timestamp, specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT], details: { - response_type: req.query.response_type, - client_id: req.query.client_id, - redirect_uri: req.query.redirect_uri, - state: req.query.state, - code_challenge: req.query.code_challenge ? 'present' : 'missing', - code_challenge_method: req.query.code_challenge_method + query: req.query } }); + // Track scopes from authorization request for token issuance + const scopeParam = req.query.scope as string | undefined; + lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : []; + + if (onAuthorizationRequest) { + onAuthorizationRequest({ + scope: scopeParam, + timestamp + }); + } + const redirectUri = req.query.redirect_uri as string; const state = req.query.state as string; const redirectUrl = new URL(redirectUri); @@ -109,12 +140,15 @@ export function createAuthServer( }); app.post(authRoutes.token_endpoint, (req: Request, res: Response) => { + const timestamp = new Date().toISOString(); + const requestedScope = req.body.scope; + checks.push({ id: 'token-request', name: 'TokenRequest', description: 'Client requested access token', status: 'SUCCESS', - timestamp: new Date().toISOString(), + timestamp, specReferences: [SpecReferences.OAUTH_2_1_TOKEN], details: { endpoint: '/token', @@ -122,10 +156,29 @@ export function createAuthServer( } }); + let token = `test-token-${Date.now()}`; + let scopes: string[] = lastAuthorizationScopes; + + if (onTokenRequest) { + const result = onTokenRequest({ + scope: requestedScope, + grantType: req.body.grant_type, + timestamp + }); + token = result.token; + scopes = result.scopes; + } + + // Register token with verifier if provided + if (tokenVerifier) { + tokenVerifier.registerToken(token, scopes); + } + res.json({ - access_token: 'test-token', + access_token: token, token_type: 'Bearer', - expires_in: 3600 + expires_in: 3600, + ...(scopes.length > 0 && { scope: scopes.join(' ') }) }); }); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 35417fb..8e26944 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -1,6 +1,12 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + CallToolResult, + ErrorCode, + ListToolsRequestSchema, + McpError +} from '@modelcontextprotocol/sdk/types.js'; import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types.js'; @@ -10,6 +16,11 @@ import { SpecReferences } from '../spec-references.js'; export interface ServerOptions { prmPath?: string | null; + requiredScopes?: string[]; + scopesSupported?: string[]; + includeScopeInWwwAuth?: boolean; + authMiddleware?: express.RequestHandler; + tokenVerifier?: MockTokenVerifier; } export function createServer( @@ -18,7 +29,13 @@ export function createServer( getAuthServerUrl: () => string, options: ServerOptions = {} ): express.Application { - const { prmPath = '/.well-known/oauth-protected-resource/mcp' } = options; + const { + prmPath = '/.well-known/oauth-protected-resource/mcp', + requiredScopes = [], + scopesSupported, + includeScopeInWwwAuth = false, + tokenVerifier + } = options; const server = new Server( { name: 'auth-prm-pathbased-server', @@ -33,10 +50,30 @@ export function createServer( server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: [] + tools: [ + { + name: 'test-tool', + inputSchema: { type: 'object' } + } + ] }; }); + server.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + if (request.params.name === 'test-tool') { + return { + content: [{ type: 'text', text: 'test' }] + }; + } + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} not found` + ); + } + ); + const app = express(); app.use(express.json()); @@ -73,10 +110,16 @@ export function createServer( ? getBaseUrl() : `${getBaseUrl()}/mcp`; - res.json({ + const prmResponse: any = { resource, authorization_servers: [getAuthServerUrl()] - }); + }; + + if (scopesSupported !== undefined) { + prmResponse.scopes_supported = scopesSupported; + } + + res.json(prmResponse); }); } @@ -84,13 +127,19 @@ export function createServer( // Apply bearer token auth per-request in order to delay setting PRM URL // until after the server has started // TODO: Find a way to do this w/ pre-applying middleware. - const authMiddleware = requireBearerAuth({ - verifier: new MockTokenVerifier(checks), - requiredScopes: [], - ...(prmPath !== null && { - resourceMetadataUrl: `${getBaseUrl()}${prmPath}` - }) - }); + const verifier = + tokenVerifier || new MockTokenVerifier(checks, requiredScopes); + + const authMiddleware = + options.authMiddleware ?? + requireBearerAuth({ + verifier, + // Only pass requiredScopes if we want them in the WWW-Authenticate header + requiredScopes: includeScopeInWwwAuth ? requiredScopes : [], + ...(prmPath !== null && { + resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + }) + }); authMiddleware(req, res, async (err?: any) => { if (err) return next(err); diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index a636ec3..2ce5a0e 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -4,10 +4,23 @@ import type { ConformanceCheck } from '../../../../types.js'; import { SpecReferences } from '../spec-references.js'; export class MockTokenVerifier implements OAuthTokenVerifier { - constructor(private checks: ConformanceCheck[]) {} + private tokenScopes: Map = new Map(); + + constructor( + private checks: ConformanceCheck[], + private expectedScopes: string[] = [] + ) {} + + registerToken(token: string, scopes: string[]) { + this.tokenScopes.set(token, scopes); + } async verifyAccessToken(token: string): Promise { - if (token === 'test-token') { + // Accept tokens that start with 'test-token' + if (token.startsWith('test-token')) { + // Get scopes for this token, or use empty array + const scopes = this.tokenScopes.get(token) || []; + this.checks.push({ id: 'valid-bearer-token', name: 'ValidBearerToken', @@ -16,13 +29,14 @@ export class MockTokenVerifier implements OAuthTokenVerifier { timestamp: new Date().toISOString(), specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE], details: { - token: token.substring(0, 10) + '...' + token: token.substring(0, 15) + '...', + scopes } }); return { token, clientId: 'test-client', - scopes: [], + scopes, expiresAt: Math.floor(Date.now() / 1000) + 3600 }; } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 87c99d5..7ac4beb 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,20 +1,39 @@ import { authScenariosList } from './index.js'; import { runClientAgainstScenario, - SpawnedClientRunner + InlineClientRunner } from './test_helpers/testClient.js'; -import path from 'path'; +import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test.js'; +import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm.js'; +import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope.js'; +import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes.js'; +import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403.js'; +import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger.js'; + +beforeAll(() => { + setLogLevel('error'); +}); + +const skipScenarios = new Set([ + // Waiting on typescript-sdk support in bearerAuth middleware to include + // scope in WWW-Authenticate header + // https://github.com/modelcontextprotocol/typescript-sdk/pull/1133 + 'auth/scope-from-www-authenticate', + // Waiting on typescript-sdk support for using scopes_supported from PRM + // to request scopes. + // https://github.com/modelcontextprotocol/typescript-sdk/pull/1133 + 'auth/scope-from-scopes-supported' +]); describe('Client Auth Scenarios', () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test.ts' - ); - // Generate individual test for each auth scenario for (const scenario of authScenariosList) { test(`${scenario.name} passes`, async () => { - const runner = new SpawnedClientRunner(clientPath); + if (skipScenarios.has(scenario.name)) { + // TODO: skip in a native way? + return; + } + const runner = new InlineClientRunner(goodClient); await runClientAgainstScenario(runner, scenario.name); }); } @@ -22,16 +41,39 @@ describe('Client Auth Scenarios', () => { describe('Negative tests', () => { test('bad client requests root PRM location', async () => { - const clientPath = path.join( - process.cwd(), - 'examples/clients/typescript/auth-test-broken1.ts' - ); - const runner = new SpawnedClientRunner(clientPath); + const runner = new InlineClientRunner(badPrmClient); await runClientAgainstScenario(runner, 'auth/basic-dcr', [ - // There will be other failures, but this is the one that matters 'prm-priority-order' ]); }); - // TODO: Add more negative tests here + test('client ignores scope from WWW-Authenticate header', async () => { + const runner = new InlineClientRunner(ignoreScopeClient); + await runClientAgainstScenario(runner, 'auth/scope-from-www-authenticate', [ + 'scope-from-www-authenticate' + ]); + }); + + test('client only requests subset of scopes_supported', async () => { + const runner = new InlineClientRunner(partialScopesClient); + await runClientAgainstScenario(runner, 'auth/scope-from-scopes-supported', [ + 'scope-from-scopes-supported' + ]); + }); + + test('client requests scope even if scopes_supported is empty', async () => { + const runner = new InlineClientRunner(partialScopesClient); + await runClientAgainstScenario( + runner, + 'auth/scope-omitted-when-undefined', + ['scope-omitted-when-undefined'] + ); + }); + + test('client only responds to 401, not 403', async () => { + const runner = new InlineClientRunner(ignore403Client); + await runClientAgainstScenario(runner, 'auth/scope-step-up', [ + 'scope-step-up-escalation' + ]); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 04b64b0..24bce85 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -9,6 +9,12 @@ import { Auth20250326OAuthMetadataBackcompatScenario, Auth20250326OEndpointFallbackScenario } from './march-spec-backcompat.js'; +import { + ScopeFromWwwAuthenticateScenario, + ScopeFromScopesSupportedScenario, + ScopeOmittedWhenUndefinedScenario, + ScopeStepUpAuthScenario +} from './scope-handling.js'; export const authScenariosList: Scenario[] = [ new AuthBasicDCRScenario(), @@ -16,5 +22,9 @@ export const authScenariosList: Scenario[] = [ new AuthBasicMetadataVar2Scenario(), new AuthBasicMetadataVar3Scenario(), new Auth20250326OAuthMetadataBackcompatScenario(), - new Auth20250326OEndpointFallbackScenario() + new Auth20250326OEndpointFallbackScenario(), + new ScopeFromWwwAuthenticateScenario(), + new ScopeFromScopesSupportedScenario(), + new ScopeOmittedWhenUndefinedScenario(), + new ScopeStepUpAuthScenario() ]; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts new file mode 100644 index 0000000..5f97e36 --- /dev/null +++ b/src/scenarios/client/auth/scope-handling.ts @@ -0,0 +1,423 @@ +import type { Scenario, ConformanceCheck } 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 type { Request, Response, NextFunction } from 'express'; + +/** + * Scenario 1: Client uses scope from WWW-Authenticate header + * + * Tests that clients SHOULD follow the scope parameter from the initial + * WWW-Authenticate header in the 401 response, per the scope selection strategy. + */ +export class ScopeFromWwwAuthenticateScenario implements Scenario { + name = 'auth/scope-from-www-authenticate'; + 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 { + this.checks = []; + + const expectedScope = 'mcp:basic'; + const tokenVerifier = new MockTokenVerifier(this.checks, [expectedScope]); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + onAuthorizationRequest: (data) => { + // Check if client used the scope from WWW-Authenticate header + const requestedScopes = data.scope ? data.scope.split(' ') : []; + const usedCorrectScope = requestedScopes.includes(expectedScope); + this.checks.push({ + id: 'scope-from-www-authenticate', + name: 'Client scope selection from WWW-Authenticate header', + description: usedCorrectScope + ? 'Client correctly used the scope parameter from the WWW-Authenticate header' + : 'Client SHOULD use the scope parameter from the WWW-Authenticate header when provided', + status: usedCorrectScope ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY], + details: { + expectedScope, + requestedScope: data.scope || 'none' + } + }); + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [expectedScope], + includeScopeInWwwAuth: true, + tokenVerifier + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + return this.checks; + } +} + +/** + * Scenario 2: Client falls back to scopes_supported when scope not in WWW-Authenticate + * + * Tests that clients SHOULD use all scopes from scopes_supported in the PRM + * when the scope parameter is not available in the WWW-Authenticate header. + */ +export class ScopeFromScopesSupportedScenario implements Scenario { + name = 'auth/scope-from-scopes-supported'; + 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 { + 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, { + tokenVerifier, + onAuthorizationRequest: (data) => { + // Check if client requested all scopes from scopes_supported + const requestedScopes = data.scope ? data.scope.split(' ') : []; + const hasAllScopes = scopesSupported.every((scope) => + requestedScopes.includes(scope) + ); + this.checks.push({ + id: 'scope-from-scopes-supported', + name: 'Client scope selection from scopes_supported', + description: hasAllScopes + ? 'Client correctly used all scopes from scopes_supported in PRM when scope not in WWW-Authenticate' + : 'Client SHOULD use all scopes from scopes_supported when scope not available in WWW-Authenticate header', + status: hasAllScopes ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY], + details: { + scopesSupported: scopesSupported.join(' '), + requestedScope: data.scope || 'none', + ...(hasAllScopes + ? {} + : { + missingScopes: scopesSupported + .filter((s) => !requestedScopes.includes(s)) + .join(' ') + }) + } + }); + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: scopesSupported, + scopesSupported: scopesSupported, + includeScopeInWwwAuth: false, + tokenVerifier + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + return this.checks; + } +} + +/** + * Scenario 3: Client omits scope when scopes_supported is undefined + * + * Tests that clients SHOULD omit the scope parameter when scopes_supported + * is not available in the PRM and scope is not in WWW-Authenticate header. + */ +export class ScopeOmittedWhenUndefinedScenario implements Scenario { + name = 'auth/scope-omitted-when-undefined'; + 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 { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + onAuthorizationRequest: (data) => { + // Check if client omitted scope parameter + const scopeOmitted = !data.scope || data.scope.trim() === ''; + this.checks.push({ + id: 'scope-omitted-when-undefined', + name: 'Client scope omission when scopes_supported undefined', + description: scopeOmitted + ? 'Client correctly omitted scope parameter when scopes_supported is undefined' + : 'Client SHOULD omit scope parameter when scopes_supported is undefined and scope not in WWW-Authenticate', + status: scopeOmitted ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY], + details: { + scopeParameter: scopeOmitted ? 'omitted' : data.scope + } + }); + } + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + scopesSupported: undefined, + includeScopeInWwwAuth: false, + tokenVerifier + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + return this.checks; + } +} + +/** + * Scenario 4: Client performs step-up authentication + * + * Tests that clients handle step-up authentication where: + * - initialize/notifications do not require auth + * - listTools requires mcp:basic scope (401 if missing) + * - tools/call requires mcp:basic + mcp:write scopes (403 if insufficient) + * Client must handle both 401 and 403 responses with different scope requirements + */ +export class ScopeStepUpAuthScenario implements Scenario { + name = 'auth/scope-step-up'; + 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[] = []; + + async start(): Promise { + this.checks = []; + + const initialScope = 'mcp:basic'; + const escalatedScopes = ['mcp:basic', 'mcp:write']; + const tokenVerifier = new MockTokenVerifier(this.checks, escalatedScopes); + let authRequestCount = 0; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + onAuthorizationRequest: (data) => { + authRequestCount++; + const requestedScopes = data.scope ? data.scope.split(' ') : []; + + if (authRequestCount === 1) { + // First auth request - should request mcp:basic from WWW-Authenticate + const usedCorrectScope = requestedScopes.includes(initialScope); + this.checks.push({ + id: 'scope-step-up-initial', + name: 'Client initial scope selection for step-up auth', + description: usedCorrectScope + ? 'Client correctly used scope from WWW-Authenticate header for initial auth' + : 'Client SHOULD use the scope parameter from the WWW-Authenticate header', + status: usedCorrectScope ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY], + details: { + expectedScope: initialScope, + requestedScope: data.scope || 'none' + } + }); + } else if (authRequestCount === 2) { + // Second auth request - should escalate to mcp:basic + mcp:write + const hasAllScopes = escalatedScopes.every((s) => + requestedScopes.includes(s) + ); + 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' + : 'Client SHOULD request additional scopes when receiving 403 with new scope requirements', + status: hasAllScopes ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY], + details: { + expectedScopes: escalatedScopes.join(' '), + requestedScope: data.scope || 'none' + } + }); + } + } + }); + await this.authServer.start(authApp); + + // Inline step-up auth middleware + const resourceMetadataUrl = () => + `${this.server.getUrl()}/.well-known/oauth-protected-resource/mcp`; + + const stepUpMiddleware = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + // Parse body to check method + let body = req.body; + if (typeof body === 'string') { + body = JSON.parse(body); + } + const method = body?.method; + + // Allow initialize and notifications without auth + if (method === 'initialize' || method?.startsWith('notifications/')) { + return next(); + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + // No auth - return 401 with initial scope + return res + .status(401) + .set( + 'WWW-Authenticate', + `Bearer scope="${initialScope}", resource_metadata="${resourceMetadataUrl()}"` + ) + .json({ + error: 'invalid_token', + error_description: 'Missing Authorization header' + }); + } + + const token = authHeader.substring('Bearer '.length); + const authInfo = await tokenVerifier.verifyAccessToken(token); + const tokenScopes = authInfo.scopes || []; + + // Determine required scopes based on method + const isToolCall = method === 'tools/call'; + const requiredScopes = isToolCall ? escalatedScopes : [initialScope]; + + const hasRequiredScopes = requiredScopes.every((s) => + tokenScopes.includes(s) + ); + + if (!hasRequiredScopes) { + // Has token but insufficient scopes - return 403 + return res + .status(403) + .set( + 'WWW-Authenticate', + `Bearer scope="${requiredScopes.join(' ')}", resource_metadata="${resourceMetadataUrl()}"` + ) + .json({ + error: 'insufficient_scope', + error_description: 'Token has insufficient scope' + }); + } + + next(); + }; + + const baseApp = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: escalatedScopes, + scopesSupported: escalatedScopes, + includeScopeInWwwAuth: true, + authMiddleware: stepUpMiddleware, + tokenVerifier + } + ); + + await this.server.start(baseApp); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Emit failure checks if expected auth requests didn't happen + const hasInitialCheck = this.checks.some( + (c) => c.id === 'scope-step-up-initial' + ); + const hasEscalationCheck = this.checks.some( + (c) => c.id === 'scope-step-up-escalation' + ); + + if (!hasInitialCheck) { + this.checks.push({ + id: 'scope-step-up-initial', + name: 'Client initial scope selection for step-up auth', + description: 'Client did not make an initial authorization request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] + }); + } + + if (!hasEscalationCheck) { + this.checks.push({ + id: 'scope-step-up-escalation', + name: 'Client scope escalation for step-up auth', + description: + 'Client did not make a second authorization request for scope escalation', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index e81e140..3642396 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -40,5 +40,17 @@ export const SpecReferences: { [key: string]: SpecReference } = { MCP_ACCESS_TOKEN_USAGE: { id: 'MCP-Access-token-usage', url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage' + }, + MCP_SCOPE_SELECTION_STRATEGY: { + id: 'MCP-Scope-selection-strategy', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-selection-strategy' + }, + MCP_SCOPE_CHALLENGE_HANDLING: { + id: 'MCP-Scope-challenge-handling', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling' + }, + MCP_AUTH_ERROR_HANDLING: { + id: 'MCP-Auth-error-handling', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling' } }; diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 47a2891..6a34c79 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -136,7 +136,9 @@ export async function runClientAgainstScenario( } // Verify that only the expected checks failed - const failures = nonInfoChecks.filter((c) => c.status === 'FAILURE'); + const failures = nonInfoChecks.filter( + (c) => c.status === 'FAILURE' || c.status === 'WARNING' + ); const failureSlugs = failures.map((c) => c.id); // Check that failureSlugs contains all expectedFailureSlugs expect(failureSlugs).toEqual( From 4a4c188ceed47d67187b9b585acd08933efd2778 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 19 Nov 2025 15:02:42 +0000 Subject: [PATCH 021/226] 0.1.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc75a55..88477cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", diff --git a/package.json b/package.json index 006754b..3a2035e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 6239c16d428c1fe80740764714d6ddfde5eea801 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 11:25:27 +0000 Subject: [PATCH 022/226] Move lefthook to devDependencies (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lefthook was incorrectly listed as a production dependency, causing it to install git hooks in users' repositories when they install this conformance testing package. This is undesirable behavior - lefthook should only be used by contributors to this repo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3a2035e..b6ee9cc 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "cors": "^2.8.5", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", + "lefthook": "^2.0.2", "prettier": "3.6.2", "tsdown": "^0.15.12", "tsx": "^4.7.0", @@ -47,7 +48,6 @@ "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", "express": "^5.1.0", - "lefthook": "^2.0.2", "zod": "^3.25.76" } } From 3caf68722da5f281282d5f099ac8677e0cad26ec Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Thu, 20 Nov 2025 03:44:28 -0800 Subject: [PATCH 023/226] Set logging level before calling test_tool_with_logging (#38) --- src/scenarios/server/tools.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index 656df0a..ae07cb2 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -388,6 +388,9 @@ Implement tool \`test_tool_with_logging\` with no arguments. const connection = await connectToServer(serverUrl); const notifications = new NotificationCollector(connection.client); + // Set logging level to debug + await connection.client.setLoggingLevel('debug'); + await connection.client.callTool({ name: 'test_tool_with_logging', arguments: {} From 32f47f422252a2b60b5087a9e9c8da56b36f1eb7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 11:48:01 +0000 Subject: [PATCH 024/226] Use --bin flag for pkg.pr.new to show npx commands (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This shows `npx` commands instead of `npm i` in PR preview comments, which is more appropriate for this CLI tool. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .github/workflows/pr-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-publish.yml b/.github/workflows/pr-publish.yml index 29a33a9..d7e8b83 100644 --- a/.github/workflows/pr-publish.yml +++ b/.github/workflows/pr-publish.yml @@ -24,4 +24,4 @@ jobs: - name: Build run: npm run build - name: Publish - run: npx pkg-pr-new publish + run: npx pkg-pr-new publish --bin From eb4d67a4a9987b8d467bfdd1f4bdfc668c85c128 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 13:10:04 +0000 Subject: [PATCH 025/226] Add --suite option to client command for running tests in parallel (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a --suite option to the client command that runs groups of related tests concurrently and outputs a summary. Currently supports the 'auth' suite. Usage: client --suite auth --command "tsx examples/clients/typescript/auth-test.ts" 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- src/index.ts | 131 +++++++++++++++++++++++++++++++++++++---- src/scenarios/index.ts | 4 ++ 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 427b4b3..1cb6b38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,8 @@ import { import { listScenarios, listClientScenarios, - listActiveClientScenarios + listActiveClientScenarios, + listAuthScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -33,20 +34,129 @@ program 'Run conformance tests against a client implementation or start interactive mode' ) .option('--command ', 'Command to run the client') - .requiredOption('--scenario ', 'Scenario to test') + .option('--scenario ', 'Scenario to test') + .option('--suite ', 'Run a suite of tests in parallel (e.g., "auth")') .option('--timeout ', 'Timeout in milliseconds', '30000') .option('--verbose', 'Show verbose output') .action(async (options) => { try { - // Validate options with Zod + const timeout = parseInt(options.timeout, 10); + const verbose = options.verbose ?? false; + + // Handle suite mode + if (options.suite) { + if (!options.command) { + console.error('--command is required when using --suite'); + process.exit(1); + } + + const suites: Record string[]> = { + auth: listAuthScenarios + }; + + const suiteName = options.suite.toLowerCase(); + if (!suites[suiteName]) { + console.error(`Unknown suite: ${suiteName}`); + console.error(`Available suites: ${Object.keys(suites).join(', ')}`); + process.exit(1); + } + + const scenarios = suites[suiteName](); + console.log( + `Running ${suiteName} suite (${scenarios.length} scenarios) in parallel...\n` + ); + + const results = await Promise.all( + scenarios.map(async (scenarioName) => { + try { + const result = await runConformanceTest( + options.command, + scenarioName, + timeout + ); + return { + scenario: scenarioName, + checks: result.checks, + error: null + }; + } catch (error) { + return { + scenario: scenarioName, + checks: [ + { + id: scenarioName, + name: scenarioName, + description: 'Failed to run scenario', + status: 'FAILURE' as const, + timestamp: new Date().toISOString(), + errorMessage: + error instanceof Error ? error.message : String(error) + } + ], + error + }; + } + }) + ); + + console.log('\n=== SUITE SUMMARY ===\n'); + + let totalPassed = 0; + let totalFailed = 0; + let totalWarnings = 0; + + for (const result of results) { + const passed = result.checks.filter( + (c) => c.status === 'SUCCESS' + ).length; + const failed = result.checks.filter( + (c) => c.status === 'FAILURE' + ).length; + const warnings = result.checks.filter( + (c) => c.status === 'WARNING' + ).length; + + totalPassed += passed; + totalFailed += failed; + totalWarnings += warnings; + + const status = failed === 0 ? '✓' : '✗'; + console.log( + `${status} ${result.scenario}: ${passed} passed, ${failed} failed` + ); + + if (verbose && failed > 0) { + result.checks + .filter((c) => c.status === 'FAILURE') + .forEach((c) => { + console.log( + ` - ${c.name}: ${c.errorMessage || c.description}` + ); + }); + } + } + + console.log( + `\nTotal: ${totalPassed} passed, ${totalFailed} failed, ${totalWarnings} warnings` + ); + process.exit(totalFailed > 0 ? 1 : 0); + } + + // Require either --scenario or --suite + if (!options.scenario) { + console.error('Either --scenario or --suite is required'); + console.error('\nAvailable client scenarios:'); + listScenarios().forEach((s) => console.error(` - ${s}`)); + console.error('\nAvailable suites: auth'); + process.exit(1); + } + + // Validate options with Zod for single scenario mode const validated = ClientOptionsSchema.parse(options); // If no command provided, run in interactive mode if (!validated.command) { - await runInteractiveMode( - validated.scenario, - validated.verbose ?? false - ); + await runInteractiveMode(validated.scenario, verbose); process.exit(0); } @@ -54,13 +164,10 @@ program const result = await runConformanceTest( validated.command, validated.scenario, - validated.timeout ?? 30000 + timeout ); - const { failed } = printClientResults( - result.checks, - validated.verbose ?? false - ); + const { failed } = printClientResults(result.checks, verbose); process.exit(failed > 0 ? 1 : 0); } catch (error) { if (error instanceof ZodError) { diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 5d50dff..16e01ce 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -147,3 +147,7 @@ export function listClientScenarios(): string[] { export function listActiveClientScenarios(): string[] { return activeClientScenariosList.map((scenario) => scenario.name); } + +export function listAuthScenarios(): string[] { + return authScenariosList.map((scenario) => scenario.name); +} From f14895ca0eec7e359dbcca9abda73b6600c2752a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 13:14:01 +0000 Subject: [PATCH 026/226] [auth] Rename basic-dcr scenarios to metadata variants (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * merge metadata tests * [auth] Rename basic-dcr scenarios to metadata variants Consolidate auth metadata discovery scenarios with consistent naming: - AuthBasicDCRScenario → AuthMetadataDefaultScenario (auth/metadata-default) - AuthBasicMetadataVar1Scenario → AuthMetadataVar1Scenario (auth/metadata-var1) - AuthBasicMetadataVar2Scenario → AuthMetadataVar2Scenario (auth/metadata-var2) - AuthBasicMetadataVar3Scenario → AuthMetadataVar3Scenario (auth/metadata-var3) Also updates descriptions to use consistent markdown formatting with bold headers for Registration, PRM, and OAuth metadata fields. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/scenarios/client/auth/basic-dcr.ts | 93 -------- src/scenarios/client/auth/basic-metadata.ts | 210 ----------------- .../client/auth/discovery-metadata.ts | 219 ++++++++++++++++++ .../client/auth/helpers/createServer.ts | 9 +- src/scenarios/client/auth/index.test.ts | 2 +- src/scenarios/client/auth/index.ts | 12 +- 6 files changed, 228 insertions(+), 317 deletions(-) delete mode 100644 src/scenarios/client/auth/basic-dcr.ts delete mode 100644 src/scenarios/client/auth/basic-metadata.ts create mode 100644 src/scenarios/client/auth/discovery-metadata.ts diff --git a/src/scenarios/client/auth/basic-dcr.ts b/src/scenarios/client/auth/basic-dcr.ts deleted file mode 100644 index 902fbdb..0000000 --- a/src/scenarios/client/auth/basic-dcr.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Scenario, ConformanceCheck } 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 { Request, Response } from 'express'; -import { SpecReferences } from './spec-references.js'; - -export class AuthBasicDCRScenario implements Scenario { - name = 'auth/basic-dcr'; - description = - 'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl); - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - this.authServer.getUrl - ); - - // For this scenario, reject PRM requests at root location since we have the path-based PRM. - app.get( - '/.well-known/oauth-protected-resource', - (req: Request, res: Response) => { - this.checks.push({ - id: 'prm-priority-order', - name: 'PRM Priority Order', - description: - 'Client requested PRM metadata at root location on a server with path-based PRM', - status: 'FAILURE', - timestamp: new Date().toISOString(), - specReferences: [ - SpecReferences.RFC_PRM_DISCOVERY, - SpecReferences.MCP_PRM_DISCOVERY - ], - details: { - url: req.url, - path: req.path - } - }); - - // Return 404 to indicate PRM is not available at root location - res.status(404).json({ - error: 'not_found', - error_description: 'PRM metadata not available at root location' - }); - } - ); - - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'prm-pathbased-requested', - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - // TODO: these are redundant... - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - // TODO: ideally we'd add the spec references - }); - } - } - - return this.checks; - } -} diff --git a/src/scenarios/client/auth/basic-metadata.ts b/src/scenarios/client/auth/basic-metadata.ts deleted file mode 100644 index 828f756..0000000 --- a/src/scenarios/client/auth/basic-metadata.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { Scenario, ConformanceCheck } 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'; - -export class AuthBasicMetadataVar1Scenario implements Scenario { - name = 'auth/basic-metadata-var1'; - description = - 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - metadataPath: '/.well-known/openid-configuration', - isOpenIdConfiguration: true - }); - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - this.authServer.getUrl, - { - prmPath: '/.well-known/oauth-protected-resource' - } - ); - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} - -export class AuthBasicMetadataVar2Scenario implements Scenario { - name = 'auth/basic-metadata-var2'; - description = - 'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at path-based OAuth discovery path'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - metadataPath: '/tenant1/.well-known/openid-configuration', - isOpenIdConfiguration: true, - routePrefix: '/tenant1' - }); - - authApp.get('/.well-known/oauth-authorization-server', (req, res) => { - this.checks.push({ - id: 'authorization-server-metadata-wrong-path', - name: 'AuthorizationServerMetadataWrongPath', - description: - 'Client requested authorization server at the root path when the AS URL has a path-based location', - status: 'FAILURE', - timestamp: new Date().toISOString(), - specReferences: [ - SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, - SpecReferences.MCP_AUTH_DISCOVERY - ], - details: { - url: req.url - } - }); - res.status(404).send('Not Found'); - }); - - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - () => `${this.authServer.getUrl()}/tenant1`, - { - prmPath: '/.well-known/oauth-protected-resource' - } - ); - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} - -export class AuthBasicMetadataVar3Scenario implements Scenario { - name = 'auth/basic-metadata-var3'; - description = - 'Tests Basic OAuth flow with DCR, PRM at custom location listed in WWW-Authenticate header, OAuth metadata is at nested OpenID discovery path, and no scopes required'; - private authServer = new ServerLifecycle(); - private server = new ServerLifecycle(); - private checks: ConformanceCheck[] = []; - - async start(): Promise { - this.checks = []; - - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { - metadataPath: '/tenant1/.well-known/openid-configuration', - isOpenIdConfiguration: true, - routePrefix: '/tenant1' - }); - await this.authServer.start(authApp); - - const app = createServer( - this.checks, - this.server.getUrl, - () => { - return `${this.authServer.getUrl()}/tenant1`; - }, - { - // This is a custom path, so unable to get via probing, it's only available - // via following the `resource_metadata_url` in the WWW-Authenticate header. - // The resource must match the original request URL per RFC 9728. - prmPath: '/custom/metadata/location.json' - } - ); - await this.server.start(app); - - return { serverUrl: `${this.server.getUrl()}/mcp` }; - } - - async stop() { - await this.authServer.stop(); - await this.server.stop(); - } - - getChecks(): ConformanceCheck[] { - const expectedSlugs = [ - 'authorization-server-metadata', - 'client-registration', - 'authorization-request', - 'token-request' - ]; - - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - this.checks.push({ - id: slug, - name: `Expected Check Missing: ${slug}`, - description: `Expected Check Missing: ${slug}`, - status: 'FAILURE', - timestamp: new Date().toISOString() - }); - } - } - - return this.checks; - } -} diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts new file mode 100644 index 0000000..56a9694 --- /dev/null +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -0,0 +1,219 @@ +/** + * OAuth Metadata Discovery Scenarios + * + * These scenarios test different combinations of PRM and OAuth metadata locations. + * The configurations are defined in SCENARIO_CONFIGS below and scenarios are + * generated from them. + */ + +import type { Scenario, ConformanceCheck } 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 { Request, Response } from 'express'; + +/** + * Configuration for a metadata discovery scenario. + */ +interface MetadataScenarioConfig { + name: string; + prmLocation: string; + inWwwAuth: boolean; + oauthMetadataLocation: string; + /** Route prefix for the auth server (e.g., '/tenant1') */ + authRoutePrefix?: string; + /** If true, add a trap for root PRM requests */ + trapRootPrm?: boolean; +} + +/** + * Scenario configurations table: + * + * | Scenario | PRM Location | In WWW-Auth | OAuth Metadata Location | + * |------------------|-------------------------------------------|-------------|------------------------------------------------| + * | metadata-default | /.well-known/oauth-protected-resource/mcp | Yes | /.well-known/oauth-authorization-server | + * | metadata-var1 | /.well-known/oauth-protected-resource/mcp | No | /.well-known/openid-configuration | + * | metadata-var2 | /.well-known/oauth-protected-resource | No | /.well-known/oauth-authorization-server/tenant1| + * | metadata-var3 | /custom/metadata/location.json | Yes | /tenant1/.well-known/openid-configuration | + */ +const SCENARIO_CONFIGS: MetadataScenarioConfig[] = [ + { + name: 'metadata-default', + prmLocation: '/.well-known/oauth-protected-resource/mcp', + inWwwAuth: true, + oauthMetadataLocation: '/.well-known/oauth-authorization-server', + trapRootPrm: true + }, + { + name: 'metadata-var1', + prmLocation: '/.well-known/oauth-protected-resource/mcp', + inWwwAuth: false, + oauthMetadataLocation: '/.well-known/openid-configuration' + }, + { + name: 'metadata-var2', + prmLocation: '/.well-known/oauth-protected-resource', + inWwwAuth: false, + oauthMetadataLocation: '/.well-known/oauth-authorization-server/tenant1', + authRoutePrefix: '/tenant1' + }, + { + name: 'metadata-var3', + prmLocation: '/custom/metadata/location.json', + inWwwAuth: true, + oauthMetadataLocation: '/tenant1/.well-known/openid-configuration', + authRoutePrefix: '/tenant1' + } +]; + +/** + * Creates a metadata discovery scenario from configuration. + */ +function createMetadataScenario(config: MetadataScenarioConfig): Scenario { + const authServer = new ServerLifecycle(); + const server = new ServerLifecycle(); + let checks: ConformanceCheck[] = []; + + const routePrefix = config.authRoutePrefix || ''; + const isOpenIdConfiguration = config.oauthMetadataLocation.includes( + 'openid-configuration' + ); + + // Determine if PRM is at path-based location + const isPathBasedPrm = + config.prmLocation === '/.well-known/oauth-protected-resource/mcp'; + + return { + name: `auth/${config.name}`, + description: `Tests Basic OAuth metadata discovery flow. + +**PRM:** ${config.prmLocation}${config.inWwwAuth ? '' : ' (not in WWW-Authenticate)'} +**OAuth metadata:** ${config.oauthMetadataLocation} +`, + + async start(): Promise { + checks = []; + + const authApp = createAuthServer(checks, authServer.getUrl, { + metadataPath: config.oauthMetadataLocation, + isOpenIdConfiguration, + ...(routePrefix && { routePrefix }) + }); + + // If path-based OAuth metadata, trap root requests + if (routePrefix) { + authApp.get('/.well-known/oauth-authorization-server', (req, res) => { + checks.push({ + id: 'authorization-server-metadata-wrong-path', + name: 'AuthorizationServerMetadataWrongPath', + description: + 'Client requested authorization server at the root path when the AS URL has a path-based location', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY + ], + details: { + url: req.url + } + }); + res.status(404).send('Not Found'); + }); + } + + await authServer.start(authApp); + + const getAuthServerUrl = routePrefix + ? () => `${authServer.getUrl()}${routePrefix}` + : authServer.getUrl; + + const app = createServer(checks, server.getUrl, getAuthServerUrl, { + prmPath: config.prmLocation, + includePrmInWwwAuth: config.inWwwAuth + }); + + // Add trap for root PRM requests if configured + if (config.trapRootPrm) { + app.get( + '/.well-known/oauth-protected-resource', + (req: Request, res: Response) => { + checks.push({ + id: 'prm-priority-order', + name: 'PRM Priority Order', + description: + 'Client requested PRM metadata at root location on a server with path-based PRM', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_PRM_DISCOVERY, + SpecReferences.MCP_PRM_DISCOVERY + ], + details: { + url: req.url, + path: req.path + } + }); + + res.status(404).json({ + error: 'not_found', + error_description: 'PRM metadata not available at root location' + }); + } + ); + } + + await server.start(app); + + return { serverUrl: `${server.getUrl()}/mcp` }; + }, + + async stop() { + await authServer.stop(); + await server.stop(); + }, + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + ...(isPathBasedPrm ? ['prm-pathbased-requested'] : []), + 'authorization-server-metadata', + 'client-registration', + 'authorization-request', + 'token-request' + ]; + + for (const slug of expectedSlugs) { + if (!checks.find((c) => c.id === slug)) { + checks.push({ + id: slug, + name: `Expected Check Missing: ${slug}`, + description: `Expected Check Missing: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString() + }); + } + } + + return checks; + } + }; +} + +// Generate scenario instances from configurations +export const AuthMetadataDefaultScenario = createMetadataScenario( + SCENARIO_CONFIGS[0] +); +export const AuthMetadataVar1Scenario = createMetadataScenario( + SCENARIO_CONFIGS[1] +); +export const AuthMetadataVar2Scenario = createMetadataScenario( + SCENARIO_CONFIGS[2] +); +export const AuthMetadataVar3Scenario = createMetadataScenario( + SCENARIO_CONFIGS[3] +); + +// Export all scenarios as an array for convenience +export const metadataScenarios = SCENARIO_CONFIGS.map(createMetadataScenario); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 8e26944..168f9ac 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -18,6 +18,7 @@ export interface ServerOptions { prmPath?: string | null; requiredScopes?: string[]; scopesSupported?: string[]; + includePrmInWwwAuth?: boolean; includeScopeInWwwAuth?: boolean; authMiddleware?: express.RequestHandler; tokenVerifier?: MockTokenVerifier; @@ -33,6 +34,7 @@ export function createServer( prmPath = '/.well-known/oauth-protected-resource/mcp', requiredScopes = [], scopesSupported, + includePrmInWwwAuth = true, includeScopeInWwwAuth = false, tokenVerifier } = options; @@ -136,9 +138,10 @@ export function createServer( verifier, // Only pass requiredScopes if we want them in the WWW-Authenticate header requiredScopes: includeScopeInWwwAuth ? requiredScopes : [], - ...(prmPath !== null && { - resourceMetadataUrl: `${getBaseUrl()}${prmPath}` - }) + ...(includePrmInWwwAuth && + prmPath !== null && { + resourceMetadataUrl: `${getBaseUrl()}${prmPath}` + }) }); authMiddleware(req, res, async (err?: any) => { diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 7ac4beb..addbe23 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -42,7 +42,7 @@ describe('Client Auth Scenarios', () => { describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); - await runClientAgainstScenario(runner, 'auth/basic-dcr', [ + await runClientAgainstScenario(runner, 'auth/metadata-default', [ 'prm-priority-order' ]); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 24bce85..f78d2e1 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -1,10 +1,5 @@ import { Scenario } from '../../../types'; -import { AuthBasicDCRScenario } from './basic-dcr.js'; -import { - AuthBasicMetadataVar1Scenario, - AuthBasicMetadataVar2Scenario, - AuthBasicMetadataVar3Scenario -} from './basic-metadata.js'; +import { metadataScenarios } from './discovery-metadata.js'; import { Auth20250326OAuthMetadataBackcompatScenario, Auth20250326OEndpointFallbackScenario @@ -17,10 +12,7 @@ import { } from './scope-handling.js'; export const authScenariosList: Scenario[] = [ - new AuthBasicDCRScenario(), - new AuthBasicMetadataVar1Scenario(), - new AuthBasicMetadataVar2Scenario(), - new AuthBasicMetadataVar3Scenario(), + ...metadataScenarios, new Auth20250326OAuthMetadataBackcompatScenario(), new Auth20250326OEndpointFallbackScenario(), new ScopeFromWwwAuthenticateScenario(), From 82d83f286bb59939744c5c4c2244656328fee0ae Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 13:31:47 +0000 Subject: [PATCH 027/226] Add metadata suite and relax lefthook requirement (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [auth] Add metadata suite for running metadata discovery tests Adds a new 'metadata' suite that runs just the auth/metadata-* scenarios for faster iteration when testing metadata discovery specifically. Usage: node dist/index.mjs client --suite metadata --command "..." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Don't require lefthook to be installed Set assert_lefthook_installed to false so the hooks gracefully skip if lefthook is not installed on the system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- lefthook.yml | 2 +- src/index.ts | 8 +++++--- src/scenarios/client/auth/discovery-metadata.ts | 5 +++++ src/scenarios/index.ts | 3 +++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index 3399e5f..445ba02 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,7 +1,7 @@ # lefthook.yml # Configuration reference: https://lefthook.dev/configuration/ -assert_lefthook_installed: true +assert_lefthook_installed: false output: - meta # Print lefthook version diff --git a/src/index.ts b/src/index.ts index 1cb6b38..21145d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,8 @@ import { listScenarios, listClientScenarios, listActiveClientScenarios, - listAuthScenarios + listAuthScenarios, + listMetadataScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -51,7 +52,8 @@ program } const suites: Record string[]> = { - auth: listAuthScenarios + auth: listAuthScenarios, + metadata: listMetadataScenarios }; const suiteName = options.suite.toLowerCase(); @@ -147,7 +149,7 @@ program console.error('Either --scenario or --suite is required'); console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); - console.error('\nAvailable suites: auth'); + console.error('\nAvailable suites: auth, metadata'); process.exit(1); } diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 56a9694..86f1d00 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -217,3 +217,8 @@ export const AuthMetadataVar3Scenario = createMetadataScenario( // Export all scenarios as an array for convenience export const metadataScenarios = SCENARIO_CONFIGS.map(createMetadataScenario); + +// Export function to list metadata scenario names (for suite support) +export function listMetadataScenarios(): string[] { + return metadataScenarios.map((s) => s.name); +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 16e01ce..3180c26 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -46,6 +46,7 @@ import { } from './server/prompts.js'; import { authScenariosList } from './client/auth/index.js'; +import { listMetadataScenarios } from './client/auth/discovery-metadata.js'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -151,3 +152,5 @@ export function listActiveClientScenarios(): string[] { export function listAuthScenarios(): string[] { return authScenariosList.map((scenario) => scenario.name); } + +export { listMetadataScenarios }; From 7f0755627fd6e617e0ccfaf4757d1b61f723b298 Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Thu, 20 Nov 2025 15:33:37 +0100 Subject: [PATCH 028/226] Fix example server elicitation conf (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix server elicitation conf * Remove empty elicitation object from server config * Use ElicitResultSchema instead of z.any() for proper validation The elicitation/create response should be validated against ElicitResultSchema which ensures the response has the correct structure (action, content fields) rather than accepting any response. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Paul Carleton Co-authored-by: Claude Co-authored-by: Paul Carleton --- examples/servers/typescript/everything-server.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index be90caf..a7ffdfd 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -13,6 +13,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import express from 'express'; import cors from 'cors'; @@ -369,9 +370,7 @@ function createMcpServer() { } } }, - z - .object({ method: z.literal('elicitation/create') }) - .passthrough() as any + ElicitResultSchema ); const elicitResult = result as any; @@ -445,9 +444,7 @@ function createMcpServer() { } } }, - z - .object({ method: z.literal('elicitation/create') }) - .passthrough() as any + ElicitResultSchema ); const elicitResult = result as any; @@ -544,9 +541,7 @@ function createMcpServer() { } } }, - z - .object({ method: z.literal('elicitation/create') }) - .passthrough() as any + ElicitResultSchema ); const elicitResult = result as any; From 8ef618bfe56f64cf4807305932bfc80e71a83394 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:18:35 +0000 Subject: [PATCH 029/226] fix: sync package-lock.json after lefthook moved to devDeps (#46) --- package-lock.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 88477cb..1c66b8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@modelcontextprotocol/sdk": "^1.22.0", "commander": "^14.0.2", "express": "^5.1.0", - "lefthook": "^2.0.2", "zod": "^3.25.76" }, "bin": { @@ -26,6 +25,7 @@ "cors": "^2.8.5", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", + "lefthook": "^2.0.2", "prettier": "3.6.2", "tsdown": "^0.15.12", "tsx": "^4.7.0", @@ -3635,6 +3635,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.0.2.tgz", "integrity": "sha512-2lrSva53G604ZWjK5kHYvDdwb5GzbhciIPWhebv0A8ceveqSsnG2JgVEt+DnhOPZ4VfNcXvt3/ohFBPNpuAlVw==", + "dev": true, "hasInstallScript": true, "bin": { "lefthook": "bin/index.js" @@ -3659,6 +3660,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3672,6 +3674,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3685,6 +3688,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3698,6 +3702,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3711,6 +3716,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3724,6 +3730,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3737,6 +3744,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3750,6 +3758,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3763,6 +3772,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3776,6 +3786,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ From f087e44bdb6c60cd77206ca0caa2d13efdfb09ea Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:20:06 +0000 Subject: [PATCH 030/226] Add JSON Schema 2020-12 conformance test (SEP-1613) (#45) Add conformance test to validate that MCP servers correctly preserve JSON Schema 2020-12 keywords ($schema, $defs, additionalProperties) in tool definitions. Changes: - Add JsonSchema2020_12Scenario in src/scenarios/server/json-schema-2020-12.ts - Add json_schema_2020_12_tool to everything-server with tools/list override to return raw JSON Schema instead of Zod-converted schema - Register scenario in pendingClientScenariosList (requires SDK PR #1135) The test is added to pending because the client SDK currently strips the JSON Schema 2020-12 keywords. Once PR #1135 is merged into the SDK, this scenario can be moved to the active list. --- .../servers/typescript/everything-server.ts | 138 ++++++++++++- src/scenarios/index.ts | 12 +- src/scenarios/server/json-schema-2020-12.ts | 181 ++++++++++++++++++ 3 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/scenarios/server/json-schema-2020-12.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index a7ffdfd..376434f 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -13,7 +13,13 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + ElicitResultSchema, + ListToolsRequestSchema, + type ListToolsResult, + type Tool +} from '@modelcontextprotocol/sdk/types.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; import { z } from 'zod'; import express from 'express'; import cors from 'cors'; @@ -35,6 +41,28 @@ const TEST_IMAGE_BASE64 = const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; +// SEP-1613: Raw JSON Schema 2020-12 definition for conformance testing +// This schema includes $schema, $defs, and additionalProperties to test +// that SDKs correctly preserve these fields +const JSON_SCHEMA_2020_12_INPUT_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object' as const, + $defs: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' } + } + } + }, + properties: { + name: { type: 'string' }, + address: { $ref: '#/$defs/address' } + }, + additionalProperties: false +}; + // Function to create a new MCP server instance (one per session) function createMcpServer() { const mcpServer = new McpServer( @@ -566,6 +594,40 @@ function createMcpServer() { } ); + // SEP-1613: JSON Schema 2020-12 conformance test tool + // This tool is registered with a Zod schema for tools/call validation, + // but the tools/list handler (below) returns the raw JSON Schema 2020-12 + // definition to test that SDKs preserve $schema, $defs, additionalProperties + mcpServer.registerTool( + 'json_schema_2020_12_tool', + { + description: + 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + inputSchema: { + name: z.string().optional(), + address: z + .object({ + street: z.string().optional(), + city: z.string().optional() + }) + .optional() + } + }, + async (args: { + name?: string; + address?: { street?: string; city?: string }; + }) => { + return { + content: [ + { + type: 'text', + text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` + } + ] + }; + } + ); + // Dynamic tool (registered later via timer) // ===== RESOURCES ===== @@ -829,6 +891,80 @@ function createMcpServer() { } ); + // ===== SEP-1613: Override tools/list to return raw JSON Schema 2020-12 ===== + // This override is necessary because registerTool converts Zod schemas to + // JSON Schema without preserving $schema, $defs, and additionalProperties. + // We need to return the raw JSON Schema for our test tool while using the + // SDK's conversion for other tools. + mcpServer.server.setRequestHandler( + ListToolsRequestSchema, + (): ListToolsResult => { + // Access internal registered tools (this is internal SDK API but stable) + const registeredTools = (mcpServer as any)._registeredTools as Record< + string, + { + enabled: boolean; + title?: string; + description?: string; + inputSchema?: any; + outputSchema?: any; + annotations?: any; + _meta?: any; + } + >; + + return { + tools: Object.entries(registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + // For our SEP-1613 test tool, return raw JSON Schema 2020-12 + if (name === 'json_schema_2020_12_tool') { + return { + name, + description: tool.description, + inputSchema: JSON_SCHEMA_2020_12_INPUT_SCHEMA + }; + } + + // 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: {} }; + } + + return { + name, + title: tool.title, + description: tool.description, + inputSchema, + annotations: tool.annotations, + _meta: tool._meta + }; + }) + }; + } + ); + return mcpServer; } diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 3180c26..1eb41e4 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -25,6 +25,8 @@ import { ToolsCallEmbeddedResourceScenario } from './server/tools.js'; +import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12.js'; + import { ElicitationDefaultsScenario } from './server/elicitation-defaults.js'; import { ElicitationEnumsScenario } from './server/elicitation-enums.js'; @@ -51,7 +53,12 @@ import { listMetadataScenarios } from './client/auth/discovery-metadata.js'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1330) - new ElicitationEnumsScenario() + new ElicitationEnumsScenario(), + + // JSON Schema 2020-12 (SEP-1613) + // This test is pending until the SDK includes PR #1135 which preserves + // $schema, $defs, and additionalProperties fields in tool schemas. + new JsonSchema2020_12Scenario() ]; // All client scenarios @@ -76,6 +83,9 @@ const allClientScenariosList: ClientScenario[] = [ new ToolsCallSamplingScenario(), new ToolsCallElicitationScenario(), + // JSON Schema 2020-12 support (SEP-1613) + new JsonSchema2020_12Scenario(), + // Elicitation scenarios (SEP-1034) new ElicitationDefaultsScenario(), diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts new file mode 100644 index 0000000..2cfd08b --- /dev/null +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -0,0 +1,181 @@ +/** + * JSON Schema 2020-12 conformance test scenario (SEP-1613) + * + * Validates that MCP servers correctly preserve JSON Schema 2020-12 keywords + * in tool definitions, ensuring implementations don't strip $schema, $defs, + * or additionalProperties fields. + */ + +import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { connectToServer } from './client-helper.js'; + +const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool'; +const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; + +export class JsonSchema2020_12Scenario implements ClientScenario { + name = 'json-schema-2020-12'; + description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613). + +**Server Implementation Requirements:** + +Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema 2020-12 features: + +\`\`\`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 + } +} +\`\`\` + +**Verification**: The test verifies that \`$schema\`, \`$defs\`, and \`additionalProperties\` are preserved in the tool listing response.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const specReferences = [ + { + id: 'SEP-1613', + url: 'https://github.com/modelcontextprotocol/specification/pull/655' + } + ]; + + try { + const connection = await connectToServer(serverUrl); + const result = await connection.client.listTools(); + + // Find the test tool + const tool = result.tools?.find((t) => t.name === EXPECTED_TOOL_NAME); + + // Check 1: Tool exists + checks.push({ + id: 'json-schema-2020-12-tool-found', + name: 'JsonSchema2020_12ToolFound', + description: `Server advertises tool '${EXPECTED_TOOL_NAME}'`, + status: tool ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: tool + ? undefined + : `Tool '${EXPECTED_TOOL_NAME}' not found. Available tools: ${result.tools?.map((t) => t.name).join(', ') || 'none'}`, + specReferences, + details: { + toolFound: !!tool, + availableTools: result.tools?.map((t) => t.name) || [] + } + }); + + if (!tool) { + await connection.close(); + return checks; + } + + const inputSchema = tool.inputSchema as Record; + + // Check 2: $schema field preserved + const hasSchema = '$schema' in inputSchema; + const schemaValue = inputSchema['$schema']; + const schemaCorrect = schemaValue === EXPECTED_SCHEMA_DIALECT; + + checks.push({ + id: 'json-schema-2020-12-$schema', + name: 'JsonSchema2020_12$Schema', + description: `inputSchema.$schema field preserved with value '${EXPECTED_SCHEMA_DIALECT}'`, + status: hasSchema && schemaCorrect ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: !hasSchema + ? '$schema field missing from inputSchema - field was likely stripped' + : !schemaCorrect + ? `$schema has unexpected value: ${JSON.stringify(schemaValue)}` + : undefined, + specReferences, + details: { + hasSchema, + schemaValue, + expected: EXPECTED_SCHEMA_DIALECT + } + }); + + // Check 3: $defs field preserved + const hasDefs = '$defs' in inputSchema; + const defsValue = inputSchema['$defs'] as + | Record + | undefined; + const defsHasAddress = defsValue && 'address' in defsValue; + + checks.push({ + id: 'json-schema-2020-12-$defs', + name: 'JsonSchema2020_12$Defs', + description: + 'inputSchema.$defs field preserved with expected structure', + status: hasDefs && defsHasAddress ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: !hasDefs + ? '$defs field missing from inputSchema - field was likely stripped' + : !defsHasAddress + ? '$defs exists but missing expected "address" definition' + : undefined, + specReferences, + details: { + hasDefs, + defsKeys: defsValue ? Object.keys(defsValue) : [], + defsValue + } + }); + + // Check 4: additionalProperties field preserved + const hasAdditionalProps = 'additionalProperties' in inputSchema; + const additionalPropsValue = inputSchema['additionalProperties']; + const additionalPropsCorrect = additionalPropsValue === false; + + checks.push({ + id: 'json-schema-2020-12-additionalProperties', + name: 'JsonSchema2020_12AdditionalProperties', + description: 'inputSchema.additionalProperties field preserved', + status: + hasAdditionalProps && additionalPropsCorrect ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: !hasAdditionalProps + ? 'additionalProperties field missing from inputSchema - field was likely stripped' + : !additionalPropsCorrect + ? `additionalProperties has unexpected value: ${JSON.stringify(additionalPropsValue)}, expected: false` + : undefined, + specReferences, + details: { + hasAdditionalProps, + additionalPropsValue, + expected: false + } + }); + + await connection.close(); + } catch (error) { + checks.push({ + id: 'json-schema-2020-12-error', + name: 'JsonSchema2020_12Error', + description: 'JSON Schema 2020-12 conformance test', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences + }); + } + + return checks; + } +} From d303013e5d141abd9bb62e473832e1510a6ec116 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 21:00:13 +0000 Subject: [PATCH 031/226] [auth] Implement CIMD conformance test (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add cimd test and negative test * Improve auth-test-no-cimd.ts documentation for clarity Address review feedback: expanded comments to better explain what CIMD is, why this client is non-compliant, and what behavior it's testing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Remove .js extensions from relative imports tsx doesn't require .js extensions for TypeScript imports. Keeps .js for @modelcontextprotocol/sdk imports as those are external packages. Also improves auth-test-no-cimd.ts documentation for clarity. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Skip auth/basic-cimd test until SDK support lands Waiting on typescript-sdk PR #1127 for CIMD support. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix trailing whitespace in auth-test-no-cimd.ts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../clients/typescript/auth-test-bad-prm.ts | 8 +- .../typescript/auth-test-ignore-403.ts | 8 +- .../typescript/auth-test-ignore-scope.ts | 8 +- .../clients/typescript/auth-test-no-cimd.ts | 51 ++++++++++ .../typescript/auth-test-partial-scopes.ts | 8 +- examples/clients/typescript/auth-test.ts | 18 +++- .../helpers/ConformanceOAuthProvider.ts | 10 +- .../typescript/helpers/withOAuthRetry.ts | 6 +- src/checks/client.ts | 2 +- src/checks/index.ts | 2 +- src/scenarios/client/auth/basic-cimd.ts | 99 +++++++++++++++++++ .../client/auth/discovery-metadata.ts | 12 +-- .../client/auth/helpers/createAuthServer.ts | 18 +++- .../client/auth/helpers/createServer.ts | 8 +- .../client/auth/helpers/mockTokenVerifier.ts | 4 +- src/scenarios/client/auth/index.test.ts | 29 ++++-- src/scenarios/client/auth/index.ts | 8 +- .../client/auth/march-spec-backcompat.ts | 12 +-- src/scenarios/client/auth/scope-handling.ts | 14 +-- src/scenarios/client/auth/spec-references.ts | 8 ++ .../client/auth/test_helpers/testClient.ts | 2 +- src/scenarios/client/elicitation-defaults.ts | 6 +- src/scenarios/client/initialize.ts | 4 +- src/scenarios/client/tools_call.ts | 6 +- src/scenarios/index.ts | 28 +++--- src/scenarios/request-logger.ts | 2 +- src/scenarios/server/all-scenarios.test.ts | 2 +- src/scenarios/server/elicitation-defaults.ts | 4 +- src/scenarios/server/elicitation-enums.ts | 4 +- src/scenarios/server/lifecycle.ts | 4 +- src/scenarios/server/prompts.ts | 4 +- src/scenarios/server/resources.ts | 4 +- src/scenarios/server/tools.ts | 4 +- src/scenarios/server/utils.ts | 4 +- 34 files changed, 301 insertions(+), 110 deletions(-) create mode 100644 examples/clients/typescript/auth-test-no-cimd.ts create mode 100644 src/scenarios/client/auth/basic-cimd.ts diff --git a/examples/clients/typescript/auth-test-bad-prm.ts b/examples/clients/typescript/auth-test-bad-prm.ts index 9ef7e49..4f65f32 100644 --- a/examples/clients/typescript/auth-test-bad-prm.ts +++ b/examples/clients/typescript/auth-test-bad-prm.ts @@ -7,10 +7,10 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; -import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; -import { runAsCli } from './helpers/cliRunner.js'; -import { logger } from './helpers/logger.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; /** * Broken client that always uses root-based PRM discovery. diff --git a/examples/clients/typescript/auth-test-ignore-403.ts b/examples/clients/typescript/auth-test-ignore-403.ts index e78ab28..08aaa82 100644 --- a/examples/clients/typescript/auth-test-ignore-403.ts +++ b/examples/clients/typescript/auth-test-ignore-403.ts @@ -8,10 +8,10 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; -import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; -import { runAsCli } from './helpers/cliRunner.js'; -import { logger } from './helpers/logger.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; /** * Broken client that only responds to 401, not 403. diff --git a/examples/clients/typescript/auth-test-ignore-scope.ts b/examples/clients/typescript/auth-test-ignore-scope.ts index e516625..c934da4 100644 --- a/examples/clients/typescript/auth-test-ignore-scope.ts +++ b/examples/clients/typescript/auth-test-ignore-scope.ts @@ -8,10 +8,10 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; -import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; -import { runAsCli } from './helpers/cliRunner.js'; -import { logger } from './helpers/logger.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; /** * Broken client that ignores the scope from WWW-Authenticate header. diff --git a/examples/clients/typescript/auth-test-no-cimd.ts b/examples/clients/typescript/auth-test-no-cimd.ts new file mode 100644 index 0000000..11b8f05 --- /dev/null +++ b/examples/clients/typescript/auth-test-no-cimd.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +/** + * Non-compliant client that doesn't use CIMD (Client ID Metadata Document). + * + * This client intentionally omits the clientMetadataUrl parameter when the server + * advertises client_id_metadata_document_supported=true. A compliant client should + * use CIMD when the server supports it, but this client falls back to DCR (Dynamic + * Client Registration) instead. + * + * Used to test that conformance checks detect clients that don't properly + * implement CIMD support. + */ +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-no-cimd', version: '1.0.0' }, + { capabilities: {} } + ); + + // Non-compliant: omitting clientMetadataUrl causes fallback to DCR + // A compliant client would pass a clientMetadataUrl here when the server + // advertises client_id_metadata_document_supported=true + const oauthFetch = withOAuthRetry( + 'test-auth-client-no-cimd', + new URL(serverUrl) + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Connected to MCP server (without CIMD)'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-no-cimd '); diff --git a/examples/clients/typescript/auth-test-partial-scopes.ts b/examples/clients/typescript/auth-test-partial-scopes.ts index fc3b3ab..654ea29 100644 --- a/examples/clients/typescript/auth-test-partial-scopes.ts +++ b/examples/clients/typescript/auth-test-partial-scopes.ts @@ -8,10 +8,10 @@ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; -import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; -import { runAsCli } from './helpers/cliRunner.js'; -import { logger } from './helpers/logger.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; /** * Broken client that only requests a subset of scopes. diff --git a/examples/clients/typescript/auth-test.ts b/examples/clients/typescript/auth-test.ts index 29e62a5..3efe71e 100644 --- a/examples/clients/typescript/auth-test.ts +++ b/examples/clients/typescript/auth-test.ts @@ -2,9 +2,17 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; -import { runAsCli } from './helpers/cliRunner.js'; -import { logger } from './helpers/logger.js'; +import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +/** + * Fixed client metadata URL for CIMD conformance tests. + * When server supports client_id_metadata_document_supported, this URL + * will be used as the client_id instead of doing dynamic registration. + */ +const CIMD_CLIENT_METADATA_URL = + 'https://conformance-test.local/client-metadata.json'; /** * Well-behaved auth client that follows all OAuth protocols correctly. @@ -17,7 +25,9 @@ export async function runClient(serverUrl: string): Promise { const oauthFetch = withOAuthRetry( 'test-auth-client', - new URL(serverUrl) + new URL(serverUrl), + handle401, + CIMD_CLIENT_METADATA_URL )(fetch); const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index 2db99d9..aa3aef5 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -27,13 +27,11 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { return this._clientMetadata; } + get clientMetadataUrl(): string | undefined { + return this._clientMetadataUrl?.toString(); + } + clientInformation(): OAuthClientInformation | undefined { - if (this._clientMetadataUrl) { - console.log('Using client ID metadata URL'); - return { - client_id: this._clientMetadataUrl.toString() - }; - } return this._clientInformation; } diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index c95ab96..497f4a4 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -60,14 +60,16 @@ export const handle401 = async ( export const withOAuthRetry = ( clientName: string, baseUrl?: string | URL, - handle401Fn: typeof handle401 = handle401 + handle401Fn: typeof handle401 = handle401, + clientMetadataUrl?: string ): Middleware => { const provider = new ConformanceOAuthProvider( 'http://localhost:3000/callback', { client_name: clientName, redirect_uris: ['http://localhost:3000/callback'] - } + }, + clientMetadataUrl ); return (next: FetchLike) => { return async ( diff --git a/src/checks/client.ts b/src/checks/client.ts index cde349d..7a14589 100644 --- a/src/checks/client.ts +++ b/src/checks/client.ts @@ -1,4 +1,4 @@ -import { ConformanceCheck, CheckStatus } from '../types.js'; +import { ConformanceCheck, CheckStatus } from '../types'; export function createServerInfoCheck(serverInfo: { name: string; diff --git a/src/checks/index.ts b/src/checks/index.ts index cb807fe..ed443ce 100644 --- a/src/checks/index.ts +++ b/src/checks/index.ts @@ -1,4 +1,4 @@ // Namespaced exports for client checks -import * as client from './client.js'; +import * as client from './client'; export const clientChecks = client; diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts new file mode 100644 index 0000000..64c87d5 --- /dev/null +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -0,0 +1,99 @@ +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 { SpecReferences } from './spec-references'; + +/** + * Fixed client metadata URL that clients should use for CIMD tests. + * This URL doesn't need to resolve - the server will accept it as-is + * and use hardcoded metadata. + */ +export const CIMD_CLIENT_METADATA_URL = + 'https://conformance-test.local/client-metadata.json'; + +/** + * Scenario: Client ID Metadata Documents (SEP-991/URL-based client IDs) + * + * Tests that when a server advertises client_id_metadata_document_supported=true, + * clients SHOULD use a URL as their client_id instead of using dynamic client + * registration. + */ +export class AuthBasicCIMDScenario implements Scenario { + name = 'auth/basic-cimd'; + 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 { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + clientIdMetadataDocumentSupported: true, + onAuthorizationRequest: (data) => { + // Check if client used URL-based client ID + const usedUrlClientId = data.clientId === CIMD_CLIENT_METADATA_URL; + this.checks.push({ + id: 'cimd-client-id-used', + name: 'Client ID Metadata Document Usage', + description: usedUrlClientId + ? 'Client correctly used URL-based client ID when server supports client_id_metadata_document_supported' + : 'Client SHOULD use URL-based client ID when server advertises client_id_metadata_document_supported=true', + status: usedUrlClientId ? 'SUCCESS' : 'WARNING', + timestamp: data.timestamp, + specReferences: [ + SpecReferences.MCP_CLIENT_ID_METADATA_DOCUMENTS, + SpecReferences.IETF_CIMD + ], + details: { + expectedClientId: CIMD_CLIENT_METADATA_URL, + actualClientId: data.clientId || 'none' + } + }); + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl + ); + + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the CIMD check - if not, the client didn't make an auth request + const hasCimdCheck = this.checks.some( + (c) => c.id === 'cimd-client-id-used' + ); + if (!hasCimdCheck) { + this.checks.push({ + id: 'cimd-client-id-used', + name: 'Client ID Metadata Document Usage', + description: + 'Client did not make an authorization request to test CIMD support', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.MCP_CLIENT_ID_METADATA_DOCUMENTS, + SpecReferences.IETF_CIMD + ] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 86f1d00..6fc09a8 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -6,12 +6,12 @@ * generated from them. */ -import type { Scenario, ConformanceCheck } 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 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 { SpecReferences } from './spec-references'; import { Request, Response } from 'express'; /** diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 7828990..c4a2094 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,8 +1,8 @@ import express, { Request, Response } from 'express'; -import type { ConformanceCheck } from '../../../../types.js'; -import { createRequestLogger } from '../../../request-logger.js'; -import { SpecReferences } from '../spec-references.js'; -import { MockTokenVerifier } from './mockTokenVerifier.js'; +import type { ConformanceCheck } from '../../../../types'; +import { createRequestLogger } from '../../../request-logger'; +import { SpecReferences } from '../spec-references'; +import { MockTokenVerifier } from './mockTokenVerifier'; export interface AuthServerOptions { metadataPath?: string; @@ -10,6 +10,7 @@ export interface AuthServerOptions { loggingEnabled?: boolean; routePrefix?: string; scopesSupported?: string[]; + clientIdMetadataDocumentSupported?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -17,6 +18,7 @@ export interface AuthServerOptions { timestamp: string; }) => { token: string; scopes: string[] }; onAuthorizationRequest?: (requestData: { + clientId?: string; scope?: string; timestamp: string; }) => void; @@ -33,6 +35,7 @@ export function createAuthServer( loggingEnabled = true, routePrefix = '', scopesSupported, + clientIdMetadataDocumentSupported, tokenVerifier, onTokenRequest, onAuthorizationRequest @@ -93,6 +96,12 @@ export function createAuthServer( metadata.scopes_supported = scopesSupported; } + // Add client_id_metadata_document_supported if provided + if (clientIdMetadataDocumentSupported !== undefined) { + metadata.client_id_metadata_document_supported = + clientIdMetadataDocumentSupported; + } + // Add OpenID Configuration specific fields if (isOpenIdConfiguration) { metadata.jwks_uri = `${getAuthBaseUrl()}/.well-known/jwks.json`; @@ -123,6 +132,7 @@ export function createAuthServer( if (onAuthorizationRequest) { onAuthorizationRequest({ + clientId: req.query.client_id as string | undefined, scope: scopeParam, timestamp }); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 168f9ac..de230c8 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -9,10 +9,10 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; import express, { Request, Response, NextFunction } from 'express'; -import type { ConformanceCheck } from '../../../../types.js'; -import { createRequestLogger } from '../../../request-logger.js'; -import { MockTokenVerifier } from './mockTokenVerifier.js'; -import { SpecReferences } from '../spec-references.js'; +import type { ConformanceCheck } from '../../../../types'; +import { createRequestLogger } from '../../../request-logger'; +import { MockTokenVerifier } from './mockTokenVerifier'; +import { SpecReferences } from '../spec-references'; export interface ServerOptions { prmPath?: string | null; diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index 2ce5a0e..f49f101 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -1,7 +1,7 @@ import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; -import type { ConformanceCheck } from '../../../../types.js'; -import { SpecReferences } from '../spec-references.js'; +import type { ConformanceCheck } from '../../../../types'; +import { SpecReferences } from '../spec-references'; export class MockTokenVerifier implements OAuthTokenVerifier { private tokenScopes: Map = new Map(); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index addbe23..b04ebfc 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,14 +1,15 @@ -import { authScenariosList } from './index.js'; +import { authScenariosList } from './index'; import { runClientAgainstScenario, InlineClientRunner -} from './test_helpers/testClient.js'; -import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test.js'; -import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm.js'; -import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope.js'; -import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes.js'; -import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403.js'; -import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger.js'; +} from './test_helpers/testClient'; +import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test'; +import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm'; +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 { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; beforeAll(() => { setLogLevel('error'); @@ -22,7 +23,10 @@ const skipScenarios = new Set([ // Waiting on typescript-sdk support for using scopes_supported from PRM // to request scopes. // https://github.com/modelcontextprotocol/typescript-sdk/pull/1133 - 'auth/scope-from-scopes-supported' + 'auth/scope-from-scopes-supported', + // Waiting on typescript-sdk support for CIMD + // https://github.com/modelcontextprotocol/typescript-sdk/pull/1127 + 'auth/basic-cimd' ]); describe('Client Auth Scenarios', () => { @@ -76,4 +80,11 @@ describe('Negative tests', () => { 'scope-step-up-escalation' ]); }); + + test('client uses DCR instead of CIMD when server supports it', async () => { + const runner = new InlineClientRunner(noCimdClient); + await runClientAgainstScenario(runner, 'auth/basic-cimd', [ + 'cimd-client-id-used' + ]); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index f78d2e1..3e37108 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -1,18 +1,20 @@ import { Scenario } from '../../../types'; -import { metadataScenarios } from './discovery-metadata.js'; +import { metadataScenarios } from './discovery-metadata'; +import { AuthBasicCIMDScenario } from './basic-cimd'; import { Auth20250326OAuthMetadataBackcompatScenario, Auth20250326OEndpointFallbackScenario -} from './march-spec-backcompat.js'; +} from './march-spec-backcompat'; import { ScopeFromWwwAuthenticateScenario, ScopeFromScopesSupportedScenario, ScopeOmittedWhenUndefinedScenario, ScopeStepUpAuthScenario -} from './scope-handling.js'; +} from './scope-handling'; export const authScenariosList: Scenario[] = [ ...metadataScenarios, + new AuthBasicCIMDScenario(), new Auth20250326OAuthMetadataBackcompatScenario(), new Auth20250326OEndpointFallbackScenario(), new ScopeFromWwwAuthenticateScenario(), diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 0999bdf..4f0a5ae 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -1,10 +1,10 @@ -import type { Scenario, ConformanceCheck } 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 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 express, { Request, Response } from 'express'; -import { SpecReferences } from './spec-references.js'; +import { SpecReferences } from './spec-references'; export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { name = 'auth/2025-03-26-oauth-metadata-backcompat'; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 5f97e36..94038f1 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -1,10 +1,10 @@ -import type { Scenario, ConformanceCheck } 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 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 { SpecReferences } from './spec-references'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier'; import type { Request, Response, NextFunction } from 'express'; /** diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 3642396..6f6b824 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -52,5 +52,13 @@ export const SpecReferences: { [key: string]: SpecReference } = { MCP_AUTH_ERROR_HANDLING: { id: 'MCP-Auth-error-handling', url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling' + }, + MCP_CLIENT_ID_METADATA_DOCUMENTS: { + id: 'MCP-Client-ID-Metadata-Documents', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents' + }, + IETF_CIMD: { + id: 'IETF-OAuth-Client-ID-Metadata-Document', + url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00' } }; diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 6a34c79..df0f70c 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -1,4 +1,4 @@ -import { getScenario } from '../../../index.js'; +import { getScenario } from '../../../index'; import { spawn } from 'child_process'; const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete diff --git a/src/scenarios/client/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index 2d4b8ca..88e6bf0 100644 --- a/src/scenarios/client/elicitation-defaults.ts +++ b/src/scenarios/client/elicitation-defaults.ts @@ -11,10 +11,10 @@ import { ListToolsRequestSchema, ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck } from '../../types.js'; +import type { Scenario, ConformanceCheck } from '../../types'; import express, { Request, Response } from 'express'; -import { ScenarioUrls } from '../../types.js'; -import { createRequestLogger } from '../request-logger.js'; +import { ScenarioUrls } from '../../types'; +import { createRequestLogger } from '../request-logger'; import { randomUUID } from 'crypto'; function isInitializeRequest(body: unknown): boolean { diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index 2d5ac18..770103c 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -1,6 +1,6 @@ import http from 'http'; -import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; -import { clientChecks } from '../../checks/index.js'; +import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types'; +import { clientChecks } from '../../checks/index'; export class InitializeScenario implements Scenario { name = 'initialize'; diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index 7e3a298..ab7e312 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -4,10 +4,10 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck } from '../../types.js'; +import type { Scenario, ConformanceCheck } from '../../types'; import express, { Request, Response } from 'express'; -import { ScenarioUrls } from '../../types.js'; -import { createRequestLogger } from '../request-logger.js'; +import { ScenarioUrls } from '../../types'; +import { createRequestLogger } from '../request-logger'; function createServer(checks: ConformanceCheck[]): express.Application { const server = new Server( diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 1eb41e4..2a41815 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -1,15 +1,15 @@ -import { Scenario, ClientScenario } from '../types.js'; -import { InitializeScenario } from './client/initialize.js'; -import { ToolsCallScenario } from './client/tools_call.js'; -import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js'; +import { Scenario, ClientScenario } from '../types'; +import { InitializeScenario } from './client/initialize'; +import { ToolsCallScenario } from './client/tools_call'; +import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; // Import all new server test scenarios -import { ServerInitializeScenario } from './server/lifecycle.js'; +import { ServerInitializeScenario } from './server/lifecycle'; import { LoggingSetLevelScenario, CompletionCompleteScenario -} from './server/utils.js'; +} from './server/utils'; import { ToolsListScenario, @@ -23,12 +23,12 @@ import { ToolsCallElicitationScenario, ToolsCallAudioScenario, ToolsCallEmbeddedResourceScenario -} from './server/tools.js'; +} from './server/tools'; -import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12.js'; +import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12'; -import { ElicitationDefaultsScenario } from './server/elicitation-defaults.js'; -import { ElicitationEnumsScenario } from './server/elicitation-enums.js'; +import { ElicitationDefaultsScenario } from './server/elicitation-defaults'; +import { ElicitationEnumsScenario } from './server/elicitation-enums'; import { ResourcesListScenario, @@ -37,7 +37,7 @@ import { ResourcesTemplateReadScenario, ResourcesSubscribeScenario, ResourcesUnsubscribeScenario -} from './server/resources.js'; +} from './server/resources'; import { PromptsListScenario, @@ -45,10 +45,10 @@ import { PromptsGetWithArgsScenario, PromptsGetEmbeddedResourceScenario, PromptsGetWithImageScenario -} from './server/prompts.js'; +} from './server/prompts'; -import { authScenariosList } from './client/auth/index.js'; -import { listMetadataScenarios } from './client/auth/discovery-metadata.js'; +import { authScenariosList } from './client/auth/index'; +import { listMetadataScenarios } from './client/auth/discovery-metadata'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ diff --git a/src/scenarios/request-logger.ts b/src/scenarios/request-logger.ts index 6f63837..a799ad0 100644 --- a/src/scenarios/request-logger.ts +++ b/src/scenarios/request-logger.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from 'express'; -import { ConformanceCheck } from '../types.js'; +import { ConformanceCheck } from '../types'; export interface LoggerOptions { incomingId: string; diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 6eab252..9f9a7e9 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -1,5 +1,5 @@ import { spawn, ChildProcess } from 'child_process'; -import { getClientScenario, listActiveClientScenarios } from '../index.js'; +import { getClientScenario, listActiveClientScenarios } from '../index'; import path from 'path'; describe('Server Scenarios', () => { diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index bd54475..2be114c 100644 --- a/src/scenarios/server/elicitation-defaults.ts +++ b/src/scenarios/server/elicitation-defaults.ts @@ -2,8 +2,8 @@ * SEP-1034: Elicitation default values test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer } from './client-helper'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationDefaultsScenario implements ClientScenario { diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts index fa35b08..e5c1fa3 100644 --- a/src/scenarios/server/elicitation-enums.ts +++ b/src/scenarios/server/elicitation-enums.ts @@ -2,8 +2,8 @@ * SEP-1330: Elicitation enum schema improvements test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer } from './client-helper'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationEnumsScenario implements ClientScenario { diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 357c5b3..d9b341e 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -2,8 +2,8 @@ * Lifecycle test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer } from './client-helper'; export class ServerInitializeScenario implements ClientScenario { name = 'server-initialize'; diff --git a/src/scenarios/server/prompts.ts b/src/scenarios/server/prompts.ts index 1bbb378..436564b 100644 --- a/src/scenarios/server/prompts.ts +++ b/src/scenarios/server/prompts.ts @@ -2,8 +2,8 @@ * Prompts test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer } from './client-helper'; export class PromptsListScenario implements ClientScenario { name = 'prompts-list'; diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index c66e425..a4ed241 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -2,8 +2,8 @@ * Resources test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer } from './client-helper'; import { TextResourceContents, BlobResourceContents diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index ae07cb2..e8d27d0 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -2,8 +2,8 @@ * Tools test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer, NotificationCollector } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer, NotificationCollector } from './client-helper'; import { CallToolResultSchema, CreateMessageRequestSchema, diff --git a/src/scenarios/server/utils.ts b/src/scenarios/server/utils.ts index 70de452..e65c814 100644 --- a/src/scenarios/server/utils.ts +++ b/src/scenarios/server/utils.ts @@ -2,8 +2,8 @@ * Utilities test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; -import { connectToServer } from './client-helper.js'; +import { ClientScenario, ConformanceCheck } from '../../types'; +import { connectToServer } from './client-helper'; export class LoggingSetLevelScenario implements ClientScenario { name = 'logging-set-level'; From f2aaedaaa432180a30adda4f1c1d6b8749ac1d36 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 20 Nov 2025 21:02:52 +0000 Subject: [PATCH 032/226] 0.1.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c66b8b..a419577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", diff --git a/package.json b/package.json index b6ee9cc..61453c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.6", + "version": "0.1.7", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From b64e0549180435ec23d42e7a629cc8fbc53b2976 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 21 Nov 2025 16:49:38 +0000 Subject: [PATCH 033/226] Add sep-835 limit retry scenario, add suite options to server command and update pending scenarios (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * lefthook: move deps installs to optional local change * add test scenario for infinite retry and negative test * Add broken client for retry limit negative test and fix lefthook formatting 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix www-authenticate in scope scenario * add suites and shift pending to reflect current pending * lint --------- Co-authored-by: Claude --- .../typescript/auth-test-no-retry-limit.ts | 113 ++++++++++++ lefthook-local.example.yml | 11 +- lefthook.yml | 10 - package-lock.json | 26 +-- package.json | 2 +- src/index.ts | 37 +++- src/scenarios/client/auth/index.test.ts | 64 ++++--- src/scenarios/client/auth/index.ts | 6 +- src/scenarios/client/auth/scope-handling.ts | 172 +++++++++++++++++- .../client/auth/test_helpers/testClient.ts | 21 ++- src/scenarios/index.ts | 13 +- 11 files changed, 402 insertions(+), 73 deletions(-) create mode 100644 examples/clients/typescript/auth-test-no-retry-limit.ts diff --git a/examples/clients/typescript/auth-test-no-retry-limit.ts b/examples/clients/typescript/auth-test-no-retry-limit.ts new file mode 100644 index 0000000..78a2c7b --- /dev/null +++ b/examples/clients/typescript/auth-test-no-retry-limit.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + auth, + extractWWWAuthenticateParams, + UnauthorizedError +} 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 { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +/** + * Broken client that retries auth infinitely without any retry limit. + * BUG: Does not implement retry limits, causing infinite auth loops. + */ + +const withOAuthRetryNoLimit = ( + clientName: string, + baseUrl?: string | URL +): Middleware => { + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: clientName, + redirect_uris: ['http://localhost:3000/callback'] + } + ); + + return (next: FetchLike) => { + return async ( + input: string | URL, + init?: RequestInit + ): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + return await next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + // BUG: No retry limit - keeps retrying on every 401/403 + while (response.status === 401 || response.status === 403) { + const serverUrl = + baseUrl || + (typeof input === 'string' ? new URL(input).origin : input.origin); + + 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 + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError( + `Authentication failed with result: ${result}` + ); + } + } + + response = await makeRequest(); + } + + return response; + }; + }; +}; + +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-no-retry-limit', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetryNoLimit( + 'test-auth-client-no-retry-limit', + 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-no-retry-limit '); diff --git a/lefthook-local.example.yml b/lefthook-local.example.yml index 97a3fdf..3401c11 100644 --- a/lefthook-local.example.yml +++ b/lefthook-local.example.yml @@ -2,9 +2,18 @@ # To enable this: # cp lefthook-local.example.yml lefthook-local.yml -# Uncomment to add pre-commit hook for automatic linting on staged files: pre-commit: commands: lint: run: npm run lint:fix {staged_files} stage_fixed: true + +post-checkout: + jobs: + - name: 'Install Dependencies' + run: npm install + +post-merge: + jobs: + - name: 'Install Dependencies' + run: npm install diff --git a/lefthook.yml b/lefthook.yml index 445ba02..416265e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -27,13 +27,3 @@ pre-push: - name: 'Test' run: npm run test - -post-checkout: - jobs: - - name: 'Install Dependencies' - run: npm install - -post-merge: - jobs: - - name: 'Install Dependencies' - run: npm install diff --git a/package-lock.json b/package-lock.json index a419577..5be12a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.7", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.23.0-beta.0", "commander": "^14.0.2", "express": "^5.1.0", "zod": "^3.25.76" @@ -839,9 +839,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", - "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", + "version": "1.23.0-beta.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.23.0-beta.0.tgz", + "integrity": "sha512-NvoyrhhNcNiyf0nI8J1O+wheNiyOzK3kMTkMuwGb/TGHpSHXCcubcg0IxC/p9Aym+K4QZFxq9Wn67clOAegFKQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", @@ -855,18 +855,22 @@ "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true + }, + "zod": { + "optional": false } } }, @@ -5299,12 +5303,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "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==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index 61453c5..a0eb191 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "vitest": "^4.0.5" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.22.0", + "@modelcontextprotocol/sdk": "^1.23.0-beta.0", "commander": "^14.0.2", "express": "^5.1.0", "zod": "^3.25.76" diff --git a/src/index.ts b/src/index.ts index 21145d5..a65e79f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { listScenarios, listClientScenarios, listActiveClientScenarios, + listPendingClientScenarios, listAuthScenarios, listMetadataScenarios } from './scenarios'; @@ -53,7 +54,9 @@ program const suites: Record string[]> = { auth: listAuthScenarios, - metadata: listMetadataScenarios + metadata: listMetadataScenarios, + 'sep-835': () => + listAuthScenarios().filter((name) => name.startsWith('auth/scope-')) }; const suiteName = options.suite.toLowerCase(); @@ -123,8 +126,9 @@ program totalWarnings += warnings; const status = failed === 0 ? '✓' : '✗'; + const warningStr = warnings > 0 ? `, ${warnings} warnings` : ''; console.log( - `${status} ${result.scenario}: ${passed} passed, ${failed} failed` + `${status} ${result.scenario}: ${passed} passed, ${failed} failed${warningStr}` ); if (verbose && failed > 0) { @@ -149,7 +153,7 @@ program console.error('Either --scenario or --suite is required'); console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); - console.error('\nAvailable suites: auth, metadata'); + console.error('\nAvailable suites: auth, metadata, sep-835'); process.exit(1); } @@ -193,7 +197,12 @@ program .requiredOption('--url ', 'URL of the server to test') .option( '--scenario ', - 'Scenario to test (defaults to all scenarios if not specified)' + 'Scenario to test (defaults to active suite if not specified)' + ) + .option( + '--suite ', + 'Suite to run: "active" (default, excludes pending), "all", or "pending"', + 'active' ) .action(async (options) => { try { @@ -213,10 +222,24 @@ program ); process.exit(failed > 0 ? 1 : 0); } else { - // Run all active scenarios - const scenarios = listActiveClientScenarios(); + // Run scenarios based on suite + const suite = options.suite?.toLowerCase() || 'active'; + let scenarios: string[]; + + if (suite === 'all') { + scenarios = listClientScenarios(); + } else if (suite === 'active') { + scenarios = listActiveClientScenarios(); + } else if (suite === 'pending') { + scenarios = listPendingClientScenarios(); + } else { + console.error(`Unknown suite: ${suite}`); + console.error('Available suites: active, all, pending'); + process.exit(1); + } + console.log( - `Running ${scenarios.length} scenarios against ${validated.url}\n` + `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` ); const allResults: { scenario: string; checks: ConformanceCheck[] }[] = diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index b04ebfc..285280f 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -9,24 +9,18 @@ import { runClient as noCimdClient } from '../../../../examples/clients/typescri 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 { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; beforeAll(() => { setLogLevel('error'); }); -const skipScenarios = new Set([ - // Waiting on typescript-sdk support in bearerAuth middleware to include - // scope in WWW-Authenticate header - // https://github.com/modelcontextprotocol/typescript-sdk/pull/1133 - 'auth/scope-from-www-authenticate', - // Waiting on typescript-sdk support for using scopes_supported from PRM - // to request scopes. - // https://github.com/modelcontextprotocol/typescript-sdk/pull/1133 - 'auth/scope-from-scopes-supported', - // Waiting on typescript-sdk support for CIMD - // https://github.com/modelcontextprotocol/typescript-sdk/pull/1127 - 'auth/basic-cimd' +const skipScenarios = new Set([]); + +const allowClientErrorScenarios = new Set([ + // Client is expected to give up (error) after limited retries, but check should pass + 'auth/scope-retry-limit' ]); describe('Client Auth Scenarios', () => { @@ -38,7 +32,9 @@ describe('Client Auth Scenarios', () => { return; } const runner = new InlineClientRunner(goodClient); - await runClientAgainstScenario(runner, scenario.name); + await runClientAgainstScenario(runner, scenario.name, { + allowClientError: allowClientErrorScenarios.has(scenario.name) + }); }); } }); @@ -46,23 +42,23 @@ describe('Client Auth Scenarios', () => { describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); - await runClientAgainstScenario(runner, 'auth/metadata-default', [ - 'prm-priority-order' - ]); + await runClientAgainstScenario(runner, 'auth/metadata-default', { + expectedFailureSlugs: ['prm-priority-order'] + }); }); test('client ignores scope from WWW-Authenticate header', async () => { const runner = new InlineClientRunner(ignoreScopeClient); - await runClientAgainstScenario(runner, 'auth/scope-from-www-authenticate', [ - 'scope-from-www-authenticate' - ]); + await runClientAgainstScenario(runner, 'auth/scope-from-www-authenticate', { + expectedFailureSlugs: ['scope-from-www-authenticate'] + }); }); test('client only requests subset of scopes_supported', async () => { const runner = new InlineClientRunner(partialScopesClient); - await runClientAgainstScenario(runner, 'auth/scope-from-scopes-supported', [ - 'scope-from-scopes-supported' - ]); + await runClientAgainstScenario(runner, 'auth/scope-from-scopes-supported', { + expectedFailureSlugs: ['scope-from-scopes-supported'] + }); }); test('client requests scope even if scopes_supported is empty', async () => { @@ -70,21 +66,31 @@ describe('Negative tests', () => { await runClientAgainstScenario( runner, 'auth/scope-omitted-when-undefined', - ['scope-omitted-when-undefined'] + { + expectedFailureSlugs: ['scope-omitted-when-undefined'] + } ); }); test('client only responds to 401, not 403', async () => { const runner = new InlineClientRunner(ignore403Client); - await runClientAgainstScenario(runner, 'auth/scope-step-up', [ - 'scope-step-up-escalation' - ]); + await runClientAgainstScenario(runner, 'auth/scope-step-up', { + expectedFailureSlugs: ['scope-step-up-escalation'] + }); }); test('client uses DCR instead of CIMD when server supports it', async () => { const runner = new InlineClientRunner(noCimdClient); - await runClientAgainstScenario(runner, 'auth/basic-cimd', [ - 'cimd-client-id-used' - ]); + await runClientAgainstScenario(runner, 'auth/basic-cimd', { + expectedFailureSlugs: ['cimd-client-id-used'] + }); + }); + + test('client retries auth infinitely without limit', async () => { + const runner = new InlineClientRunner(noRetryLimitClient); + await runClientAgainstScenario(runner, 'auth/scope-retry-limit', { + expectedFailureSlugs: ['scope-retry-limit'], + allowClientError: true + }); }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 3e37108..eed1213 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -9,7 +9,8 @@ import { ScopeFromWwwAuthenticateScenario, ScopeFromScopesSupportedScenario, ScopeOmittedWhenUndefinedScenario, - ScopeStepUpAuthScenario + ScopeStepUpAuthScenario, + ScopeRetryLimitScenario } from './scope-handling'; export const authScenariosList: Scenario[] = [ @@ -20,5 +21,6 @@ export const authScenariosList: Scenario[] = [ new ScopeFromWwwAuthenticateScenario(), new ScopeFromScopesSupportedScenario(), new ScopeOmittedWhenUndefinedScenario(), - new ScopeStepUpAuthScenario() + new ScopeStepUpAuthScenario(), + new ScopeRetryLimitScenario() ]; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 94038f1..137371a 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -351,7 +351,7 @@ export class ScopeStepUpAuthScenario implements Scenario { .status(403) .set( 'WWW-Authenticate', - `Bearer scope="${requiredScopes.join(' ')}", resource_metadata="${resourceMetadataUrl()}"` + `Bearer scope="${requiredScopes.join(' ')}", resource_metadata="${resourceMetadataUrl()}", error="insufficient_scope"` ) .json({ error: 'insufficient_scope', @@ -421,3 +421,173 @@ export class ScopeStepUpAuthScenario implements Scenario { return this.checks; } } + +/** + * Scenario 5: Client implements retry limits for scope escalation + * + * Tests that clients SHOULD implement retry limits to avoid infinite + * authorization loops when receiving repeated 403 insufficient_scope errors. + * The server always returns 403 with the same scope requirement, and clients + * should stop retrying after a reasonable number of attempts (3 or fewer). + */ +export class ScopeRetryLimitScenario implements Scenario { + name = 'auth/scope-retry-limit'; + description = + 'Tests that client implements retry limits to prevent infinite authorization loops on repeated 403 responses'; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const requiredScope = 'mcp:admin'; + const tokenVerifier = new MockTokenVerifier(this.checks, []); + let authRequestCount = 0; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + onAuthorizationRequest: (data) => { + authRequestCount++; + this.checks.push({ + id: 'scope-retry-auth-attempt', + name: `Authorization attempt ${authRequestCount}`, + description: `Client made authorization request attempt ${authRequestCount}`, + status: 'INFO', + timestamp: data.timestamp, + specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING], + details: { + attemptNumber: authRequestCount, + requestedScope: data.scope || 'none' + } + }); + } + }); + await this.authServer.start(authApp); + + const resourceMetadataUrl = () => + `${this.server.getUrl()}/.well-known/oauth-protected-resource/mcp`; + + const maxAttempts = 3; + let mcpRequestWithTokenCount = 0; + + const alwaysDeny403Middleware = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + let body = req.body; + if (typeof body === 'string') { + body = JSON.parse(body); + } + const method = body?.method; + + if (method === 'initialize' || method?.startsWith('notifications/')) { + return next(); + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res + .status(401) + .set( + 'WWW-Authenticate', + `Bearer scope="${requiredScope}", resource_metadata="${resourceMetadataUrl()}"` + ) + .json({ + error: 'invalid_token', + error_description: 'Missing Authorization header' + }); + } + + mcpRequestWithTokenCount++; + + if (mcpRequestWithTokenCount > maxAttempts) { + return res.status(410).json({ + error: 'test_complete', + error_description: + 'Test is over - client exceeded maximum retry attempts' + }); + } + + return res + .status(403) + .set( + 'WWW-Authenticate', + `Bearer error="insufficient_scope", scope="${requiredScope}", resource_metadata="${resourceMetadataUrl()}", error_description="Scope upgrade will never succeed"` + ) + .json({ + error: 'insufficient_scope', + error_description: 'Scope upgrade will never succeed' + }); + }; + + const baseApp = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [requiredScope], + scopesSupported: [requiredScope], + includeScopeInWwwAuth: true, + authMiddleware: alwaysDeny403Middleware, + tokenVerifier + } + ); + + await this.server.start(baseApp); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const authAttempts = this.checks.filter( + (c) => c.id === 'scope-retry-auth-attempt' + ).length; + + if (authAttempts === 0) { + this.checks.push({ + id: 'scope-retry-limit', + name: 'Client retry limit for scope escalation', + description: 'Client did not make any authorization requests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING] + }); + } else if (authAttempts <= 3) { + this.checks.push({ + id: 'scope-retry-limit', + name: 'Client retry limit for scope escalation', + description: `Client correctly limited retry attempts to ${authAttempts} (3 or fewer)`, + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING], + details: { + authorizationAttempts: authAttempts, + maxAllowed: 3 + } + }); + } else { + this.checks.push({ + id: 'scope-retry-limit', + name: 'Client retry limit for scope escalation', + description: `Client made ${authAttempts} authorization attempts (more than 3). Clients SHOULD implement retry limits to avoid infinite loops.`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_CHALLENGE_HANDLING], + details: { + authorizationAttempts: authAttempts, + maxAllowed: 3 + } + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index df0f70c..45f4e6d 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -83,16 +83,19 @@ export class InlineClientRunner implements ClientRunner { } } +export interface RunClientOptions { + expectedFailureSlugs?: string[]; + allowClientError?: boolean; +} + export async function runClientAgainstScenario( - clientRunner: ClientRunner | string, + clientRunner: ClientRunner, scenarioName: string, - expectedFailureSlugs: string[] = [] + options: RunClientOptions = {} ): Promise { - // Handle backward compatibility: if string is passed, treat as file path - const runner = - typeof clientRunner === 'string' - ? new SpawnedClientRunner(clientRunner) - : clientRunner; + const { expectedFailureSlugs = [], allowClientError = false } = options; + + const runner = clientRunner; const scenario = getScenario(scenarioName); if (!scenario) { @@ -108,10 +111,10 @@ export async function runClientAgainstScenario( try { await runner.run(serverUrl); } catch (err) { - if (expectedFailureSlugs.length === 0) { + if (expectedFailureSlugs.length === 0 && !allowClientError) { throw err; // Unexpected failure } - // Otherwise, expected failure - continue to checks verification + // Otherwise, expected failure or allowed error - continue to checks verification } // Get checks from the scenario diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 2a41815..f0d1e78 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -58,7 +58,12 @@ const pendingClientScenariosList: ClientScenario[] = [ // JSON Schema 2020-12 (SEP-1613) // This test is pending until the SDK includes PR #1135 which preserves // $schema, $defs, and additionalProperties fields in tool schemas. - new JsonSchema2020_12Scenario() + new JsonSchema2020_12Scenario(), + + // On hold until elicitation schema types are fixed + // https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1863 + new ToolsCallElicitationScenario(), + new ElicitationDefaultsScenario() ]; // All client scenarios @@ -90,7 +95,7 @@ const allClientScenariosList: ClientScenario[] = [ new ElicitationDefaultsScenario(), // Elicitation scenarios (SEP-1330) - pending - ...pendingClientScenariosList, + new ElicitationEnumsScenario(), // Resources scenarios new ResourcesListScenario(), @@ -159,6 +164,10 @@ export function listActiveClientScenarios(): string[] { return activeClientScenariosList.map((scenario) => scenario.name); } +export function listPendingClientScenarios(): string[] { + return pendingClientScenariosList.map((scenario) => scenario.name); +} + export function listAuthScenarios(): string[] { return authScenariosList.map((scenario) => scenario.name); } From 14905af980f6602ee228c224d282726d9711afb0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 21 Nov 2025 19:05:58 +0000 Subject: [PATCH 034/226] 0.1.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5be12a5..57b3450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0-beta.0", diff --git a/package.json b/package.json index a0eb191..4a56eb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.7", + "version": "0.1.8", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 314f0f6d89e95158a40c1e0eaa5f076ab85091e7 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Mon, 24 Nov 2025 07:23:21 -0800 Subject: [PATCH 035/226] Omit arguments from tool call with no arguments (#53) --- src/scenarios/server/tools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index e8d27d0..e445a5c 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -119,8 +119,8 @@ Implement tool \`test_simple_text\` with no arguments that returns: const connection = await connectToServer(serverUrl); const result = await connection.client.callTool({ - name: 'test_simple_text', - arguments: {} + name: 'test_simple_text' + /* omit arguments as it is not required in the schema */ }); // Validate response From f0e948187d48e02caaf809bc43e3d3c3b1c4ca27 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:34:28 +0000 Subject: [PATCH 036/226] feat: add conformance tests for SEP-1699 SSE polling (#47) --- examples/clients/typescript/sse-retry-test.ts | 89 +++ .../servers/typescript/everything-server.ts | 84 ++- examples/servers/typescript/package-lock.json | 634 +++++++++--------- examples/servers/typescript/package.json | 3 +- package-lock.json | 1 + package.json | 1 + src/index.ts | 6 +- src/runner/server.ts | 11 +- src/scenarios/client/sse-retry.ts | 555 +++++++++++++++ src/scenarios/index.ts | 14 +- src/scenarios/server/all-scenarios.test.ts | 6 +- src/scenarios/server/sse-multiple-streams.ts | 268 ++++++++ src/scenarios/server/sse-polling.ts | 601 +++++++++++++++++ vitest.config.ts | 2 +- 14 files changed, 1938 insertions(+), 337 deletions(-) create mode 100644 examples/clients/typescript/sse-retry-test.ts create mode 100644 src/scenarios/client/sse-retry.ts create mode 100644 src/scenarios/server/sse-multiple-streams.ts create mode 100644 src/scenarios/server/sse-polling.ts diff --git a/examples/clients/typescript/sse-retry-test.ts b/examples/clients/typescript/sse-retry-test.ts new file mode 100644 index 0000000..8c16ca2 --- /dev/null +++ b/examples/clients/typescript/sse-retry-test.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +/** + * SSE Retry Test Client + * + * Tests that the MCP client respects the SSE retry field when reconnecting. + * This client connects to a test server that closes the SSE stream mid-tool-call, + * then waits for the client to reconnect and sends the tool result. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +async function main(): Promise { + const serverUrl = process.argv[2]; + + if (!serverUrl) { + console.error('Usage: sse-retry-test '); + process.exit(1); + } + + console.log(`Connecting to MCP server at: ${serverUrl}`); + console.log('This test validates SSE retry field compliance (SEP-1699)'); + + try { + const client = new Client( + { + name: 'sse-retry-test-client', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + reconnectionOptions: { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 3 + } + }); + + transport.onerror = (error) => { + console.log(`Transport error: ${error.message}`); + }; + + transport.onclose = () => { + console.log('Transport closed'); + }; + + console.log('Initiating connection...'); + await client.connect(transport); + console.log('Connected to MCP server'); + + console.log('Calling test_reconnection tool...'); + console.log( + 'Server will close SSE stream mid-call and send result after reconnection' + ); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test_reconnection', + arguments: {} + } + }, + CallToolResultSchema + ); + + console.log('Tool call completed:', JSON.stringify(result, null, 2)); + + await transport.close(); + console.log('Connection closed successfully'); + + process.exit(0); + } catch (error) { + console.error('Test failed:', error); + process.exit(1); + } +} + +main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); +}); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 376434f..9dd382a 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -12,7 +12,12 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + StreamableHTTPServerTransport, + EventStore, + EventId, + StreamId +} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { ElicitResultSchema, ListToolsRequestSchema, @@ -33,6 +38,41 @@ const watchedResourceContent = 'Watched resource content'; const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const servers: { [sessionId: string]: McpServer } = {}; +// In-memory event store for SEP-1699 resumability +const eventStoreData = new Map< + string, + { eventId: string; message: any; streamId: string } +>(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: any): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + eventStoreData.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: any) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0]; + const eventsToReplay: Array<[string, { message: any }]> = []; + for (const [eventId, data] of eventStoreData.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; +} + // Sample base64 encoded 1x1 red PNG pixel for testing const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; @@ -309,6 +349,46 @@ function createMcpServer() { } ); + // SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection + mcpServer.registerTool( + 'test_reconnection', + { + description: + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + inputSchema: {} + }, + async (_args, { sessionId, requestId }) => { + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + console.log(`[${sessionId}] Starting test_reconnection tool...`); + + // Get the transport for this session + const transport = sessionId ? transports[sessionId] : undefined; + if (transport && requestId) { + // Close the SSE stream to trigger client reconnection + console.log( + `[${sessionId}] Closing SSE stream to trigger client polling...` + ); + transport.closeSSEStream(requestId); + } + + // Wait for client to reconnect (should respect retry field) + await sleep(100); + + console.log(`[${sessionId}] test_reconnection tool complete`); + + return { + content: [ + { + type: 'text', + text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.' + } + ] + }; + } + ); + // Sampling tool - requests LLM completion from client mcpServer.registerTool( 'test_sampling', @@ -1003,6 +1083,8 @@ app.post('/mcp', async (req, res) => { transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000, // 5 second retry interval for SEP-1699 onsessioninitialized: (newSessionId) => { transports[newSessionId] = transport; servers[newSessionId] = mcpServer; diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index b968361..4db8198 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -11,7 +11,8 @@ "@modelcontextprotocol/sdk": "^1.20.1", "@types/cors": "^2.8.19", "cors": "^2.8.5", - "express": "^5.1.0" + "express": "^5.1.0", + "zod-to-json-schema": "^3.25.0" }, "devDependencies": { "tsx": "^4.7.0", @@ -19,9 +20,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "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==", "cpu": [ "ppc64" ], @@ -36,9 +37,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -53,9 +54,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "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==", "cpu": [ "arm64" ], @@ -70,9 +71,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "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==", "cpu": [ "x64" ], @@ -87,9 +88,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -104,9 +105,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -121,9 +122,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -138,9 +139,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -155,9 +156,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -172,9 +173,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -189,9 +190,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "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==", "cpu": [ "ia32" ], @@ -206,9 +207,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "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==", "cpu": [ "loong64" ], @@ -223,9 +224,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -240,9 +241,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "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==", "cpu": [ "ppc64" ], @@ -257,9 +258,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -274,9 +275,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -291,9 +292,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -308,9 +309,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "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==", "cpu": [ "arm64" ], @@ -325,9 +326,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -342,9 +343,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -359,9 +360,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -376,9 +377,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -393,9 +394,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -410,9 +411,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -427,9 +428,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -444,9 +445,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -461,12 +462,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.20.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz", - "integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", + "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", "license": "MIT", "dependencies": { - "ajv": "^6.12.6", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", @@ -481,11 +483,19 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } } }, "node_modules/@types/cors": { "version": "2.8.19", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@types/cors/-/cors-2.8.19.tgz", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "license": "MIT", "dependencies": { @@ -493,17 +503,17 @@ } }, "node_modules/@types/node": { - "version": "24.8.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/@types/node/-/node-24.8.0.tgz", - "integrity": "sha512-5x08bUtU8hfboMTrJ7mEO4CpepS9yBwAqcL52y86SWNmbPX8LVbNs3EP4cNrIZgdjk2NAlP2ahNihozpoZIxSg==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.14.0" + "undici-types": "~7.16.0" } }, "node_modules/accepts": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/accepts/-/accepts-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { @@ -515,42 +525,41 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/async-generator-function": { - "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/async-generator-function/-/async-generator-function-1.0.0.tgz", - "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, "node_modules/body-parser": { "version": "2.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/body-parser/-/body-parser-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { @@ -570,7 +579,7 @@ }, "node_modules/bytes": { "version": "3.1.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/bytes/-/bytes-3.1.2.tgz", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { @@ -579,7 +588,7 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { @@ -592,7 +601,7 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/call-bound/-/call-bound-1.0.4.tgz", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", "dependencies": { @@ -607,20 +616,21 @@ } }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { "version": "1.0.5", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/content-type/-/content-type-1.0.5.tgz", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", "engines": { @@ -629,7 +639,7 @@ }, "node_modules/cookie": { "version": "0.7.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/cookie/-/cookie-0.7.2.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { @@ -638,7 +648,7 @@ }, "node_modules/cookie-signature": { "version": "1.2.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/cookie-signature/-/cookie-signature-1.2.2.tgz", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", "engines": { @@ -647,7 +657,7 @@ }, "node_modules/cors": { "version": "2.8.5", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/cors/-/cors-2.8.5.tgz", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "license": "MIT", "dependencies": { @@ -660,7 +670,7 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/cross-spawn/-/cross-spawn-7.0.6.tgz", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { @@ -674,7 +684,7 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/debug/-/debug-4.4.3.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { @@ -691,7 +701,7 @@ }, "node_modules/depd": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/depd/-/depd-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", "engines": { @@ -700,7 +710,7 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/dunder-proto/-/dunder-proto-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", "dependencies": { @@ -714,13 +724,13 @@ }, "node_modules/ee-first": { "version": "1.1.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ee-first/-/ee-first-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/encodeurl/-/encodeurl-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { @@ -729,7 +739,7 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/es-define-property/-/es-define-property-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", "engines": { @@ -738,7 +748,7 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/es-errors/-/es-errors-1.3.0.tgz", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", "engines": { @@ -747,7 +757,7 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", "dependencies": { @@ -758,9 +768,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -771,43 +781,43 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@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" } }, "node_modules/escape-html": { "version": "1.0.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/escape-html/-/escape-html-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, "node_modules/etag": { "version": "1.8.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/etag/-/etag-1.8.1.tgz", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", "engines": { @@ -816,7 +826,7 @@ }, "node_modules/eventsource": { "version": "3.0.7", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/eventsource/-/eventsource-3.0.7.tgz", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", "dependencies": { @@ -828,7 +838,7 @@ }, "node_modules/eventsource-parser": { "version": "3.0.6", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { @@ -837,7 +847,7 @@ }, "node_modules/express": { "version": "5.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express/-/express-5.1.0.tgz", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { @@ -879,7 +889,7 @@ }, "node_modules/express-rate-limit": { "version": "7.5.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", "engines": { @@ -894,19 +904,29 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/finalhandler": { "version": "2.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/finalhandler/-/finalhandler-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { @@ -923,7 +943,7 @@ }, "node_modules/forwarded": { "version": "0.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/forwarded/-/forwarded-0.2.0.tgz", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", "engines": { @@ -932,7 +952,7 @@ }, "node_modules/fresh": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/fresh/-/fresh-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { @@ -941,9 +961,10 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/fsevents/-/fsevents-2.3.3.tgz", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -955,36 +976,24 @@ }, "node_modules/function-bind": { "version": "1.1.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/function-bind/-/function-bind-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-intrinsic": { - "version": "1.3.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/get-intrinsic/-/get-intrinsic-1.3.1.tgz", - "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "async-generator-function": "^1.0.0", "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "generator-function": "^2.0.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", @@ -1000,7 +1009,7 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/get-proto/-/get-proto-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", "dependencies": { @@ -1012,9 +1021,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.12.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/get-tsconfig/-/get-tsconfig-4.12.0.tgz", - "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1026,7 +1035,7 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/gopd/-/gopd-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", "engines": { @@ -1038,8 +1047,9 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/has-symbols/-/has-symbols-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -1049,7 +1059,7 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/hasown/-/hasown-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { @@ -1060,33 +1070,28 @@ } }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/iconv-lite/-/iconv-lite-0.6.3.tgz", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { @@ -1098,13 +1103,13 @@ }, "node_modules/inherits": { "version": "2.0.4", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/inherits/-/inherits-2.0.4.tgz", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, "node_modules/ipaddr.js": { "version": "1.9.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", "engines": { @@ -1113,25 +1118,25 @@ }, "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/is-promise/-/is-promise-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/isexe/-/isexe-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { @@ -1140,7 +1145,7 @@ }, "node_modules/media-typer": { "version": "1.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/media-typer/-/media-typer-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { @@ -1149,7 +1154,7 @@ }, "node_modules/merge-descriptors": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", "engines": { @@ -1161,7 +1166,7 @@ }, "node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/mime-db/-/mime-db-1.54.0.tgz", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { @@ -1169,26 +1174,30 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/ms/-/ms-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/negotiator/-/negotiator-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { @@ -1197,7 +1206,7 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/object-assign/-/object-assign-4.1.1.tgz", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { @@ -1206,7 +1215,7 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/object-inspect/-/object-inspect-1.13.4.tgz", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { @@ -1218,7 +1227,7 @@ }, "node_modules/on-finished": { "version": "2.4.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/on-finished/-/on-finished-2.4.1.tgz", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { @@ -1230,7 +1239,7 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/once/-/once-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", "dependencies": { @@ -1239,7 +1248,7 @@ }, "node_modules/parseurl": { "version": "1.3.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/parseurl/-/parseurl-1.3.3.tgz", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", "engines": { @@ -1248,7 +1257,7 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/path-key/-/path-key-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", "engines": { @@ -1257,7 +1266,7 @@ }, "node_modules/path-to-regexp": { "version": "8.3.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", "funding": { @@ -1266,9 +1275,9 @@ } }, "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", "engines": { "node": ">=16.20.0" @@ -1276,7 +1285,7 @@ }, "node_modules/proxy-addr": { "version": "2.0.7", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/proxy-addr/-/proxy-addr-2.0.7.tgz", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { @@ -1287,18 +1296,9 @@ "node": ">= 0.10" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.14.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/qs/-/qs-6.14.0.tgz", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { @@ -1313,7 +1313,7 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/range-parser/-/range-parser-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", "engines": { @@ -1321,15 +1321,15 @@ } }, "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" @@ -1337,7 +1337,7 @@ }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/iconv-lite/-/iconv-lite-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { @@ -1351,9 +1351,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", @@ -1363,7 +1372,7 @@ }, "node_modules/router": { "version": "2.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/router/-/router-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { @@ -1377,35 +1386,15 @@ "node": ">= 18" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, "node_modules/send": { "version": "1.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/send/-/send-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { @@ -1427,7 +1416,7 @@ }, "node_modules/serve-static": { "version": "2.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/serve-static/-/serve-static-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { @@ -1442,13 +1431,13 @@ }, "node_modules/setprototypeof": { "version": "1.2.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/setprototypeof/-/setprototypeof-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/shebang-command/-/shebang-command-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { @@ -1460,7 +1449,7 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/shebang-regex/-/shebang-regex-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { @@ -1469,7 +1458,7 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/side-channel/-/side-channel-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", "dependencies": { @@ -1488,7 +1477,7 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/side-channel-list/-/side-channel-list-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", "dependencies": { @@ -1504,7 +1493,7 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/side-channel-map/-/side-channel-map-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", "dependencies": { @@ -1522,7 +1511,7 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { @@ -1541,7 +1530,7 @@ }, "node_modules/statuses": { "version": "2.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/statuses/-/statuses-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { @@ -1550,7 +1539,7 @@ }, "node_modules/toidentifier": { "version": "1.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/toidentifier/-/toidentifier-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", "engines": { @@ -1559,7 +1548,7 @@ }, "node_modules/tsx": { "version": "4.20.6", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/tsx/-/tsx-4.20.6.tgz", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", @@ -1579,7 +1568,7 @@ }, "node_modules/type-is": { "version": "2.0.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/type-is/-/type-is-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { @@ -1593,7 +1582,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/typescript/-/typescript-5.9.3.tgz", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", @@ -1606,31 +1595,23 @@ } }, "node_modules/undici-types": { - "version": "7.14.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/undici-types/-/undici-types-7.14.0.tgz", - "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/unpipe/-/unpipe-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", "engines": { "node": ">= 0.8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/vary": { "version": "1.1.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/vary/-/vary-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { @@ -1639,7 +1620,7 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/which/-/which-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", "dependencies": { @@ -1654,12 +1635,13 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/wrappy/1.0.2/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, "node_modules/zod": { "version": "3.25.76", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/zod/-/zod-3.25.76.tgz", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { @@ -1667,12 +1649,12 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-all/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "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==", "license": "ISC", "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 83e462e..5e12580 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -18,7 +18,8 @@ "@modelcontextprotocol/sdk": "^1.20.1", "@types/cors": "^2.8.19", "cors": "^2.8.5", - "express": "^5.1.0" + "express": "^5.1.0", + "zod-to-json-schema": "^3.25.0" }, "devDependencies": { "tsx": "^4.7.0", diff --git a/package-lock.json b/package-lock.json index 57b3450..fcfc981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0-beta.0", "commander": "^14.0.2", + "eventsource-parser": "^3.0.6", "express": "^5.1.0", "zod": "^3.25.76" }, diff --git a/package.json b/package.json index 4a56eb0..2dc81c3 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0-beta.0", "commander": "^14.0.2", + "eventsource-parser": "^3.0.6", "express": "^5.1.0", "zod": "^3.25.76" } diff --git a/src/index.ts b/src/index.ts index a65e79f..6128a16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -204,11 +204,14 @@ program 'Suite to run: "active" (default, excludes pending), "all", or "pending"', 'active' ) + .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { // Validate options with Zod const validated = ServerOptionsSchema.parse(options); + const verbose = options.verbose ?? false; + // If a single scenario is specified, run just that one if (validated.scenario) { const result = await runServerConformanceTest( @@ -218,7 +221,8 @@ program const { failed } = printServerResults( result.checks, - result.scenarioDescription + result.scenarioDescription, + verbose ); process.exit(failed > 0 ? 1 : 0); } else { diff --git a/src/runner/server.ts b/src/runner/server.ts index 46be558..18c8254 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { ConformanceCheck } from '../types'; import { getClientScenario } from '../scenarios'; -import { ensureResultsDir, createResultDir } from './utils'; +import { ensureResultsDir, createResultDir, formatPrettyChecks } from './utils'; /** * Format markdown-style text for terminal output using ANSI codes @@ -54,7 +54,8 @@ export async function runServerConformanceTest( export function printServerResults( checks: ConformanceCheck[], - scenarioDescription: string + scenarioDescription: string, + verbose: boolean = false ): { passed: number; failed: number; @@ -68,7 +69,11 @@ export function printServerResults( const failed = checks.filter((c) => c.status === 'FAILURE').length; const warnings = checks.filter((c) => c.status === 'WARNING').length; - console.log(`Checks:\n${JSON.stringify(checks, null, 2)}`); + if (verbose) { + console.log(JSON.stringify(checks, null, 2)); + } else { + console.log(`Checks:\n${formatPrettyChecks(checks)}`); + } console.log(`\nTest Results:`); console.log( diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts new file mode 100644 index 0000000..6e44a06 --- /dev/null +++ b/src/scenarios/client/sse-retry.ts @@ -0,0 +1,555 @@ +/** + * SSE Retry conformance test scenarios for MCP clients (SEP-1699) + * + * Tests that clients properly respect the SSE retry field by: + * - Waiting the specified milliseconds before reconnecting + * - Sending Last-Event-ID header on reconnection + * - Treating graceful stream closure as reconnectable + */ + +import http from 'http'; +import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; + +export class SSERetryScenario implements Scenario { + name = 'sse-retry'; + description = + 'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)'; + + private server: http.Server | null = null; + private checks: ConformanceCheck[] = []; + private port: number = 0; + + // Timing tracking + private toolStreamCloseTime: number | null = null; + private getReconnectionTime: number | null = null; + private getConnectionCount: number = 0; + private lastEventIds: (string | undefined)[] = []; + private retryValue: number = 500; // 500ms + private eventIdCounter: number = 0; + private sessionId: string = `session-${Date.now()}`; + + // Pending tool call to respond to after reconnection + private pendingToolCallId: number | string | null = null; + private getResponseStream: http.ServerResponse | null = null; + + // Tolerances for timing validation + 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 { + 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(); + } + }); + } + + getChecks(): ConformanceCheck[] { + // Generate checks based on observed behavior + this.generateChecks(); + return this.checks; + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method === 'GET') { + // Track GET reconnection timing and Last-Event-ID + this.getConnectionCount++; + this.getReconnectionTime = performance.now(); + + const lastEventId = req.headers['last-event-id'] as string | undefined; + const description = lastEventId + ? `Received GET request for ${req.url} (Last-Event-ID: ${lastEventId})` + : `Received GET request for ${req.url}`; + this.checks.push({ + id: 'incoming-request', + name: 'IncomingRequest', + description, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + method: 'GET', + url: req.url, + headers: req.headers, + connectionCount: this.getConnectionCount + } + }); + + if (lastEventId) { + this.lastEventIds.push(lastEventId); + } + + // Handle GET SSE stream request (reconnection) + this.handleGetSSEStream(req, res); + } else if (req.method === 'POST') { + // Handle POST JSON-RPC requests + this.handlePostRequest(req, res); + } else { + res.writeHead(405); + res.end('Method Not Allowed'); + } + } + + private handleGetSSEStream( + _req: http.IncomingMessage, + res: http.ServerResponse + ): void { + // Set SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'mcp-session-id': this.sessionId + }); + + // Generate event ID + this.eventIdCounter++; + const eventId = `event-${this.eventIdCounter}`; + + // Send priming event with ID and retry field + const primingContent = `id: ${eventId}\nretry: ${this.retryValue}\ndata: \n\n`; + res.write(primingContent); + + this.checks.push({ + id: 'outgoing-sse-event', + name: 'OutgoingSseEvent', + description: `Sent SSE priming event on GET stream (id: ${eventId}, retry: ${this.retryValue}ms)`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + eventId, + retryMs: this.retryValue, + eventType: 'priming', + raw: primingContent + } + }); + + // Store the GET stream to send pending tool response + this.getResponseStream = res; + + // If we have a pending tool call, send the response now + if (this.pendingToolCallId !== null) { + const toolResponse = { + jsonrpc: '2.0', + id: this.pendingToolCallId, + result: { + content: [ + { + type: 'text', + text: 'Reconnection test completed successfully' + } + ] + } + }; + + const responseEventId = `event-${++this.eventIdCounter}`; + const responseContent = `event: message\nid: ${responseEventId}\ndata: ${JSON.stringify(toolResponse)}\n\n`; + res.write(responseContent); + + this.checks.push({ + id: 'outgoing-sse-event', + name: 'OutgoingSseEvent', + description: `Sent tool response on GET stream after reconnection (id: ${responseEventId})`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + eventId: responseEventId, + eventType: 'message', + jsonrpcId: this.pendingToolCallId, + body: toolResponse, + raw: responseContent + } + }); + + this.pendingToolCallId = null; + } + } + + private handlePostRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + let body = ''; + + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const request = JSON.parse(body); + + this.checks.push({ + id: 'incoming-request', + name: 'IncomingRequest', + description: `Received POST request for ${req.url} (method: ${request.method})`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + method: 'POST', + url: req.url, + jsonrpcMethod: request.method, + jsonrpcId: request.id + } + }); + + if (request.method === 'initialize') { + this.handleInitialize(req, 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) { + // Notifications (no id) - return 202 Accepted + res.writeHead(202); + res.end(); + } else { + // For other requests, send a simple JSON response + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: {} + }) + ); + } + } catch (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32700, + message: `Parse error: ${error}` + } + }) + ); + } + }); + } + + private handleInitialize( + _req: http.IncomingMessage, + res: http.ServerResponse, + request: any + ): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + const response = { + jsonrpc: '2.0', + id: request.id, + result: { + protocolVersion: '2025-03-26', + serverInfo: { + name: 'sse-retry-test-server', + version: '1.0.0' + }, + capabilities: { + tools: {} + } + } + }; + + res.end(JSON.stringify(response)); + + this.checks.push({ + id: 'outgoing-response', + name: 'OutgoingResponse', + description: `Sent initialize response`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + jsonrpcId: request.id, + body: response + } + }); + } + + private handleToolsList(res: http.ServerResponse, request: any): void { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'mcp-session-id': this.sessionId + }); + + const response = { + jsonrpc: '2.0', + id: request.id, + result: { + tools: [ + { + name: 'test_reconnection', + description: + 'A tool that triggers SSE stream closure to test client reconnection behavior', + inputSchema: { + type: 'object', + properties: {}, + required: [] + } + } + ] + } + }; + + res.end(JSON.stringify(response)); + + this.checks.push({ + id: 'outgoing-response', + name: 'OutgoingResponse', + description: `Sent tools/list response`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + jsonrpcId: request.id, + body: response + } + }); + } + + private handleToolsCall(res: http.ServerResponse, request: any): void { + // Store the request ID so we can respond after reconnection + this.pendingToolCallId = request.id; + + // Start SSE stream + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'mcp-session-id': this.sessionId + }); + + // Send priming event with retry field + this.eventIdCounter++; + const primingEventId = `event-${this.eventIdCounter}`; + const primingContent = `id: ${primingEventId}\nretry: ${this.retryValue}\ndata: \n\n`; + res.write(primingContent); + + this.checks.push({ + id: 'outgoing-sse-event', + name: 'OutgoingSseEvent', + description: `Sent SSE priming event for tools/call (id: ${primingEventId}, retry: ${this.retryValue}ms)`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + eventId: primingEventId, + retryMs: this.retryValue, + eventType: 'priming', + raw: primingContent + } + }); + + // Close the stream after a short delay to trigger reconnection + setTimeout(() => { + this.toolStreamCloseTime = performance.now(); + this.checks.push({ + id: 'outgoing-stream-close', + name: 'OutgoingStreamClose', + description: + 'Closed tools/call SSE stream to trigger client reconnection', + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + retryMs: this.retryValue, + pendingToolCallId: this.pendingToolCallId + } + }); + res.end(); + }, 50); + } + + private generateChecks(): void { + // Check 1: Client should have reconnected via GET after tool call stream close + if (this.getConnectionCount < 1) { + this.checks.push({ + id: 'client-sse-graceful-reconnect', + name: 'ClientGracefulReconnect', + description: + 'Client reconnects via GET after SSE stream is closed gracefully', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Client did not attempt GET reconnection after stream closure. Client should treat graceful stream close as reconnectable.`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + getConnectionCount: this.getConnectionCount, + toolStreamCloseTime: this.toolStreamCloseTime, + retryValue: this.retryValue + } + }); + return; + } + + // Client did reconnect - SUCCESS for graceful reconnection + this.checks.push({ + id: 'client-sse-graceful-reconnect', + name: 'ClientGracefulReconnect', + description: + 'Client reconnects via GET after SSE stream is closed gracefully', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + getConnectionCount: this.getConnectionCount + } + }); + + // Check 2: Client MUST respect retry field timing + if ( + this.toolStreamCloseTime !== null && + this.getReconnectionTime !== null + ) { + const actualDelay = this.getReconnectionTime - this.toolStreamCloseTime; + const minExpected = this.retryValue - this.EARLY_TOLERANCE; + const maxExpected = this.retryValue + this.LATE_TOLERANCE; + + 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, + timestamp: new Date().toISOString(), + errorMessage, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + 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 + } + }); + } else { + this.checks.push({ + id: 'client-sse-retry-timing', + name: 'ClientRespectsRetryField', + description: 'Client MUST respect the retry field timing', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: + 'Could not measure timing - tool stream close time or GET reconnection time not recorded', + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + toolStreamCloseTime: this.toolStreamCloseTime, + getReconnectionTime: this.getReconnectionTime + } + }); + } + + // Check 3: Client SHOULD send Last-Event-ID header on reconnection + const hasLastEventId = + this.lastEventIds.length > 0 && this.lastEventIds[0] !== undefined; + + this.checks.push({ + id: 'client-sse-last-event-id', + name: 'ClientSendsLastEventId', + description: + 'Client SHOULD send Last-Event-ID header on reconnection for resumability', + status: hasLastEventId ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + hasLastEventId, + lastEventIds: this.lastEventIds, + getConnectionCount: this.getConnectionCount + }, + errorMessage: !hasLastEventId + ? 'Client did not send Last-Event-ID header on reconnection. This is a SHOULD requirement for resumability.' + : undefined + }); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index f0d1e78..de76cd6 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -2,6 +2,7 @@ import { Scenario, ClientScenario } from '../types'; import { InitializeScenario } from './client/initialize'; import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; +import { SSERetryScenario } from './client/sse-retry'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; @@ -29,6 +30,8 @@ import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12'; import { ElicitationDefaultsScenario } from './server/elicitation-defaults'; import { ElicitationEnumsScenario } from './server/elicitation-enums'; +import { ServerSSEPollingScenario } from './server/sse-polling'; +import { ServerSSEMultipleStreamsScenario } from './server/sse-multiple-streams'; import { ResourcesListScenario, @@ -63,7 +66,11 @@ const pendingClientScenariosList: ClientScenario[] = [ // On hold until elicitation schema types are fixed // https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1863 new ToolsCallElicitationScenario(), - new ElicitationDefaultsScenario() + new ElicitationDefaultsScenario(), + + // On hold until server-side SSE improvements are made + // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 + new ServerSSEPollingScenario() ]; // All client scenarios @@ -94,6 +101,10 @@ const allClientScenariosList: ClientScenario[] = [ // Elicitation scenarios (SEP-1034) new ElicitationDefaultsScenario(), + // SSE Polling scenarios (SEP-1699) + new ServerSSEPollingScenario(), + new ServerSSEMultipleStreamsScenario(), + // Elicitation scenarios (SEP-1330) - pending new ElicitationEnumsScenario(), @@ -132,6 +143,7 @@ const scenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), + new SSERetryScenario(), ...authScenariosList ]; diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 9f9a7e9..bbff157 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -125,9 +125,9 @@ describe('Server Scenarios', () => { throw new Error(`Scenario failed with checks:\n ${failureMessages}`); } - // All checks should be SUCCESS - const successes = checks.filter((c) => c.status === 'SUCCESS'); - expect(successes.length).toBe(checks.length); + // 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); }, 10000); // 10 second timeout per scenario } }); diff --git a/src/scenarios/server/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts new file mode 100644 index 0000000..7cda3f2 --- /dev/null +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -0,0 +1,268 @@ +/** + * SSE Multiple Streams conformance test scenarios for MCP servers (SEP-1699) + * + * Tests that servers properly support multiple concurrent SSE streams: + * - Accepting multiple POST requests that return SSE streams simultaneously + * - Each POST request gets its own stream with unique stream ID + * + * Note: The standalone GET stream (without Last-Event-ID) is limited to one per session. + * Multiple concurrent streams are achieved via POST requests, each getting their own stream. + */ + +import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +export class ServerSSEMultipleStreamsScenario implements ClientScenario { + name = 'server-sse-multiple-streams'; + description = + 'Test server supports multiple concurrent POST SSE streams (SEP-1699)'; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + let sessionId: 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' + } + ], + details: { + message: + 'Server did not provide session ID - multiple streams test may not work correctly' + } + }); + return checks; + } + + // 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" + const streamResponses: Response[] = []; + const numStreams = 3; + + // Launch all POST requests concurrently + const postPromises = []; + 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' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1000 + i, // Different request IDs for each stream + method: 'tools/list', + params: {} + }) + }); + postPromises.push(promise); + } + + // Wait for all responses + const responses = await Promise.all(postPromises); + streamResponses.push(...responses); + + // Check that all streams were accepted (HTTP 200) + const allAccepted = streamResponses.every((r) => r.ok); + const statuses = streamResponses.map((r) => r.status); + const contentTypes = streamResponses.map((r) => + r.headers.get('content-type') + ); + + // Count how many returned SSE streams vs JSON + const sseStreams = contentTypes.filter((ct) => + ct?.includes('text/event-stream') + ).length; + + checks.push({ + id: 'server-accepts-multiple-post-streams', + name: 'ServerAcceptsMultiplePostStreams', + description: + 'Server allows multiple concurrent POST requests (each may return SSE or JSON)', + status: allAccepted ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + numStreamsAttempted: numStreams, + numStreamsAccepted: statuses.filter((s) => s === 200).length, + numSseStreams: sseStreams, + statuses, + contentTypes + }, + errorMessage: !allAccepted + ? `Server rejected some requests. Statuses: ${statuses.join(', ')}` + : undefined + }); + + // Step 3: Verify SSE streams are functional by reading events + // Only test streams that returned SSE content-type + const eventResults = await Promise.all( + streamResponses.map(async (response, index) => { + const contentType = response.headers.get('content-type'); + + // Skip non-SSE responses (JSON responses are also valid) + if (!contentType?.includes('text/event-stream')) { + return { index, type: 'json', skipped: true }; + } + + if (!response.ok || !response.body) { + return { index, type: 'sse', error: 'Stream not available' }; + } + + try { + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + + // Wait for one event with timeout + const timeoutPromise = new Promise((resolve) => + setTimeout(() => resolve(null), 2000) + ); + + const eventPromise = reader.read().then(({ value }) => value); + + const event = await Promise.race([eventPromise, timeoutPromise]); + + // Cancel reader + await reader.cancel(); + + return { index, type: 'sse', event }; + } catch (error) { + return { + index, + type: 'sse', + error: error instanceof Error ? error.message : String(error) + }; + } + }) + ); + + // Count functional SSE streams (received event or timed out waiting - both are valid) + const sseResults = eventResults.filter((r) => r.type === 'sse'); + const functionalSseStreams = sseResults.filter( + (r) => !('error' in r) + ).length; + + // If server returned SSE streams, they should be functional + if (sseStreams > 0) { + checks.push({ + id: 'server-sse-streams-functional', + name: 'ServerSSEStreamsFunctional', + description: 'Multiple POST SSE streams should be functional', + status: + functionalSseStreams === sseStreams + ? 'SUCCESS' + : functionalSseStreams > 0 + ? 'WARNING' + : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + numSseStreams: sseStreams, + functionalSseStreams, + results: eventResults + }, + errorMessage: + functionalSseStreams < sseStreams + ? `Only ${functionalSseStreams}/${sseStreams} SSE streams were functional` + : undefined + }); + } else { + // Server returned JSON for all requests - this is valid but worth noting + checks.push({ + id: 'server-sse-streams-functional', + name: 'ServerSSEStreamsFunctional', + description: 'Server returned JSON responses (SSE streams optional)', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + numSseStreams: 0, + message: + 'Server returned JSON for all requests - SSE streaming is optional', + results: eventResults + } + }); + } + } catch (error) { + checks.push({ + id: 'server-sse-multiple-streams-error', + name: 'ServerSSEMultipleStreamsTest', + description: 'Test server multiple SSE streams behavior', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Error: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + } finally { + // Clean up + if (client) { + try { + await client.close(); + } catch { + // Ignore cleanup errors + } + } + } + + return checks; + } +} diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts new file mode 100644 index 0000000..5a3a240 --- /dev/null +++ b/src/scenarios/server/sse-polling.ts @@ -0,0 +1,601 @@ +/** + * SSE Polling conformance test scenarios for MCP servers (SEP-1699) + * + * Tests that servers properly implement SSE polling behavior including: + * - Sending priming events with event ID and empty data on POST SSE streams + * - Sending retry field in priming events when configured + * - Closing SSE stream mid-operation and resuming after client reconnects + * - Replaying events when client reconnects with Last-Event-ID + */ + +import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +function createLoggingFetch(checks: ConformanceCheck[]) { + return async (url: string, options: RequestInit): Promise => { + const method = options.method || 'GET'; + let description = `Sending ${method} request`; + if (options.body) { + try { + const body = JSON.parse(options.body as string); + if (body.method) { + description = `Sending ${method} ${body.method}`; + } + } catch { + // Not JSON + } + } + + checks.push({ + id: 'outgoing-request', + name: 'OutgoingRequest', + description, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + method, + url, + headers: options.headers, + body: options.body ? JSON.parse(options.body as string) : undefined + } + }); + + const response = await fetch(url, options); + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + checks.push({ + id: 'incoming-response', + name: 'IncomingResponse', + description: `Received ${response.status} response for ${method}`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + statusCode: response.status, + headers: responseHeaders + } + }); + + return response; + }; +} + +export class ServerSSEPollingScenario implements ClientScenario { + name = 'server-sse-polling'; + description = + 'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)'; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + let sessionId: 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 (accessing internal state) + sessionId = (transport as unknown as { sessionId?: string }).sessionId; + + if (!sessionId) { + checks.push({ + id: 'server-sse-polling-session', + name: 'ServerSSEPollingSession', + description: 'Server provides session ID for SSE polling tests', + 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 - SSE polling tests may not work correctly' + } + }); + } + + // Step 2: Call test_reconnection tool via raw fetch to observe SSE behavior + // This tool should close the stream mid-call, requiring reconnection + const loggingFetch = createLoggingFetch(checks); + + const postResponse = await loggingFetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + ...(sessionId && { 'mcp-session-id': sessionId }), + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test_reconnection', + arguments: {} + } + }) + }); + + if (!postResponse.ok) { + // Check if tool doesn't exist (method not found or similar) + if (postResponse.status === 400 || postResponse.status === 404) { + checks.push({ + id: 'server-sse-test-reconnection-tool', + name: 'ServerTestReconnectionTool', + description: + 'Server implements test_reconnection tool for SSE polling tests', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Server does not implement test_reconnection tool (HTTP ${postResponse.status}). This tool is recommended for testing SSE polling behavior.`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + return checks; + } + + checks.push({ + id: 'server-sse-post-request', + name: 'ServerSSEPostRequest', + description: 'Server accepts POST request with SSE stream response', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Server returned HTTP ${postResponse.status}`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + return checks; + } + + // Check if server returned SSE stream + const contentType = postResponse.headers.get('content-type'); + if (!contentType?.includes('text/event-stream')) { + checks.push({ + id: 'server-sse-content-type', + name: 'ServerSSEContentType', + description: 'Server returns text/event-stream for POST request', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + contentType, + message: + 'Server returned JSON instead of SSE stream - priming event tests not applicable' + } + }); + return checks; + } + + // Step 3: Parse SSE stream for priming event and tool response + let hasEventId = false; + let hasPrimingEvent = false; + let primingEventIsFirst = false; + let hasRetryField = false; + let retryValue: number | undefined; + let lastEventId: string | undefined; + let eventCount = 0; + let receivedToolResponse = false; + + if (!postResponse.body) { + checks.push({ + id: 'server-sse-polling-stream', + name: 'ServerSSEPollingStream', + description: 'Server provides SSE response body', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: 'Response body is null', + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + return checks; + } + + const reader = postResponse.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough( + new EventSourceParserStream({ + onRetry: (retryMs: number) => { + hasRetryField = true; + retryValue = retryMs; + } + }) + ) + .getReader(); + + // Read events with timeout - expect stream to close before we get the response + const timeout = setTimeout(() => { + reader.cancel(); + }, 10000); + + try { + while (true) { + const { value: event, done } = await reader.read(); + + if (done) { + break; + } + + eventCount++; + + // Track the last event ID for reconnection + if (event.id) { + hasEventId = true; + lastEventId = event.id; + + // Check if this is a priming event (empty or minimal data) + const isPriming = + event.data === '' || + event.data === '{}' || + event.data.trim() === ''; + if (isPriming) { + hasPrimingEvent = true; + // Check if priming event is the first event + if (eventCount === 1) { + primingEventIsFirst = true; + } + } + + // Log the SSE event + checks.push({ + id: 'incoming-sse-event', + name: 'IncomingSseEvent', + description: isPriming + ? `Received SSE priming event (id: ${event.id})` + : `Received SSE event (id: ${event.id})`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + eventId: event.id, + eventType: event.event || 'message', + isPriming, + hasRetryField, + retryValue, + data: event.data + } + }); + } + + // Check if this is the tool response + if (event.data) { + try { + const parsed = JSON.parse(event.data); + if (parsed.id === 1 && parsed.result) { + receivedToolResponse = true; + const isError = parsed.result?.isError === true; + checks.push({ + id: 'incoming-sse-event', + name: 'IncomingSseEvent', + description: `Received tool response on POST stream`, + status: isError ? 'FAILURE' : 'INFO', + timestamp: new Date().toISOString(), + details: { + eventId: event.id, + body: parsed + }, + ...(isError && { errorMessage: `Tool call failed` }) + }); + } + } catch { + // Not JSON, ignore + } + } + } + } finally { + clearTimeout(timeout); + } + + // Log stream closure + checks.push({ + id: 'stream-closed', + name: 'StreamClosed', + description: `POST SSE stream closed after ${eventCount} event(s)`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + eventCount, + lastEventId, + receivedToolResponse + } + }); + + // Check 1: Server SHOULD send priming event with ID on POST SSE stream + let primingStatus: 'SUCCESS' | 'WARNING' = 'SUCCESS'; + let primingErrorMessage: string | undefined; + + if (!hasPrimingEvent) { + primingStatus = 'WARNING'; + primingErrorMessage = + 'Server did not send priming event with id and empty data on POST SSE stream. This is recommended for resumability.'; + } else if (!primingEventIsFirst) { + primingStatus = 'WARNING'; + primingErrorMessage = + 'Priming event was not sent first. It should be sent immediately when the SSE stream is established.'; + } + + checks.push({ + id: 'server-sse-priming-event', + name: 'ServerSendsPrimingEvent', + description: + 'Server SHOULD send priming event with id and empty data on POST SSE streams', + status: primingStatus, + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + hasPrimingEvent, + primingEventIsFirst, + hasEventId, + lastEventId, + eventCount + }, + errorMessage: primingErrorMessage + }); + + // Check 2: Server SHOULD send retry field in priming event + checks.push({ + id: 'server-sse-retry-field', + name: 'ServerSendsRetryField', + description: + 'Server SHOULD send retry field to control client reconnection timing', + status: hasRetryField ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + hasRetryField, + retryValue + }, + errorMessage: !hasRetryField + ? 'Server did not send retry field. This is recommended for controlling client reconnection timing.' + : undefined + }); + + // Step 4: If tool response wasn't received, reconnect with Last-Event-ID + if (!receivedToolResponse && lastEventId && sessionId) { + // Make a GET request with Last-Event-ID to get the tool response + const getResponse = await loggingFetch(serverUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + 'last-event-id': lastEventId + } + }); + + if (getResponse.ok && getResponse.body) { + const reconnectReader = getResponse.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new EventSourceParserStream()) + .getReader(); + + const reconnectTimeout = setTimeout(() => { + reconnectReader.cancel(); + }, 5000); + + try { + while (true) { + const { value: event, done } = await reconnectReader.read(); + if (done) break; + + // Log each event received on GET stream + checks.push({ + id: 'incoming-sse-event', + name: 'IncomingSseEvent', + description: `Received SSE event on GET reconnection stream (id: ${event.id || 'none'})`, + status: 'INFO', + timestamp: new Date().toISOString(), + details: { + eventId: event.id, + eventType: event.event || 'message', + data: event.data + } + }); + + // Check if this is the tool response + if (event.data) { + try { + const parsed = JSON.parse(event.data); + if (parsed.id === 1 && parsed.result) { + receivedToolResponse = true; + break; + } + } catch { + // Not JSON, ignore + } + } + } + } finally { + clearTimeout(reconnectTimeout); + } + + checks.push({ + id: 'server-sse-disconnect-resume', + name: 'ServerDisconnectResume', + description: + 'Server closes SSE stream mid-call and resumes after client reconnects with Last-Event-ID', + status: receivedToolResponse ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + lastEventIdUsed: lastEventId, + receivedToolResponse, + message: receivedToolResponse + ? 'Successfully received tool response after reconnection' + : 'Tool response not received after reconnection' + }, + errorMessage: !receivedToolResponse + ? 'Server did not send tool response after client reconnected with Last-Event-ID' + : undefined + }); + } else { + // Check if server doesn't support standalone GET streams + if (getResponse.status === 405) { + checks.push({ + id: 'server-sse-disconnect-resume', + name: 'ServerDisconnectResume', + description: + 'Server supports GET reconnection with Last-Event-ID', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + statusCode: getResponse.status, + message: + 'Server does not support standalone GET SSE endpoint (405 Method Not Allowed)' + } + }); + } else { + checks.push({ + id: 'server-sse-disconnect-resume', + name: 'ServerDisconnectResume', + description: + 'Server supports GET reconnection with Last-Event-ID', + status: 'WARNING', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + statusCode: getResponse.status, + lastEventIdUsed: lastEventId, + message: `Server returned ${getResponse.status} for GET request with Last-Event-ID` + }, + errorMessage: `Server did not accept reconnection with Last-Event-ID (HTTP ${getResponse.status})` + }); + } + } + } else if (receivedToolResponse) { + // Tool response was received on the initial POST stream (server didn't disconnect) + checks.push({ + id: 'server-sse-disconnect-resume', + name: 'ServerDisconnectResume', + description: + 'Server closes SSE stream mid-call and resumes after reconnection', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + receivedToolResponse: true, + message: + 'Tool response received on initial POST stream - server did not disconnect mid-call. The test_reconnection tool should close the stream before sending the result.' + } + }); + } else { + checks.push({ + id: 'server-sse-disconnect-resume', + name: 'ServerDisconnectResume', + description: + 'Server closes SSE stream mid-call and resumes after reconnection', + status: 'INFO', + timestamp: new Date().toISOString(), + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ], + details: { + lastEventId, + sessionId, + message: + 'Could not test disconnect/resume - no last event ID or session ID available' + } + }); + } + } catch (error) { + checks.push({ + id: 'server-sse-polling-error', + name: 'ServerSSEPollingTest', + description: 'Test server SSE polling behavior', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Error: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'SEP-1699', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699' + } + ] + }); + } finally { + // Clean up + if (client) { + try { + await client.close(); + } catch { + // Ignore cleanup errors + } + } + } + + return checks; + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 436dd00..93242b5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ globals: true, environment: 'node', include: ['**/*.test.ts'], - exclude: ['node_modules', 'dist'], + exclude: ['**/node_modules/**', 'dist'], // Run test files sequentially to avoid port conflicts fileParallelism: false, // Increase timeout for server tests in CI From b96a29a962133e8bc8ce4547b3741aa1103c8878 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 20:07:05 +0000 Subject: [PATCH 037/226] Add client credentials conformance tests (JWT and Basic auth) (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add client credentials conformance scenarios with dynamic keypair generation Implements SEP-1046 client credentials conformance tests: - auth/client-credentials-jwt: Tests private_key_jwt authentication - auth/client-credentials-basic: Tests client_secret_basic authentication Key changes: - Generate EC P-256 keypair dynamically at test start (no hardcoded keys) - Pass credentials to client via MCP_CONFORMANCE_CONTEXT environment variable - Add context field to ScenarioUrls interface for scenario-specific data - Update client runner to pass context as env var to spawned client process The MCP_CONFORMANCE_CONTEXT env var contains a JSON object with: - client_id: The expected client identifier - private_key_pem: PEM-encoded private key (for JWT scenarios) - client_secret: Client secret (for basic auth scenarios) - signing_algorithm: JWT signing algorithm (defaults to ES256) * Fix client-credentials scenarios for conformance testing - Generate EC keypair with extractable: true so private key can be exported and passed to clients via MCP_CONFORMANCE_CONTEXT - Fix client_secret_basic scenario to use authorizationHeader param instead of non-existent headers object * Fix jose type import - use CryptoKey instead of KeyLike * Add OAuth 2.1 section 4.2 spec reference to client credentials checks Add OAUTH_2_1_CLIENT_CREDENTIALS spec reference pointing to OAuth 2.1 draft section 4.2 (Client Credentials Grant) and include it in all client_credentials conformance checks for both JWT and basic auth flows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add JWT iss claim verification for client credentials Per RFC 7523, verify that the JWT issuer (iss) claim matches the expected client_id, in addition to the existing sub claim check. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- package-lock.json | 10 + package.json | 1 + src/runner/client.ts | 34 +- .../client/auth/client-credentials.ts | 400 ++++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 59 ++- .../client/auth/helpers/mockTokenVerifier.ts | 4 +- src/scenarios/client/auth/index.test.ts | 7 +- src/scenarios/client/auth/index.ts | 8 +- src/scenarios/client/auth/spec-references.ts | 12 + src/types.ts | 5 + 10 files changed, 517 insertions(+), 23 deletions(-) create mode 100644 src/scenarios/client/auth/client-credentials.ts diff --git a/package-lock.json b/package-lock.json index fcfc981..c8fe2e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", + "jose": "^6.1.2", "zod": "^3.25.76" }, "bin": { @@ -3579,6 +3580,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", + "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", diff --git a/package.json b/package.json index 2dc81c3..fc781d9 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", + "jose": "^6.1.2", "zod": "^3.25.76" } } diff --git a/src/runner/client.ts b/src/runner/client.ts index 07454ff..cafb7c3 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -15,7 +15,8 @@ export interface ClientExecutionResult { async function executeClient( command: string, serverUrl: string, - timeout: number = 30000 + timeout: number = 30000, + context?: Record ): Promise { const commandParts = command.split(' '); const executable = commandParts[0]; @@ -25,30 +26,37 @@ async function executeClient( let stderr = ''; let timedOut = false; + // Build environment with optional context + const env = { ...process.env }; + if (context) { + env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context); + } + return new Promise((resolve) => { - const process = spawn(executable, args, { + const childProcess = spawn(executable, args, { shell: true, - stdio: 'pipe' + stdio: 'pipe', + env }); const timeoutHandle = setTimeout(() => { timedOut = true; - process.kill(); + childProcess.kill(); }, timeout); - if (process.stdout) { - process.stdout.on('data', (data) => { + if (childProcess.stdout) { + childProcess.stdout.on('data', (data) => { stdout += data.toString(); }); } - if (process.stderr) { - process.stderr.on('data', (data) => { + if (childProcess.stderr) { + childProcess.stderr.on('data', (data) => { stderr += data.toString(); }); } - process.on('close', (code) => { + childProcess.on('close', (code) => { clearTimeout(timeoutHandle); resolve({ exitCode: code || 0, @@ -58,7 +66,7 @@ async function executeClient( }); }); - process.on('error', (error) => { + childProcess.on('error', (error) => { clearTimeout(timeoutHandle); resolve({ exitCode: -1, @@ -90,12 +98,16 @@ export async function runConformanceTest( const urls = await scenario.start(); console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); + if (urls.context) { + console.error(`With context: ${JSON.stringify(urls.context)}`); + } try { const clientOutput = await executeClient( clientCommand, urls.serverUrl, - timeout + timeout, + urls.context ); // Print stdout/stderr if client exited with nonzero code diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts new file mode 100644 index 0000000..df13384 --- /dev/null +++ b/src/scenarios/client/auth/client-credentials.ts @@ -0,0 +1,400 @@ +import * as jose from 'jose'; +import type { CryptoKey } from 'jose'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import { createAuthServer } from './helpers/createAuthServer'; +import { createServer } from './helpers/createServer'; +import { ServerLifecycle } from './helpers/serverLifecycle'; +import { SpecReferences } from './spec-references'; + +const CONFORMANCE_TEST_CLIENT_ID = 'conformance-test-client'; +const CONFORMANCE_TEST_CLIENT_SECRET = 'conformance-test-secret'; + +/** + * Generate an EC P-256 keypair for JWT signing. + * Returns both public key (for server verification) and private key PEM (for client signing). + */ +async function generateTestKeypair(): Promise<{ + publicKey: CryptoKey; + privateKeyPem: string; +}> { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); + const privateKeyPem = await jose.exportPKCS8(privateKey); + return { publicKey, privateKeyPem }; +} + +/** + * Scenario: Client Credentials with JWT Authentication (SEP-1046) + * + * Tests OAuth client_credentials flow with private_key_jwt authentication. + * Client authenticates using a JWT assertion signed with a dynamically generated keypair. + */ +export class ClientCredentialsJwtScenario implements Scenario { + name = 'auth/client-credentials-jwt'; + description = + 'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + // Generate a fresh keypair for this test run + const { publicKey, privateKeyPem } = await generateTestKeypair(); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: ['client_credentials'], + tokenEndpointAuthMethodsSupported: ['private_key_jwt'], + tokenEndpointAuthSigningAlgValuesSupported: ['ES256'], + onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { + // Per RFC 7523bis, the audience MUST be the issuer identifier + const issuerUrl = authBaseUrl.endsWith('/') + ? authBaseUrl + : `${authBaseUrl}/`; + if (grantType !== 'client_credentials') { + this.checks.push({ + id: 'client-credentials-grant-type', + name: 'ClientCredentialsGrantType', + description: `Expected grant_type=client_credentials, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only client_credentials grant is supported' + }; + } + + const clientAssertion = body.client_assertion; + const clientAssertionType = body.client_assertion_type; + + // Verify assertion type + if ( + clientAssertionType !== + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + ) { + this.checks.push({ + id: 'client-credentials-assertion-type', + name: 'ClientCredentialsAssertionType', + description: `Invalid client_assertion_type: ${clientAssertionType}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH] + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid client_assertion_type', + statusCode: 401 + }; + } + + // Verify JWT signature and claims using the generated public key + try { + // Per RFC 7523bis, audience MUST be the issuer identifier + const { payload } = await jose.jwtVerify(clientAssertion, publicKey, { + audience: issuerUrl, + clockTolerance: 30 + }); + + // Verify iss claim matches expected client_id + if (payload.iss !== CONFORMANCE_TEST_CLIENT_ID) { + this.checks.push({ + id: 'client-credentials-jwt-iss', + name: 'ClientCredentialsJwtIss', + description: `JWT iss claim '${payload.iss}' does not match expected client_id '${CONFORMANCE_TEST_CLIENT_ID}'`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH], + details: { + expected: CONFORMANCE_TEST_CLIENT_ID, + actual: payload.iss + } + }); + return { + error: 'invalid_client', + errorDescription: + 'JWT iss claim does not match expected client_id', + statusCode: 401 + }; + } + + // Verify sub claim matches expected client_id + if (payload.sub !== CONFORMANCE_TEST_CLIENT_ID) { + this.checks.push({ + id: 'client-credentials-jwt-sub', + name: 'ClientCredentialsJwtSub', + description: `JWT sub claim '${payload.sub}' does not match expected client_id '${CONFORMANCE_TEST_CLIENT_ID}'`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH], + details: { + expected: CONFORMANCE_TEST_CLIENT_ID, + actual: payload.sub + } + }); + return { + error: 'invalid_client', + errorDescription: + 'JWT sub claim does not match expected client_id', + statusCode: 401 + }; + } + + // Success! + this.checks.push({ + id: 'client-credentials-jwt-verified', + name: 'ClientCredentialsJwtVerified', + description: + 'Client successfully authenticated with signed JWT assertion', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS, + SpecReferences.RFC_JWT_CLIENT_AUTH + ], + details: { + iss: payload.iss, + sub: payload.sub, + aud: payload.aud + } + }); + + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `cc-token-${Date.now()}`, + scopes + }; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.checks.push({ + id: 'client-credentials-jwt-verified', + name: 'ClientCredentialsJwtVerified', + description: `JWT verification failed: ${errorMessage}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_JWT_CLIENT_AUTH], + details: { error: errorMessage } + }); + return { + error: 'invalid_client', + errorDescription: `JWT verification failed: ${errorMessage}`, + statusCode: 401 + }; + } + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + private_key_pem: privateKeyPem, + signing_algorithm: 'ES256' + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the JWT verification check + const hasJwtCheck = this.checks.some( + (c) => c.id === 'client-credentials-jwt-verified' + ); + if (!hasJwtCheck) { + this.checks.push({ + id: 'client-credentials-jwt-verified', + name: 'ClientCredentialsJwtVerified', + description: 'Client did not make a client_credentials token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + } + + return this.checks; + } +} + +/** + * Scenario: Client Credentials with client_secret_basic Authentication + * + * Tests OAuth client_credentials flow with HTTP Basic authentication. + */ +export class ClientCredentialsBasicScenario implements Scenario { + name = 'auth/client-credentials-basic'; + description = + 'Tests OAuth client_credentials flow with client_secret_basic authentication'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + grantTypesSupported: ['client_credentials'], + tokenEndpointAuthMethodsSupported: ['client_secret_basic'], + onTokenRequest: async ({ + grantType, + body, + timestamp, + authorizationHeader + }) => { + if (grantType !== 'client_credentials') { + this.checks.push({ + id: 'client-credentials-grant-type', + name: 'ClientCredentialsGrantType', + description: `Expected grant_type=client_credentials, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + return { + error: 'unsupported_grant_type', + errorDescription: 'Only client_credentials grant is supported' + }; + } + + // Verify Basic auth header + const authHeader = authorizationHeader; + if (!authHeader || !authHeader.startsWith('Basic ')) { + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: + 'Missing or invalid Authorization header for Basic auth', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS] + }); + return { + error: 'invalid_client', + errorDescription: 'Missing or invalid Authorization header', + statusCode: 401 + }; + } + + const base64Credentials = authHeader.slice(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString( + 'utf-8' + ); + const [clientId, clientSecret] = credentials.split(':'); + + if ( + clientId !== CONFORMANCE_TEST_CLIENT_ID || + clientSecret !== CONFORMANCE_TEST_CLIENT_SECRET + ) { + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: 'Invalid client credentials', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_1046_CLIENT_CREDENTIALS], + details: { clientId } + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid client credentials', + statusCode: 401 + }; + } + + // Success! + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: + 'Client successfully authenticated with client_secret_basic', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ], + details: { clientId } + }); + + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `cc-token-${Date.now()}`, + scopes + }; + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + client_secret: CONFORMANCE_TEST_CLIENT_SECRET + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the basic auth check + const hasBasicAuthCheck = this.checks.some( + (c) => c.id === 'client-credentials-basic-auth' + ); + if (!hasBasicAuthCheck) { + this.checks.push({ + id: 'client-credentials-basic-auth', + name: 'ClientCredentialsBasicAuth', + description: 'Client did not make a client_credentials token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.OAUTH_2_1_CLIENT_CREDENTIALS, + SpecReferences.SEP_1046_CLIENT_CREDENTIALS + ] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c4a2094..c251597 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -4,19 +4,40 @@ import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; +export interface TokenRequestResult { + token: string; + scopes: string[]; +} + +export interface TokenRequestError { + error: string; + errorDescription?: string; + statusCode?: number; +} + export interface AuthServerOptions { metadataPath?: string; isOpenIdConfiguration?: boolean; loggingEnabled?: boolean; routePrefix?: string; scopesSupported?: string[]; + grantTypesSupported?: string[]; + tokenEndpointAuthMethodsSupported?: string[]; + tokenEndpointAuthSigningAlgValuesSupported?: string[]; clientIdMetadataDocumentSupported?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; grantType: string; timestamp: string; - }) => { token: string; scopes: string[] }; + body: Record; + authBaseUrl: string; + tokenEndpoint: string; + authorizationHeader?: string; + }) => + | TokenRequestResult + | TokenRequestError + | Promise; onAuthorizationRequest?: (requestData: { clientId?: string; scope?: string; @@ -35,6 +56,9 @@ export function createAuthServer( loggingEnabled = true, routePrefix = '', scopesSupported, + grantTypesSupported = ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethodsSupported = ['none'], + tokenEndpointAuthSigningAlgValuesSupported, clientIdMetadataDocumentSupported, tokenVerifier, onTokenRequest, @@ -86,9 +110,13 @@ export function createAuthServer( token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], + grant_types_supported: grantTypesSupported, code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['none'] + token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported, + ...(tokenEndpointAuthSigningAlgValuesSupported && { + token_endpoint_auth_signing_alg_values_supported: + tokenEndpointAuthSigningAlgValuesSupported + }) }; // Add scopes_supported if provided @@ -149,9 +177,10 @@ export function createAuthServer( res.redirect(redirectUrl.toString()); }); - app.post(authRoutes.token_endpoint, (req: Request, res: Response) => { + app.post(authRoutes.token_endpoint, async (req: Request, res: Response) => { const timestamp = new Date().toISOString(); const requestedScope = req.body.scope; + const grantType = req.body.grant_type; checks.push({ id: 'token-request', @@ -162,7 +191,7 @@ export function createAuthServer( specReferences: [SpecReferences.OAUTH_2_1_TOKEN], details: { endpoint: '/token', - grantType: req.body.grant_type + grantType } }); @@ -170,11 +199,25 @@ export function createAuthServer( let scopes: string[] = lastAuthorizationScopes; if (onTokenRequest) { - const result = onTokenRequest({ + const result = await onTokenRequest({ scope: requestedScope, - grantType: req.body.grant_type, - timestamp + grantType, + timestamp, + body: req.body, + authBaseUrl: getAuthBaseUrl(), + tokenEndpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, + authorizationHeader: req.headers.authorization }); + + // Check if result is an error + if ('error' in result) { + res.status(result.statusCode || 400).json({ + error: result.error, + error_description: result.errorDescription + }); + return; + } + token = result.token; scopes = result.scopes; } diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index f49f101..8cbfae1 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -16,8 +16,8 @@ export class MockTokenVerifier implements OAuthTokenVerifier { } async verifyAccessToken(token: string): Promise { - // Accept tokens that start with 'test-token' - if (token.startsWith('test-token')) { + // Accept tokens that start with known prefixes + if (token.startsWith('test-token') || token.startsWith('cc-token')) { // Get scopes for this token, or use empty array const scopes = this.tokenScopes.get(token) || []; diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 285280f..803ecf7 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -16,7 +16,12 @@ beforeAll(() => { setLogLevel('error'); }); -const skipScenarios = new Set([]); +const skipScenarios = new Set([ + // Client credentials scenarios require SDK support for client_credentials grant + // Pending typescript-sdk implementation + 'auth/client-credentials-jwt', + 'auth/client-credentials-basic' +]); const allowClientErrorScenarios = new Set([ // Client is expected to give up (error) after limited retries, but check should pass diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index eed1213..170c1cd 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -12,6 +12,10 @@ import { ScopeStepUpAuthScenario, ScopeRetryLimitScenario } from './scope-handling'; +import { + ClientCredentialsJwtScenario, + ClientCredentialsBasicScenario +} from './client-credentials'; export const authScenariosList: Scenario[] = [ ...metadataScenarios, @@ -22,5 +26,7 @@ export const authScenariosList: Scenario[] = [ new ScopeFromScopesSupportedScenario(), new ScopeOmittedWhenUndefinedScenario(), new ScopeStepUpAuthScenario(), - new ScopeRetryLimitScenario() + new ScopeRetryLimitScenario(), + new ClientCredentialsJwtScenario(), + new ClientCredentialsBasicScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 6f6b824..52a08ca 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -60,5 +60,17 @@ export const SpecReferences: { [key: string]: SpecReference } = { IETF_CIMD: { id: 'IETF-OAuth-Client-ID-Metadata-Document', url: 'https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00' + }, + RFC_JWT_CLIENT_AUTH: { + id: 'RFC-7523-JWT-Client-Auth', + url: 'https://datatracker.ietf.org/doc/html/rfc7523#section-2.2' + }, + OAUTH_2_1_CLIENT_CREDENTIALS: { + id: 'OAUTH-2.1-client-credentials-grant', + url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.2' + }, + SEP_1046_CLIENT_CREDENTIALS: { + id: 'SEP-1046-Client-Credentials', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' } }; diff --git a/src/types.ts b/src/types.ts index 07e2b3d..d5192b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,11 @@ export interface ConformanceCheck { export interface ScenarioUrls { serverUrl: string; authUrl?: string; + /** + * Optional context to pass to the client via MCP_CONFORMANCE_CONTEXT env var. + * This is a JSON-serializable object containing scenario-specific data like credentials. + */ + context?: Record; } export interface Scenario { From 6464588da2ade65d8e47136d604bd53b1a6ab546 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 25 Nov 2025 20:35:39 +0000 Subject: [PATCH 038/226] Add dependabot config with 7-day dependency cooldown (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configures Dependabot to wait 7 days after a package is published before creating update PRs. This helps protect against supply chain attacks by allowing time for malicious packages to be detected and removed. See: https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .github/dependabot.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a972625 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 From 3590c171522b5a413b5ac7ca20f547f199c1a2e0 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 4 Dec 2025 09:32:07 -0800 Subject: [PATCH 039/226] chore: enforce LF line endings for x-plat consistency (#74) --- .gitattributes | 3 +++ .prettierrc.json | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9cc8d5b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Enforce LF line endings for all text files +* text=auto eol=lf + diff --git a/.prettierrc.json b/.prettierrc.json index 6c36d55..4ba6ca3 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,4 +1,5 @@ { + "endOfLine": "lf", "singleQuote": true, "trailingComma": "none", "overrides": [ From e5cdffec37c09fbaa1e4ad1717bb4892033df0c4 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 4 Dec 2025 09:39:54 -0800 Subject: [PATCH 040/226] Add server ping scenario (#75) --- src/scenarios/index.ts | 2 + src/scenarios/server/utils.ts | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index de76cd6..470ffed 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -8,6 +8,7 @@ import { SSERetryScenario } from './client/sse-retry'; import { ServerInitializeScenario } from './server/lifecycle'; import { + PingScenario, LoggingSetLevelScenario, CompletionCompleteScenario } from './server/utils'; @@ -80,6 +81,7 @@ const allClientScenariosList: ClientScenario[] = [ // Utilities scenarios new LoggingSetLevelScenario(), + new PingScenario(), new CompletionCompleteScenario(), // Tools scenarios diff --git a/src/scenarios/server/utils.ts b/src/scenarios/server/utils.ts index e65c814..07b2ca6 100644 --- a/src/scenarios/server/utils.ts +++ b/src/scenarios/server/utils.ts @@ -83,6 +83,95 @@ export class LoggingSetLevelScenario implements ClientScenario { } } +export class PingScenario implements ClientScenario { + name = 'ping'; + description = `Test ping utility for connection health check. + +**Server Implementation Requirements:** + +**Endpoint**: \`ping\` + +**Requirements**: +- Accept ping request with no parameters +- Respond promptly with empty object \`{}\` + +**Request Format**: + +\`\`\`json +{ + "jsonrpc": "2.0", + "id": "123", + "method": "ping" +} +\`\`\` + +**Response Format**: + +\`\`\`json +{ + "jsonrpc": "2.0", + "id": "123", + "result": {} +} +\`\`\` + +**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 { + const checks: ConformanceCheck[] = []; + + try { + const connection = await connectToServer(serverUrl); + + // Send ping request + const result = await connection.client.ping(); + + // Validate response (should return empty object {}) + const errors: string[] = []; + if (result && Object.keys(result).length > 0) { + errors.push('Expected empty object {} response'); + } + + checks.push({ + id: 'ping', + name: 'Ping', + description: 'Server responds to ping requests', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: [ + { + id: 'MCP-Ping', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping' + } + ], + details: { + result + } + }); + + await connection.close(); + } catch (error) { + checks.push({ + id: 'ping', + name: 'Ping', + description: 'Server responds to ping requests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: [ + { + id: 'MCP-Ping', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/ping' + } + ] + }); + } + + return checks; + } +} + export class CompletionCompleteScenario implements ClientScenario { name = 'completion-complete'; description = `Test completion endpoint. From 976a8f6ce225d61aca135a948dbd66f673e3a56d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 5 Jan 2026 11:42:02 +0000 Subject: [PATCH 041/226] fix: validate scope checks ran and report overall test status (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: validate scope checks ran and report overall test status - Add FAILURE checks to scope scenarios when authorization flow doesn't complete (ScopeFromWwwAuthenticateScenario, ScopeFromScopesSupportedScenario, ScopeOmittedWhenUndefinedScenario) - Report client timeout and exit errors in test results - Add OVERALL: PASSED/FAILED summary at end of test output - Exit with code 1 on any failure (check failures, client timeout, or exit error) Previously, if the OAuth authorization flow didn't complete, the scope checks were never triggered and the test would incorrectly report success. Now these scenarios emit a FAILURE if the expected scope check wasn't recorded. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Count warnings as failures in overall test result Warnings now contribute to the overall failure status, causing the test to exit with code 1 when any warnings are present. Also displays warning checks in the output summary, similar to how failures are displayed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Count warnings as failures in suite runner Update the suite summary to show ✗ for scenarios with warnings, and exit with code 1 if any warnings are present across the suite. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/index.ts | 12 +++-- src/runner/client.ts | 49 +++++++++++++++++++-- src/scenarios/client/auth/scope-handling.ts | 45 +++++++++++++++++++ 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6128a16..edcf042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,7 +125,7 @@ program totalFailed += failed; totalWarnings += warnings; - const status = failed === 0 ? '✓' : '✗'; + const status = failed === 0 && warnings === 0 ? '✓' : '✗'; const warningStr = warnings > 0 ? `, ${warnings} warnings` : ''; console.log( `${status} ${result.scenario}: ${passed} passed, ${failed} failed${warningStr}` @@ -145,7 +145,7 @@ program console.log( `\nTotal: ${totalPassed} passed, ${totalFailed} failed, ${totalWarnings} warnings` ); - process.exit(totalFailed > 0 ? 1 : 0); + process.exit(totalFailed > 0 || totalWarnings > 0 ? 1 : 0); } // Require either --scenario or --suite @@ -173,8 +173,12 @@ program timeout ); - const { failed } = printClientResults(result.checks, verbose); - process.exit(failed > 0 ? 1 : 0); + const { overallFailure } = printClientResults( + result.checks, + verbose, + result.clientOutput + ); + process.exit(overallFailure ? 1 : 0); } catch (error) { if (error instanceof ZodError) { console.error('Validation error:'); diff --git a/src/runner/client.ts b/src/runner/client.ts index cafb7c3..11a6115 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -150,8 +150,15 @@ export async function runConformanceTest( export function printClientResults( checks: ConformanceCheck[], - verbose: boolean = false -): { passed: number; failed: number; denominator: number; warnings: number } { + verbose: boolean = false, + clientOutput?: ClientExecutionResult +): { + passed: number; + failed: number; + denominator: number; + warnings: number; + overallFailure: boolean; +} { const denominator = checks.filter( (c) => c.status === 'SUCCESS' || c.status === 'FAILURE' ).length; @@ -159,6 +166,14 @@ export function printClientResults( const failed = checks.filter((c) => c.status === 'FAILURE').length; const warnings = checks.filter((c) => c.status === 'WARNING').length; + // Determine if there's an overall failure (failures, warnings, client timeout, or exit failure) + const clientTimedOut = clientOutput?.timedOut ?? false; + const clientExitedWithError = clientOutput + ? clientOutput.exitCode !== 0 + : false; + const overallFailure = + failed > 0 || warnings > 0 || clientTimedOut || clientExitedWithError; + if (verbose) { // Verbose mode: JSON goes to stdout for piping to jq/jless console.log(JSON.stringify(checks, null, 2)); @@ -173,6 +188,16 @@ export function printClientResults( `Passed: ${passed}/${denominator}, ${failed} failed, ${warnings} warnings` ); + if (clientTimedOut) { + console.error(`\n⚠️ CLIENT TIMED OUT - Test incomplete`); + } + + if (clientExitedWithError && !clientTimedOut) { + console.error( + `\n⚠️ CLIENT EXITED WITH ERROR (code ${clientOutput?.exitCode}) - Test may be incomplete` + ); + } + if (failed > 0) { console.error('\nFailed Checks:'); checks @@ -185,7 +210,25 @@ export function printClientResults( }); } - return { passed, failed, denominator, warnings }; + if (warnings > 0) { + console.error('\nWarning Checks:'); + checks + .filter((c) => c.status === 'WARNING') + .forEach((c) => { + console.error(` - ${c.name}: ${c.description}`); + if (c.errorMessage) { + console.error(` Warning: ${c.errorMessage}`); + } + }); + } + + if (overallFailure) { + console.error('\n❌ OVERALL: FAILED'); + } else { + console.error('\n✅ OVERALL: PASSED'); + } + + return { passed, failed, denominator, warnings, overallFailure }; } export async function runInteractiveMode( diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 137371a..d94760b 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -73,6 +73,21 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Emit failure check if expected scope check didn't run + const hasScopeCheck = this.checks.some( + (c) => c.id === 'scope-from-www-authenticate' + ); + if (!hasScopeCheck) { + this.checks.push({ + id: 'scope-from-www-authenticate', + name: 'Client scope selection from WWW-Authenticate header', + description: + 'Client did not complete authorization flow - scope check could not be performed', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] + }); + } return this.checks; } } @@ -153,6 +168,21 @@ export class ScopeFromScopesSupportedScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Emit failure check if expected scope check didn't run + const hasScopeCheck = this.checks.some( + (c) => c.id === 'scope-from-scopes-supported' + ); + if (!hasScopeCheck) { + this.checks.push({ + id: 'scope-from-scopes-supported', + name: 'Client scope selection from scopes_supported', + description: + 'Client did not complete authorization flow - scope check could not be performed', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] + }); + } return this.checks; } } @@ -221,6 +251,21 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { } getChecks(): ConformanceCheck[] { + // Emit failure check if expected scope check didn't run + const hasScopeCheck = this.checks.some( + (c) => c.id === 'scope-omitted-when-undefined' + ); + if (!hasScopeCheck) { + this.checks.push({ + id: 'scope-omitted-when-undefined', + name: 'Client scope omission when scopes_supported undefined', + description: + 'Client did not complete authorization flow - scope check could not be performed', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] + }); + } return this.checks; } } From 5f1bedd54bbf40730337ca2a18cf7c1976c299f1 Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Mon, 5 Jan 2026 04:53:42 -0700 Subject: [PATCH 042/226] fix: add shell:true for Windows compatibility in all-scenarios.test (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn('npx', ...) fails on Windows because npx is actually npx.cmd. Adding shell: true only on Windows fixes this while preserving Unix signal handling behavior. 🦉 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- src/scenarios/server/all-scenarios.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index bbff157..689d529 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -15,10 +15,13 @@ describe('Server Scenarios', () => { 'examples/servers/typescript/everything-server.ts' ); + // 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() }, stdio: ['ignore', 'pipe', 'pipe'], - detached: false + detached: false, + shell: isWindows }); // Capture output for debugging From 3994cb3c862328e9f5e8ef3b20421d808b466d1c Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Mon, 5 Jan 2026 04:54:56 -0700 Subject: [PATCH 043/226] feat: support protocol version 2025-11-25 (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update client initialization check to accept both 2025-06-18 and 2025-11-25 - Server echoes back client's version if valid (proper version negotiation) - Fixes compatibility with SDK 1.25.1+ which sends 2025-11-25 🦉 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- src/checks/client.ts | 12 ++++++++++-- src/scenarios/client/initialize.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/checks/client.ts b/src/checks/client.ts index 7a14589..99834f9 100644 --- a/src/checks/client.ts +++ b/src/checks/client.ts @@ -23,12 +23,20 @@ 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-06-18' + expectedSpecVersion: string = '2025-11-25' ): ConformanceCheck { const protocolVersionSent = initializeRequest?.protocolVersion; - const versionMatch = protocolVersionSent === expectedSpecVersion; + + // 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 versionMatch = validVersions.includes(protocolVersionSent); const errors: string[] = []; if (!protocolVersionSent) errors.push('Protocol version not provided'); diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index 770103c..a351700 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -110,11 +110,18 @@ 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) + ? clientVersion + : '2025-11-25'; + const response = { jsonrpc: '2.0', id: request.id, result: { - protocolVersion: '2025-06-18', + protocolVersion: responseVersion, serverInfo, capabilities: {} } From 13f7950da54bbb5f4c89f00d52f02f8e5dbc5886 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:00:56 +0000 Subject: [PATCH 044/226] Bump the npm_and_yarn group across 1 directory with 2 updates (#72) Bumps the npm_and_yarn group with 1 update in the /examples/servers/typescript directory: [express](https://github.com/expressjs/express). Updates `express` from 5.1.0 to 5.2.1 - [Release notes](https://github.com/expressjs/express/releases) - [Changelog](https://github.com/expressjs/express/blob/master/History.md) - [Commits](https://github.com/expressjs/express/compare/v5.1.0...v5.2.1) Updates `body-parser` from 2.2.0 to 2.2.1 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1) --- updated-dependencies: - dependency-name: express dependency-version: 5.2.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: body-parser dependency-version: 2.2.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/servers/typescript/package-lock.json | 57 +++++++++---------- examples/servers/typescript/package.json | 2 +- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index 4db8198..7d35240 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -11,7 +11,7 @@ "@modelcontextprotocol/sdk": "^1.20.1", "@types/cors": "^2.8.19", "cors": "^2.8.5", - "express": "^5.1.0", + "express": "^5.2.1", "zod-to-json-schema": "^3.25.0" }, "devDependencies": { @@ -558,23 +558,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bytes": { @@ -846,18 +850,20 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "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.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -1090,15 +1096,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/inherits": { @@ -1335,22 +1345,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/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==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1644,6 +1638,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 5e12580..c5d428d 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -18,7 +18,7 @@ "@modelcontextprotocol/sdk": "^1.20.1", "@types/cors": "^2.8.19", "cors": "^2.8.5", - "express": "^5.1.0", + "express": "^5.2.1", "zod-to-json-schema": "^3.25.0" }, "devDependencies": { From 29c01d515843fd9ecc67974cc45d7558eae80829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:01:26 +0000 Subject: [PATCH 045/226] Bump body-parser in the npm_and_yarn group across 1 directory (#71) Bumps the npm_and_yarn group with 1 update in the / directory: [body-parser](https://github.com/expressjs/body-parser). Updates `body-parser` from 2.2.0 to 2.2.1 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1) --- updated-dependencies: - dependency-name: body-parser dependency-version: 2.2.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 55 +++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index c8fe2e6..d7d9cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1717,6 +1717,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -1935,6 +1936,7 @@ "integrity": "sha512-LKDTZxo2D2U75EZ+8xw0AJmG3VEqUVjlWu43IJLdl+Hjyhh2eFmfufhXw3mwPRAF6hEy+E3ldUtglmmliN/CiA==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsgo": "bin/tsgo.js" }, @@ -2176,6 +2178,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2344,23 +2347,27 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -2803,6 +2810,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3021,6 +3029,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -3462,15 +3471,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -4159,6 +4172,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4329,22 +4343,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/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==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4404,6 +4402,7 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, + "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -4956,6 +4955,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -5002,6 +5002,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5089,6 +5090,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5309,6 +5311,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From ef8c0844ade40cd613e4c4284a3142d0d209412a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:01:40 +0000 Subject: [PATCH 046/226] Bump @typescript/native-preview (#70) Bumps [@typescript/native-preview](https://github.com/microsoft/typescript-go) from 7.0.0-dev.20251030.1 to 7.0.0-dev.20251124.1. - [Changelog](https://github.com/microsoft/typescript-go/blob/main/CHANGES.md) - [Commits](https://github.com/microsoft/typescript-go/commits) --- updated-dependencies: - dependency-name: "@typescript/native-preview" dependency-version: 7.0.0-dev.20251124.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 64 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7d9cc4..bcfae34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@eslint/js": "^9.8.0", "@types/express": "^5.0.3", "@types/node": "^22.10.2", - "@typescript/native-preview": "^7.0.0-dev.20251030.1", + "@typescript/native-preview": "^7.0.0-dev.20251201.1", "cors": "^2.8.5", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", @@ -1931,9 +1931,9 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-LKDTZxo2D2U75EZ+8xw0AJmG3VEqUVjlWu43IJLdl+Hjyhh2eFmfufhXw3mwPRAF6hEy+E3ldUtglmmliN/CiA==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-EiPEgGwNa2uHyyKgeoWTL6wWHKUBmF3xsfZ3OHofk7TxUuxb2mpLG5igEuaBe8iUwkCUl9uZgJvOu6o0wE5NSA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -1941,19 +1941,19 @@ "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251030.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251030.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20251030.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251030.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20251030.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251030.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20251030.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251201.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251201.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20251201.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251201.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20251201.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251201.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20251201.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-9kSrvvp7/T5kTIFtXn3ZDcAsL6Afao04iq7v4ItZyMF85e0L1zjcUQfZ5+iZsQAvlKsDff0yF26OW1jPdKPRQg==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-PY0BrlRF3YCZEMxzuk79IFSgpGqUErkdrW7Aq+/mF8DEET5uaDypTMb8Vz4CLYJ7Xvvxz8eZsLimPbv6hYDIvA==", "cpu": [ "arm64" ], @@ -1965,9 +1965,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-n6whSU1ylbgmnREklaZo5xgXWfRGpzGmmRykPpr518CWdiZTK+Nz7Yc3rrKNky19XB53duAsOccbb7aMbjSxKg==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-YeDrjnsvXwm/MNG8aURT3J+cmHQIhpiElBKOVOy/H6ky4S2Ro9ufG+Bj9CqS3etbTCLhV5btk+QNh86DZ4VDkQ==", "cpu": [ "x64" ], @@ -1979,9 +1979,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-KV/vpl+o5TWlc4Al0c6DWJ1DI1e/PVLnjZ7yvuJcKpsHKDZnOYY5PVm2lrspJppIAszYkrLnxkvwKJaEqM2Zag==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-gr2EQYK888YdGROMc7l3N3MeKY1V3QVImKIQZNgqprV+N2rXaFnxGAZ+gql3LqZgRGel4a12vCUJeP7Pjl2gww==", "cpu": [ "arm" ], @@ -1993,9 +1993,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-diTTlmweclqYGntZkgGBCcJFU7YsILiP4DJPeXTf453QBlXHOyeFiPT/1rPXfcLzvk2q9Q/oyAA6aKHrJcCf8w==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-HbEn+SBTDZEtwN/VUxA2To+6vEr7x++SCRc6yGp5y4onpBL2xnH17UoxWiqN9J4Bu1DbQ9jZv3D5CzwBlofPQA==", "cpu": [ "arm64" ], @@ -2007,9 +2007,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-Yn43Ut3LQFEXQOL+fO/mOrq/MyhBplfucHCvD2T1P7DmSD/90XgWxz5DCIOJ+o/qmFCkBF6oXQkco4JcHJ8fPg==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-q94K/LZ3Ab/SbUBMBsf37VdsumeZ1dZmymJYlhGBqk/fdXBayL0diLR3RdzyeQWbCXAxWL5KFKLIiIc3cI/fcA==", "cpu": [ "x64" ], @@ -2021,9 +2021,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-1RSrPGwdKbGEsgjgj7HiKiN1KjcZNuQCSEIZ8fbQFIcdfIeC2hUvSlzwI6a4hGx2SOSdA5kl6rUcZOE3hPqzPA==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-/AFwpsX/G05bBsfVURfg4+/JC6gfvqj9jfFe/7oe1Y1J42koN5C8TH+eSmMOOEcPYpFjR1e+NWckqBJKaCXJ4A==", "cpu": [ "arm64" ], @@ -2035,9 +2035,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20251030.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251030.1.tgz", - "integrity": "sha512-Q7kKUgnjUkk/wxmghLi+hokqo58e+H8wjWaV020JTtW63U8DbwS9vyDDPKdU3MQoV2/Jzlqu0TIXsdnERSb/qw==", + "version": "7.0.0-dev.20251201.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251201.1.tgz", + "integrity": "sha512-vTUCDEuSP4ifLHqb8aljuj44v6+M1HDKo1WLnboTDpwU7IIrTux/0jzkPfEHd9xd5FU4EhSA8ZrYDwKI0BcRcg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index fc781d9..2c200ef 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@eslint/js": "^9.8.0", "@types/express": "^5.0.3", "@types/node": "^22.10.2", - "@typescript/native-preview": "^7.0.0-dev.20251030.1", + "@typescript/native-preview": "^7.0.0-dev.20251201.1", "cors": "^2.8.5", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", From d59323096b68e76836dafbb624e9d5c4aea2ee8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:02:06 +0000 Subject: [PATCH 047/226] Bump actions/checkout from 4 to 6 (#68) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/pr-publish.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99a8f04..4a0a9cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: node-version: 24 @@ -40,7 +40,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: node-version: 24 diff --git a/.github/workflows/pr-publish.yml b/.github/workflows/pr-publish.yml index d7e8b83..6579836 100644 --- a/.github/workflows/pr-publish.yml +++ b/.github/workflows/pr-publish.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: node-version: 24 From 31e26d0dd4eb80cfd1a3467ee3d018cf550e14c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:02:10 +0000 Subject: [PATCH 048/226] Bump @eslint/js from 9.38.0 to 9.39.1 (#67) Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.38.0 to 9.39.1. - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v9.39.1/packages/js) --- updated-dependencies: - dependency-name: "@eslint/js" dependency-version: 9.39.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 21 +++++++++++++++++---- package.json | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bcfae34..e0e1780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "conformance": "dist/index.js" }, "devDependencies": { - "@eslint/js": "^9.8.0", + "@eslint/js": "^9.39.1", "@types/express": "^5.0.3", "@types/node": "^22.10.2", "@typescript/native-preview": "^7.0.0-dev.20251201.1", @@ -700,9 +700,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "dev": true, "license": "MIT", "engines": { @@ -2911,6 +2911,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", diff --git a/package.json b/package.json index 2c200ef..ea740da 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "conformance": "dist/index.js" }, "devDependencies": { - "@eslint/js": "^9.8.0", + "@eslint/js": "^9.39.1", "@types/express": "^5.0.3", "@types/node": "^22.10.2", "@typescript/native-preview": "^7.0.0-dev.20251201.1", From 619fa77b92258eb2831aed9989d27ca32bda377f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:02:13 +0000 Subject: [PATCH 049/226] Bump typescript-eslint from 8.46.2 to 8.48.0 (#65) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 8.46.2 to 8.48.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.48.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-version: 8.48.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 347 +++++++++------------------------------------- package.json | 2 +- 2 files changed, 65 insertions(+), 284 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0e1780..529b253 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "tsdown": "^0.15.12", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "8.46.2", + "typescript-eslint": "8.48.0", "vitest": "^4.0.5" } }, @@ -911,44 +911,6 @@ "@tybys/wasm-util": "^0.10.1" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-project/types": { "version": "0.95.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.95.0.tgz", @@ -1672,17 +1634,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", - "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", + "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==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/type-utils": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@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", "natural-compare": "^1.4.0", @@ -1696,7 +1658,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.2", + "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1712,17 +1674,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", - "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "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==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@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" }, "engines": { @@ -1738,14 +1700,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", - "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.2", - "@typescript-eslint/types": "^8.46.2", + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", "debug": "^4.3.4" }, "engines": { @@ -1760,14 +1722,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", - "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2" + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1778,9 +1740,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", - "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", + "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==", "dev": true, "license": "MIT", "engines": { @@ -1795,15 +1757,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", - "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2", + "@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" }, @@ -1820,9 +1782,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", - "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", "dev": true, "license": "MIT", "engines": { @@ -1834,21 +1796,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", - "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.2", - "@typescript-eslint/tsconfig-utils": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@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", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1889,16 +1850,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", - "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2" + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1913,13 +1874,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", - "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/types": "8.48.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2381,19 +2342,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3101,36 +3049,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3161,16 +3079,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3201,19 +3109,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", @@ -3574,16 +3469,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3910,43 +3795,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4311,27 +4159,6 @@ ], "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4399,17 +4226,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.0.0-beta.45", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", @@ -4546,29 +4362,6 @@ "node": ">= 18" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4850,19 +4643,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4887,6 +4667,7 @@ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.12" }, @@ -5025,16 +4806,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz", - "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==", + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.2", - "@typescript-eslint/parser": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2" + "@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" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index ea740da..b443ad1 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "tsdown": "^0.15.12", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "8.46.2", + "typescript-eslint": "8.48.0", "vitest": "^4.0.5" }, "dependencies": { From 053e9bd9e0981e47ba72ebdb8bb2ae7cd423e12f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:02:16 +0000 Subject: [PATCH 050/226] Bump the npm_and_yarn group across 2 directories with 1 update (#56) Bumps the npm_and_yarn group with 1 update in the / directory: [body-parser](https://github.com/expressjs/body-parser). Bumps the npm_and_yarn group with 1 update in the /examples/servers/typescript directory: [body-parser](https://github.com/expressjs/body-parser). Updates `body-parser` from 2.2.0 to 2.2.1 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1) Updates `body-parser` from 2.2.0 to 2.2.1 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1) --- updated-dependencies: - dependency-name: body-parser dependency-version: 2.2.1 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: body-parser dependency-version: 2.2.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From be51f872e13a03d2c066952044482cc13f075eb4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 5 Jan 2026 14:16:21 +0000 Subject: [PATCH 051/226] Refactor client testing to use 'everything' client with scenario routing (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor client testing to pass scenario name and server URL The CLI now passes both scenario name and server URL as arguments to the client command (instead of just the server URL). This enables a single "everything" client that can handle all scenarios based on the scenario name it receives. Changes: - Update executeClient() to pass scenarioName as first argument to client - Add everything-client.ts that routes to appropriate behavior based on scenario name - Update README with new usage examples and documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Use env var for scenario name instead of positional arg Pass scenario name via MCP_CONFORMANCE_SCENARIO env var instead of as a positional argument. This is non-breaking for existing clients that just accept . The env var approach is consistent with how context (including private keys for JWT auth) is already passed via MCP_CONFORMANCE_CONTEXT. * Add comment explaining env var design decision --------- Co-authored-by: Claude Co-authored-by: Olivier Chafik --- README.md | 14 +- .../clients/typescript/everything-client.ts | 222 ++++++++++++++++++ src/runner/client.ts | 9 +- 3 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 examples/clients/typescript/everything-client.ts diff --git a/README.md b/README.md index 741b0b7..f1e7ddf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,11 @@ A framework for testing MCP (Model Context Protocol) client and server implement ### Testing Clients ```bash -npx @modelcontextprotocol/conformance client --command "tsx examples/clients/typescript/test1.ts" --scenario initialize +# Using the everything-client (recommended) +npx @modelcontextprotocol/conformance client --command "tsx examples/clients/typescript/everything-client.ts" --scenario initialize + +# Run an entire suite of tests +npx @modelcontextprotocol/conformance client --command "tsx examples/clients/typescript/everything-client.ts" --suite auth ``` ### Testing Servers @@ -59,10 +63,11 @@ 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") - `--timeout` - Timeout in milliseconds (default: 30000) - `--verbose` - Show verbose output -The framework appends the server URL as the final argument to your command. +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. ### Server Testing @@ -89,8 +94,9 @@ npx @modelcontextprotocol/conformance server --url [--scenario ] ## Example Clients -- `examples/clients/typescript/test1.ts` - Valid MCP client (passes all checks) -- `examples/clients/typescript/test-broken.ts` - Invalid client missing required fields (fails checks) +- `examples/clients/typescript/everything-client.ts` - Single client that handles all scenarios based on scenario name (recommended) +- `examples/clients/typescript/test1.ts` - Simple MCP client (for reference) +- `examples/clients/typescript/auth-test.ts` - Well-behaved OAuth client (for reference) ## Available Scenarios diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts new file mode 100644 index 0000000..464eb7a --- /dev/null +++ b/examples/clients/typescript/everything-client.ts @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +/** + * Everything client - a single conformance test client that handles all scenarios. + * + * Usage: everything-client + * + * The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, + * which is set by the conformance test runner. + * + * This client routes to the appropriate behavior based on the scenario name, + * consolidating all the individual test clients into one. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { logger } from './helpers/logger.js'; + +// Scenario handler type +type ScenarioHandler = (serverUrl: string) => Promise; + +// Registry of scenario handlers +const scenarioHandlers: Record = {}; + +// Helper to register a scenario handler +function registerScenario(name: string, handler: ScenarioHandler): void { + scenarioHandlers[name] = handler; +} + +// Helper to register multiple scenarios with the same handler +function registerScenarios(names: string[], handler: ScenarioHandler): void { + for (const name of names) { + scenarioHandlers[name] = handler; + } +} + +// ============================================================================ +// Basic scenarios (initialize, tools-call) +// ============================================================================ + +async function runBasicClient(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); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenarios(['initialize', 'tools-call'], runBasicClient); + +// ============================================================================ +// Auth scenarios - well-behaved client +// ============================================================================ + +async function runAuthClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthRetry( + 'test-auth-client', + 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 client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// Register all auth scenarios that should use the well-behaved auth client +registerScenarios( + [ + 'auth/basic-dcr', + 'auth/basic-metadata-var1', + 'auth/basic-metadata-var2', + 'auth/basic-metadata-var3', + 'auth/2025-03-26-oauth-metadata-backcompat', + 'auth/2025-03-26-oauth-endpoint-fallback', + 'auth/scope-from-www-authenticate', + 'auth/scope-from-scopes-supported', + 'auth/scope-omitted-when-undefined', + 'auth/scope-step-up' + ], + runAuthClient +); + +// ============================================================================ +// Elicitation defaults scenario +// ============================================================================ + +async function runElicitationDefaultsClient(serverUrl: string): Promise { + const client = new Client( + { name: 'elicitation-defaults-test-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { + applyDefaults: true + } + } + } + ); + + // Register elicitation handler that returns empty content + // The SDK should fill in defaults for all omitted fields + client.setRequestHandler(ElicitRequestSchema, async (request) => { + logger.debug( + 'Received elicitation request:', + JSON.stringify(request.params, null, 2) + ); + logger.debug('Accepting with empty content - SDK should apply defaults'); + + // Return empty content - SDK should merge in defaults + return { + action: 'accept' as const, + content: {} + }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + // List available tools + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map((t) => t.name) + ); + + // Call the test tool which will trigger elicitation + const testTool = tools.tools.find( + (t) => t.name === 'test_client_elicitation_defaults' + ); + if (!testTool) { + throw new Error('Test tool not found: test_client_elicitation_defaults'); + } + + logger.debug('Calling test_client_elicitation_defaults tool...'); + const result = await client.callTool({ + name: 'test_client_elicitation_defaults', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('elicitation-defaults', runElicitationDefaultsClient); + +// ============================================================================ +// Main entry point +// ============================================================================ + +async function main(): Promise { + const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO; + const serverUrl = process.argv[2]; + + if (!scenarioName || !serverUrl) { + console.error( + 'Usage: MCP_CONFORMANCE_SCENARIO= everything-client ' + ); + console.error( + '\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.' + ); + console.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + console.error(` - ${name}`); + } + process.exit(1); + } + + const handler = scenarioHandlers[scenarioName]; + if (!handler) { + console.error(`Unknown scenario: ${scenarioName}`); + console.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + console.error(` - ${name}`); + } + process.exit(1); + } + + try { + await handler(serverUrl); + process.exit(0); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); +}); diff --git a/src/runner/client.ts b/src/runner/client.ts index 11a6115..dcb005f 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -14,6 +14,7 @@ export interface ClientExecutionResult { async function executeClient( command: string, + scenarioName: string, serverUrl: string, timeout: number = 30000, context?: Record @@ -26,8 +27,13 @@ async function executeClient( let stderr = ''; let timedOut = false; - // Build environment with optional context + // Build environment with scenario name and optional context. + // We use separate env vars rather than putting scenario in context because: + // 1. Scenario is always set, context is only set when there's scenario-specific data + // 2. Simpler to read a string vs parsing JSON just to get the scenario name + // 3. Semantic separation: scenario identifies "which test", context provides "test data" const env = { ...process.env }; + env.MCP_CONFORMANCE_SCENARIO = scenarioName; if (context) { env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context); } @@ -105,6 +111,7 @@ export async function runConformanceTest( try { const clientOutput = await executeClient( clientCommand, + scenarioName, urls.serverUrl, timeout, urls.context From 0d1e9c71047ab43f1e8818383096588877f25e4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:17:28 +0000 Subject: [PATCH 052/226] Bump vitest from 4.0.5 to 4.0.13 (#69) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.0.5 to 4.0.13. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.13/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.0.13 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Paul Carleton --- package-lock.json | 864 +++++++++++++++++++++++++++++++++++++--------- package.json | 2 +- 2 files changed, 701 insertions(+), 165 deletions(-) diff --git a/package-lock.json b/package-lock.json index 529b253..8833116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "tsx": "^4.7.0", "typescript": "^5.5.4", "typescript-eslint": "8.48.0", - "vitest": "^4.0.5" + "vitest": "^4.0.16" } }, "node_modules/@babel/generator": { @@ -1180,9 +1180,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "cpu": [ "arm" ], @@ -1194,9 +1194,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "cpu": [ "arm64" ], @@ -1208,9 +1208,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "cpu": [ "arm64" ], @@ -1222,9 +1222,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "cpu": [ "x64" ], @@ -1236,9 +1236,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "cpu": [ "arm64" ], @@ -1250,9 +1250,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "cpu": [ "x64" ], @@ -1264,9 +1264,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "cpu": [ "arm" ], @@ -1278,9 +1278,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "cpu": [ "arm" ], @@ -1292,9 +1292,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "cpu": [ "arm64" ], @@ -1306,9 +1306,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "cpu": [ "arm64" ], @@ -1320,9 +1320,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "cpu": [ "loong64" ], @@ -1334,9 +1348,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "cpu": [ "ppc64" ], @@ -1348,9 +1376,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "cpu": [ "riscv64" ], @@ -1362,9 +1390,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "cpu": [ "riscv64" ], @@ -1376,9 +1404,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "cpu": [ "s390x" ], @@ -1390,9 +1418,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "cpu": [ "x64" ], @@ -1404,9 +1432,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "cpu": [ "x64" ], @@ -1417,10 +1445,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "cpu": [ "arm64" ], @@ -1432,9 +1474,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "cpu": [ "arm64" ], @@ -1446,9 +1488,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "cpu": [ "ia32" ], @@ -1460,9 +1502,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "cpu": [ "x64" ], @@ -1474,9 +1516,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "cpu": [ "x64" ], @@ -1488,9 +1530,9 @@ ] }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -2010,17 +2052,17 @@ ] }, "node_modules/@vitest/expect": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.5.tgz", - "integrity": "sha512-DJctLVlKoddvP/G389oGmKWNG6GD9frm2FPXARziU80Rjo7SIYxQzb2YFzmQ4fVD3Q5utUYY8nUmWrqsuIlIXQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.5", - "@vitest/utils": "4.0.5", - "chai": "^6.0.1", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2028,15 +2070,15 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.5.tgz", - "integrity": "sha512-iYHIy72LfbK+mL5W8zXROp6oOcJKXWeKcNjcPPsqoa18qIEDrhB6/Z08o0wRajTd6SSSDNw8NCSIHVNOMpz0mw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.5", + "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", - "magic-string": "^0.30.19" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2055,9 +2097,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.5.tgz", - "integrity": "sha512-t1T/sSdsYyNc5AZl0EMeD0jW9cpJe2cODP0R++ZQe1kTkpgrwEfxGFR/yCG4w8ZybizbXRTHU7lE8sTDD/QsGw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2068,13 +2110,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.5.tgz", - "integrity": "sha512-CQVVe+YEeKSiFBD5gBAmRDQglm4PnMBYzeTmt06t5iWtsUN9StQeeKhYCea/oaqBYilf8sARG6fSctUcEL/UmQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.5", + "@vitest/utils": "4.0.16", "pathe": "^2.0.3" }, "funding": { @@ -2082,14 +2124,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.5.tgz", - "integrity": "sha512-jfmSAeR6xYNEvcD+/RxFGA1bzpqHtkVhgxo2cxXia+Q3xX7m6GpZij07rz+WyQcA/xEGn4eIS1OItkMyWsGBmQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.5", - "magic-string": "^0.30.19", + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -2097,9 +2139,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.5.tgz", - "integrity": "sha512-TUmVQpAQign7r8+EnZsgTF3vY9BdGofTUge1rGNbnHn2IN3FChiQoT9lrPz7A7AVUZJU2LAZXl4v66HhsNMhoA==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, "license": "MIT", "funding": { @@ -2107,13 +2149,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.5.tgz", - "integrity": "sha512-V5RndUgCB5/AfNvK9zxGCrRs99IrPYtMTIdUzJMMFs9nrmE5JXExIEfjVtUteyTRiLfCm+dCRMHf/Uu7Mm8/dg==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.5", + "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2401,9 +2443,9 @@ } }, "node_modules/chai": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", - "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -3891,6 +3933,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4305,9 +4358,9 @@ } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, "license": "MIT", "dependencies": { @@ -4321,28 +4374,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" } }, @@ -4610,11 +4666,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4880,13 +4939,13 @@ } }, "node_modules/vite": { - "version": "7.1.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", - "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, - "peer": true, + "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -4954,29 +5013,513 @@ } } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/vitest": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.5.tgz", - "integrity": "sha512-4H+J28MI5oeYgGg3h5BFSkQ1g/2GKK1IR8oorH3a6EQQbb7CwjbnyBjH4PGxw9/6vpwAPNzaeUMp4Js4WJmdXQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.5", - "@vitest/mocker": "4.0.5", - "@vitest/pretty-format": "4.0.5", - "@vitest/runner": "4.0.5", - "@vitest/snapshot": "4.0.5", - "@vitest/spy": "4.0.5", - "@vitest/utils": "4.0.5", - "debug": "^4.4.3", + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", - "magic-string": "^0.30.19", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.9.0", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", + "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", @@ -4993,12 +5536,12 @@ }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", + "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.5", - "@vitest/browser-preview": "4.0.5", - "@vitest/browser-webdriverio": "4.0.5", - "@vitest/ui": "4.0.5", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, @@ -5006,7 +5549,7 @@ "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { @@ -5032,13 +5575,6 @@ } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index b443ad1..1c38bc9 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "tsx": "^4.7.0", "typescript": "^5.5.4", "typescript-eslint": "8.48.0", - "vitest": "^4.0.5" + "vitest": "^4.0.16" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.23.0-beta.0", From e4cb55ead6452075179c2a7376a15698fa2bd6c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:20:40 +0000 Subject: [PATCH 053/226] Bump the npm_and_yarn group across 1 directory with 2 updates (#83) Bumps the npm_and_yarn group with 2 updates in the /examples/servers/typescript directory: [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) and [qs](https://github.com/ljharb/qs). Updates `@modelcontextprotocol/sdk` from 1.22.0 to 1.24.0 - [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/1.22.0...1.24.0) Updates `qs` from 6.14.0 to 6.14.1 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1) --- updated-dependencies: - dependency-name: "@modelcontextprotocol/sdk" dependency-version: 1.24.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.14.1 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/servers/typescript/package-lock.json | 34 +++++++++++++------ examples/servers/typescript/package.json | 2 +- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index 7d35240..80a2f37 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -8,7 +8,7 @@ "name": "mcp-conformance-test-server", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "^1.24.0", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", @@ -462,9 +462,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", - "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.0.tgz", + "integrity": "sha512-D8h5KXY2vHFW8zTuxn2vuZGN0HGrQ5No6LkHwlEA9trVgNdPL3TF1dSqKA7Dny6BbBYKSW/rOBDXdC8KJAjUCg==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", @@ -476,20 +476,25 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true + }, + "zod": { + "optional": false } } }, @@ -1138,6 +1143,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1307,9 +1321,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index c5d428d..5819d2a 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -15,7 +15,7 @@ "testing" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.20.1", + "@modelcontextprotocol/sdk": "^1.24.0", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", From a90896b7d3144e118740d61bb0138a5242cf84ad Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 5 Jan 2026 14:32:35 +0000 Subject: [PATCH 054/226] Add token endpoint auth method conformance tests (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [auth] Add metadata suite for running metadata discovery tests Adds a new 'metadata' suite that runs just the auth/metadata-* scenarios for faster iteration when testing metadata discovery specifically. Usage: node dist/index.mjs client --suite metadata --command "..." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Don't require lefthook to be installed Set assert_lefthook_installed to false so the hooks gracefully skip if lefthook is not installed on the system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add token endpoint auth method conformance tests Add three new scenarios to test that clients correctly use the appropriate authentication method based on server metadata: - auth/token-endpoint-auth-basic: Tests client_secret_basic (HTTP Basic) - auth/token-endpoint-auth-post: Tests client_secret_post - auth/token-endpoint-auth-none: Tests public client (no auth) Each scenario configures the server to only support one auth method and verifies the client uses the correct method in token requests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Refactor token endpoint auth tests to use shared createAuthServer Extend createAuthServer helper with: - tokenEndpointAuthMethodsSupported option for metadata - onTokenRequest callback now receives full Request object - onRegistrationRequest callback for custom client credentials This eliminates the duplicate auth server implementation in token-endpoint-auth.ts and reduces code by ~140 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix onTokenRequest callback to use new interface The createAuthServer onTokenRequest callback now passes an object with authorizationHeader, body, timestamp etc. instead of (req, timestamp). Also added missing return value for TokenRequestResult. * Update @modelcontextprotocol/sdk to 1.25.1 --------- Co-authored-by: Claude Co-authored-by: Olivier Chafik --- package-lock.json | 51 ++++- package.json | 2 +- .../client/auth/helpers/createAuthServer.ts | 31 ++- src/scenarios/client/auth/index.ts | 8 + .../client/auth/token-endpoint-auth.ts | 178 ++++++++++++++++++ 5 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 src/scenarios/client/auth/token-endpoint-auth.ts diff --git a/package-lock.json b/package-lock.json index 8833116..5335f3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.23.0-beta.0", + "@modelcontextprotocol/sdk": "^1.25.1", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -749,6 +749,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.7", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", + "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -841,11 +853,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.23.0-beta.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.23.0-beta.0.tgz", - "integrity": "sha512-NvoyrhhNcNiyf0nI8J1O+wheNiyOzK3kMTkMuwGb/TGHpSHXCcubcg0IxC/p9Aym+K4QZFxq9Wn67clOAegFKQ==", + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", + "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -855,6 +868,8 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", @@ -3388,6 +3403,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", + "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -3582,6 +3607,12 @@ "dev": true, "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/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4095,9 +4126,9 @@ } }, "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", "engines": { "node": ">=16.20.0" @@ -5647,9 +5678,9 @@ } }, "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/package.json b/package.json index 1c38bc9..5a3e166 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "vitest": "^4.0.16" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.23.0-beta.0", + "@modelcontextprotocol/sdk": "^1.25.1", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c251597..1071828 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -43,6 +43,11 @@ export interface AuthServerOptions { scope?: string; timestamp: string; }) => void; + onRegistrationRequest?: (req: Request) => { + clientId: string; + clientSecret?: string; + tokenEndpointAuthMethod?: string; + }; } export function createAuthServer( @@ -62,7 +67,8 @@ export function createAuthServer( clientIdMetadataDocumentSupported, tokenVerifier, onTokenRequest, - onAuthorizationRequest + onAuthorizationRequest, + onRegistrationRequest } = options; // Track scopes from the most recent authorization request @@ -236,6 +242,17 @@ export function createAuthServer( }); app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => { + let clientId = 'test-client-id'; + let clientSecret: string | undefined = 'test-client-secret'; + let tokenEndpointAuthMethod: string | undefined; + + if (onRegistrationRequest) { + const result = onRegistrationRequest(req); + clientId = result.clientId; + clientSecret = result.clientSecret; + tokenEndpointAuthMethod = result.tokenEndpointAuthMethod; + } + checks.push({ id: 'client-registration', name: 'ClientRegistration', @@ -245,15 +262,19 @@ export function createAuthServer( specReferences: [SpecReferences.MCP_DCR], details: { endpoint: '/register', - clientName: req.body.client_name + clientName: req.body.client_name, + ...(tokenEndpointAuthMethod && { tokenEndpointAuthMethod }) } }); res.status(201).json({ - client_id: 'test-client-id', - client_secret: 'test-client-secret', + client_id: clientId, + ...(clientSecret && { client_secret: clientSecret }), client_name: req.body.client_name || 'test-client', - redirect_uris: req.body.redirect_uris || [] + redirect_uris: req.body.redirect_uris || [], + ...(tokenEndpointAuthMethod && { + token_endpoint_auth_method: tokenEndpointAuthMethod + }) }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 170c1cd..6a3ad1c 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -12,6 +12,11 @@ import { ScopeStepUpAuthScenario, ScopeRetryLimitScenario } from './scope-handling'; +import { + ClientSecretBasicAuthScenario, + ClientSecretPostAuthScenario, + PublicClientAuthScenario +} from './token-endpoint-auth'; import { ClientCredentialsJwtScenario, ClientCredentialsBasicScenario @@ -27,6 +32,9 @@ export const authScenariosList: Scenario[] = [ new ScopeOmittedWhenUndefinedScenario(), new ScopeStepUpAuthScenario(), new ScopeRetryLimitScenario(), + new ClientSecretBasicAuthScenario(), + new ClientSecretPostAuthScenario(), + new PublicClientAuthScenario(), new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario() ]; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts new file mode 100644 index 0000000..4203789 --- /dev/null +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -0,0 +1,178 @@ +import type { Scenario, ConformanceCheck } 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'; + +type AuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +function detectAuthMethod( + authorizationHeader?: string, + bodyClientSecret?: string +): AuthMethod { + if (authorizationHeader?.startsWith('Basic ')) { + return 'client_secret_basic'; + } + if (bodyClientSecret) { + return 'client_secret_post'; + } + return 'none'; +} + +function validateBasicAuthFormat(authorizationHeader: string): { + valid: boolean; + error?: string; +} { + const encoded = authorizationHeader.substring('Basic '.length); + try { + const decoded = Buffer.from(encoded, 'base64').toString('utf-8'); + if (!decoded.includes(':')) { + return { valid: false, error: 'missing colon separator' }; + } + return { valid: true }; + } catch { + return { valid: false, error: 'base64 decoding failed' }; + } +} + +const AUTH_METHOD_NAMES: Record = { + client_secret_basic: 'HTTP Basic authentication (client_secret_basic)', + client_secret_post: 'client_secret_post', + none: 'no authentication (public client)' +}; + +class TokenEndpointAuthScenario implements Scenario { + name: string; + description: string; + private expectedAuthMethod: AuthMethod; + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + constructor(expectedAuthMethod: AuthMethod) { + this.expectedAuthMethod = expectedAuthMethod; + this.name = `auth/token-endpoint-auth-${expectedAuthMethod === 'client_secret_basic' ? 'basic' : expectedAuthMethod === 'client_secret_post' ? 'post' : 'none'}`; + this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`; + } + + async start(): Promise { + this.checks = []; + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], + onTokenRequest: ({ authorizationHeader, body, timestamp }) => { + const bodyClientSecret = body.client_secret; + const actualMethod = detectAuthMethod( + authorizationHeader, + bodyClientSecret + ); + const isCorrect = actualMethod === this.expectedAuthMethod; + + // For basic auth, also validate the format + let formatError: string | undefined; + if (actualMethod === 'client_secret_basic' && authorizationHeader) { + const validation = validateBasicAuthFormat(authorizationHeader); + if (!validation.valid) { + formatError = validation.error; + } + } + + const status = isCorrect && !formatError ? 'SUCCESS' : 'FAILURE'; + let description: string; + + if (formatError) { + description = `Client sent Basic auth header but ${formatError}`; + } else if (isCorrect) { + description = `Client correctly used ${AUTH_METHOD_NAMES[this.expectedAuthMethod]} for token endpoint`; + } else { + description = `Client used ${actualMethod} but server only supports ${this.expectedAuthMethod}`; + } + + this.checks.push({ + id: 'token-endpoint-auth-method', + name: 'Token endpoint authentication method', + description, + status, + timestamp, + specReferences: [SpecReferences.OAUTH_2_1_TOKEN], + details: { + expectedAuthMethod: this.expectedAuthMethod, + actualAuthMethod: actualMethod, + hasAuthorizationHeader: !!authorizationHeader, + hasBodyClientSecret: !!bodyClientSecret, + ...(formatError && { formatError }) + } + }); + + return { + token: `test-token-${Date.now()}`, + scopes: [] + }; + }, + onRegistrationRequest: () => ({ + clientId: `test-client-${Date.now()}`, + clientSecret: + this.expectedAuthMethod === 'none' + ? undefined + : `test-secret-${Date.now()}`, + tokenEndpointAuthMethod: this.expectedAuthMethod + }) + }); + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + 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[] { + if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) { + this.checks.push({ + id: 'token-endpoint-auth-method', + name: 'Token endpoint authentication method', + description: 'Client did not make a token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.OAUTH_2_1_TOKEN] + }); + } + return this.checks; + } +} + +export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario { + constructor() { + super('client_secret_basic'); + } +} + +export class ClientSecretPostAuthScenario extends TokenEndpointAuthScenario { + constructor() { + super('client_secret_post'); + } +} + +export class PublicClientAuthScenario extends TokenEndpointAuthScenario { + constructor() { + super('none'); + } +} From 753f1240d18df27e127943352e4a2f45489e3015 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 5 Jan 2026 17:48:53 +0000 Subject: [PATCH 055/226] Add client credentials support to everything-client (#89) * Add client credentials support to everything-client - Add ConformanceContextSchema with discriminated union for auth contexts - Add runClientCredentialsJwt and runClientCredentialsBasic handlers - Update runner to include scenario name in context for discriminated union parsing - Update test helpers to set env vars for inline client runners - Fix JWT audience validation to match SDK behavior (no trailing slash) - Remove client credentials scenarios from skip list * Accept both trailing slash forms for JWT audience per RFC 3986 Per RFC 3986, URLs with and without trailing slash are equivalent. The MCP spec recommends (SHOULD) the form without trailing slash, but since it's not a MUST, the conformance test should accept both forms for interoperability with clients like Pydantic that normalize URLs. * Remove .vscode from tracking and add to gitignore * Address review feedback - Remove unused issuerUrl variable and comment - Simplify audience array to exactly 2 values with clearer naming - Add comment explaining strip-then-add-back logic - Rename ConformanceContextSchema to ClientConformanceContextSchema * Remove dist from tracking and add to gitignore * Fix .gitignore formatting * Add getHandler to everything-client for cleaner test imports Instead of importing individual handlers, tests can now import getHandler and look up handlers by scenario name. * Use getHandler exclusively, remove goodClient import - Add CIMD support to runAuthClient in everything-client - Add auth fallback to getHandler for unregistered auth/* scenarios - Remove goodClient import from test file * Remove runAuthClient fallback, register all auth scenarios explicitly --- .gitignore | 3 +- .../clients/typescript/everything-client.ts | 150 ++++++++++++++++-- src/runner/client.ts | 6 +- .../client/auth/client-credentials.ts | 15 +- src/scenarios/client/auth/index.test.ts | 13 +- .../client/auth/test_helpers/testClient.ts | 14 ++ src/schemas/context.ts | 25 +++ 7 files changed, 201 insertions(+), 25 deletions(-) create mode 100644 src/schemas/context.ts diff --git a/.gitignore b/.gitignore index 4a1642b..5c745fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules results/ lefthook-local.yml -dist \ No newline at end of file +dist/ +.vscode/ diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 464eb7a..4491fe6 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -12,12 +12,26 @@ * consolidating all the individual test clients into one. */ +import { fileURLToPath } from 'url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { + ClientCredentialsProvider, + PrivateKeyJwtProvider +} from '@modelcontextprotocol/sdk/client/auth-extensions.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { withOAuthRetry } from './helpers/withOAuthRetry.js'; +import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; +import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js'; import { logger } from './helpers/logger.js'; +/** + * Fixed client metadata URL for CIMD conformance tests. + * When server supports client_id_metadata_document_supported, this URL + * will be used as the client_id instead of doing dynamic registration. + */ +const CIMD_CLIENT_METADATA_URL = + 'https://conformance-test.local/client-metadata.json'; + // Scenario handler type type ScenarioHandler = (serverUrl: string) => Promise; @@ -36,6 +50,14 @@ function registerScenarios(names: string[], handler: ScenarioHandler): void { } } +/** + * Get a scenario handler by name. + * Returns undefined if no handler is registered for the scenario. + */ +export function getHandler(scenarioName: string): ScenarioHandler | undefined { + return scenarioHandlers[scenarioName]; +} + // ============================================================================ // Basic scenarios (initialize, tools-call) // ============================================================================ @@ -72,7 +94,9 @@ async function runAuthClient(serverUrl: string): Promise { const oauthFetch = withOAuthRetry( 'test-auth-client', - new URL(serverUrl) + new URL(serverUrl), + handle401, + CIMD_CLIENT_METADATA_URL )(fetch); const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { @@ -92,19 +116,30 @@ async function runAuthClient(serverUrl: string): Promise { logger.debug('Connection closed successfully'); } -// Register all auth scenarios that should use the well-behaved auth client +// Register all auth scenarios that use the well-behaved OAuth auth client registerScenarios( [ + // Basic auth scenarios + 'auth/basic-cimd', 'auth/basic-dcr', - 'auth/basic-metadata-var1', - 'auth/basic-metadata-var2', - 'auth/basic-metadata-var3', + // Metadata discovery scenarios + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/metadata-var3', + // Backcompat scenarios 'auth/2025-03-26-oauth-metadata-backcompat', 'auth/2025-03-26-oauth-endpoint-fallback', + // Scope handling scenarios 'auth/scope-from-www-authenticate', 'auth/scope-from-scopes-supported', 'auth/scope-omitted-when-undefined', - 'auth/scope-step-up' + 'auth/scope-step-up', + 'auth/scope-retry-limit', + // Token endpoint auth method scenarios + 'auth/token-endpoint-auth-basic', + 'auth/token-endpoint-auth-post', + 'auth/token-endpoint-auth-none' ], runAuthClient ); @@ -175,6 +210,96 @@ async function runElicitationDefaultsClient(serverUrl: string): Promise { registerScenario('elicitation-defaults', runElicitationDefaultsClient); +// ============================================================================ +// Client Credentials scenarios +// ============================================================================ + +/** + * Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var. + */ +function parseContext() { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) { + throw new Error('MCP_CONFORMANCE_CONTEXT not set'); + } + return ClientConformanceContextSchema.parse(JSON.parse(raw)); +} + +/** + * Client credentials with private_key_jwt authentication. + */ +export async function runClientCredentialsJwt( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-jwt') { + throw new Error(`Expected jwt context, got ${ctx.name}`); + } + + const provider = new PrivateKeyJwtProvider({ + clientId: ctx.client_id, + privateKey: ctx.private_key_pem, + algorithm: ctx.signing_algorithm || 'ES256' + }); + + const client = new Client( + { name: 'conformance-client-credentials-jwt', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with private_key_jwt auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt); + +/** + * Client credentials with client_secret_basic authentication. + */ +export async function runClientCredentialsBasic( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-basic') { + throw new Error(`Expected basic context, got ${ctx.name}`); + } + + const provider = new ClientCredentialsProvider({ + clientId: ctx.client_id, + clientSecret: ctx.client_secret + }); + + const client = new Client( + { name: 'conformance-client-credentials-basic', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with client_secret_basic auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); + // ============================================================================ // Main entry point // ============================================================================ @@ -216,7 +341,10 @@ async function main(): Promise { } } -main().catch((error) => { - console.error('Unhandled error:', error); - process.exit(1); -}); +// Only run main when this file is executed directly, not when imported as a module +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); + }); +} diff --git a/src/runner/client.ts b/src/runner/client.ts index dcb005f..7db5461 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -35,7 +35,11 @@ async function executeClient( const env = { ...process.env }; env.MCP_CONFORMANCE_SCENARIO = scenarioName; if (context) { - env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context); + // Include scenario name in context for discriminated union parsing + env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({ + name: scenarioName, + ...context + }); } return new Promise((resolve) => { diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index df13384..b82b5e2 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -50,10 +50,6 @@ export class ClientCredentialsJwtScenario implements Scenario { tokenEndpointAuthMethodsSupported: ['private_key_jwt'], tokenEndpointAuthSigningAlgValuesSupported: ['ES256'], onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => { - // Per RFC 7523bis, the audience MUST be the issuer identifier - const issuerUrl = authBaseUrl.endsWith('/') - ? authBaseUrl - : `${authBaseUrl}/`; if (grantType !== 'client_credentials') { this.checks.push({ id: 'client-credentials-grant-type', @@ -97,9 +93,16 @@ export class ClientCredentialsJwtScenario implements Scenario { // Verify JWT signature and claims using the generated public key try { - // Per RFC 7523bis, audience MUST be the issuer identifier + // Per RFC 7523bis, audience MUST be the issuer identifier. + // Per RFC 3986, URLs with and without trailing slash are equivalent, + // so we accept both forms for interoperability (e.g. Pydantic normalizes + // URLs by adding trailing slashes). + // Strip any trailing slashes first, then accept both the bare form + // and the form with exactly one trailing slash. + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; const { payload } = await jose.jwtVerify(clientAssertion, publicKey, { - audience: issuerUrl, + audience: [withoutSlash, withSlash], clockTolerance: 30 }); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 803ecf7..6dcc020 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -3,13 +3,13 @@ import { runClientAgainstScenario, InlineClientRunner } from './test_helpers/testClient'; -import { runClient as goodClient } from '../../../../examples/clients/typescript/auth-test'; import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm'; 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 { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; beforeAll(() => { @@ -17,10 +17,7 @@ beforeAll(() => { }); const skipScenarios = new Set([ - // Client credentials scenarios require SDK support for client_credentials grant - // Pending typescript-sdk implementation - 'auth/client-credentials-jwt', - 'auth/client-credentials-basic' + // Add scenarios that should be skipped here ]); const allowClientErrorScenarios = new Set([ @@ -36,7 +33,11 @@ describe('Client Auth Scenarios', () => { // TODO: skip in a native way? return; } - const runner = new InlineClientRunner(goodClient); + 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) }); diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 45f4e6d..0dbc8d8 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -107,6 +107,16 @@ export async function runClientAgainstScenario( const serverUrl = urls.serverUrl; try { + // Set environment variables for inline clients + // These mirror what src/runner/client.ts does for spawned processes + process.env.MCP_CONFORMANCE_SCENARIO = scenarioName; + if (urls.context) { + process.env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({ + name: scenarioName, + ...urls.context + }); + } + // Run the client try { await runner.run(serverUrl); @@ -166,6 +176,10 @@ export async function runClientAgainstScenario( } } } finally { + // Clean up environment variables + delete process.env.MCP_CONFORMANCE_SCENARIO; + delete process.env.MCP_CONFORMANCE_CONTEXT; + // Stop the scenario server await scenario.stop(); } diff --git a/src/schemas/context.ts b/src/schemas/context.ts new file mode 100644 index 0000000..d7ea2a2 --- /dev/null +++ b/src/schemas/context.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +/** + * Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT. + * + * Each variant includes a `name` field matching the scenario name to enable + * discriminated union parsing and type-safe access to scenario-specific fields. + */ +export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ + z.object({ + name: z.literal('auth/client-credentials-jwt'), + client_id: z.string(), + private_key_pem: z.string(), + signing_algorithm: z.string().optional() + }), + z.object({ + name: z.literal('auth/client-credentials-basic'), + client_id: z.string(), + client_secret: z.string() + }) +]); + +export type ClientConformanceContext = z.infer< + typeof ClientConformanceContextSchema +>; From 244eeeb362be1c638992d49431b05cf1d6383f1e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 5 Jan 2026 17:49:21 +0000 Subject: [PATCH 056/226] Change dependabot to monthly grouped updates (#90) - Switch from weekly to monthly schedule - Group all npm dependencies into single PR - Group all GitHub actions into single PR - Add explicit config for examples/servers/typescript directory - Remove cooldown config (less relevant with monthly schedule) --- .github/dependabot.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a972625..9d1dc11 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,13 +4,26 @@ updates: - package-ecosystem: npm directory: / schedule: - interval: weekly - cooldown: - default-days: 7 + 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: weekly - cooldown: - default-days: 7 + interval: monthly + groups: + all-actions: + patterns: + - '*' From aaf3b5cd079e368f0b4e37905f2febac81d26e6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:26:29 +0000 Subject: [PATCH 057/226] Bump actions/setup-node from 4 to 6 in the all-actions group (#91) Bumps the all-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/pr-publish.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a0a9cf..e8f4711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm @@ -41,7 +41,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm diff --git a/.github/workflows/pr-publish.yml b/.github/workflows/pr-publish.yml index 6579836..9229551 100644 --- a/.github/workflows/pr-publish.yml +++ b/.github/workflows/pr-publish.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm From 21624358262cdee6f4decc4ba3c990c954522b05 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 6 Jan 2026 12:29:36 +0000 Subject: [PATCH 058/226] feat: add --suite all option to run all client scenarios (#94) --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index edcf042..b1e0dba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ program } const suites: Record string[]> = { + all: listScenarios, auth: listAuthScenarios, metadata: listMetadataScenarios, 'sep-835': () => @@ -153,7 +154,7 @@ program console.error('Either --scenario or --suite is required'); console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); - console.error('\nAvailable suites: auth, metadata, sep-835'); + console.error('\nAvailable suites: all, auth, metadata, sep-835'); process.exit(1); } From 49311c7930a83fa5d176404589dfea324c46d70b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 6 Jan 2026 12:53:41 +0000 Subject: [PATCH 059/226] chore: bump version to 0.1.9 (#95) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5a3e166..dab5ea1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.8", + "version": "0.1.9", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 62875097f40dd5c8f7960f8b7a1ce81279c5f55a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 14 Jan 2026 20:16:28 +0000 Subject: [PATCH 060/226] feat: make results directory output optional (#107) Add -o, --output-dir option to both client and server commands. By default, no results directory is created - only console output. When -o is specified, results are saved to that directory with timestamped subdirectories. This eliminates the need to add 'results/' to .gitignore in every repo where conformance tests are run. Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 0 Claude-Permission-Prompts: 2 Claude-Escapes: 0 --- src/index.ts | 18 ++++++++---- src/runner/client.ts | 68 +++++++++++++++++++++++++++++--------------- src/runner/index.ts | 1 - src/runner/server.ts | 28 +++++++++++------- src/runner/utils.ts | 15 ++++------ 5 files changed, 81 insertions(+), 49 deletions(-) diff --git a/src/index.ts b/src/index.ts index b1e0dba..3b78b46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,11 +39,13 @@ program .option('--scenario ', 'Scenario to test') .option('--suite ', 'Run a suite of tests in parallel (e.g., "auth")') .option('--timeout ', 'Timeout in milliseconds', '30000') + .option('-o, --output-dir ', 'Save results to this directory') .option('--verbose', 'Show verbose output') .action(async (options) => { try { const timeout = parseInt(options.timeout, 10); const verbose = options.verbose ?? false; + const outputDir = options.outputDir; // Handle suite mode if (options.suite) { @@ -78,7 +80,8 @@ program const result = await runConformanceTest( options.command, scenarioName, - timeout + timeout, + outputDir ); return { scenario: scenarioName, @@ -163,7 +166,7 @@ program // If no command provided, run in interactive mode if (!validated.command) { - await runInteractiveMode(validated.scenario, verbose); + await runInteractiveMode(validated.scenario, verbose, outputDir); process.exit(0); } @@ -171,7 +174,8 @@ program const result = await runConformanceTest( validated.command, validated.scenario, - timeout + timeout, + outputDir ); const { overallFailure } = printClientResults( @@ -209,6 +213,7 @@ program 'Suite to run: "active" (default, excludes pending), "all", or "pending"', 'active' ) + .option('-o, --output-dir ', 'Save results to this directory') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { @@ -216,12 +221,14 @@ program const validated = ServerOptionsSchema.parse(options); const verbose = options.verbose ?? false; + const outputDir = options.outputDir; // If a single scenario is specified, run just that one if (validated.scenario) { const result = await runServerConformanceTest( validated.url, - validated.scenario + validated.scenario, + outputDir ); const { failed } = printServerResults( @@ -259,7 +266,8 @@ program try { const result = await runServerConformanceTest( validated.url, - scenarioName + scenarioName, + outputDir ); allResults.push({ scenario: scenarioName, checks: result.checks }); } catch (error) { diff --git a/src/runner/client.ts b/src/runner/client.ts index 7db5461..27a8b6e 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -3,7 +3,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { ConformanceCheck } from '../types'; import { getScenario } from '../scenarios'; -import { ensureResultsDir, createResultDir, formatPrettyChecks } from './utils'; +import { createResultDir, formatPrettyChecks } from './utils'; export interface ClientExecutionResult { exitCode: number; @@ -91,15 +91,19 @@ async function executeClient( export async function runConformanceTest( clientCommand: string, scenarioName: string, - timeout: number = 30000 + timeout: number = 30000, + outputDir?: string ): Promise<{ checks: ConformanceCheck[]; clientOutput: ClientExecutionResult; - resultDir: string; + resultDir?: string; }> { - await ensureResultsDir(); - const resultDir = createResultDir(scenarioName); - await fs.mkdir(resultDir, { recursive: true }); + let resultDir: string | undefined; + + if (outputDir) { + resultDir = createResultDir(outputDir, scenarioName); + await fs.mkdir(resultDir, { recursive: true }); + } // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; @@ -138,16 +142,24 @@ export async function runConformanceTest( const checks = scenario.getChecks(); - await fs.writeFile( - path.join(resultDir, 'checks.json'), - JSON.stringify(checks, null, 2) - ); + if (resultDir) { + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); - await fs.writeFile(path.join(resultDir, 'stdout.txt'), clientOutput.stdout); + await fs.writeFile( + path.join(resultDir, 'stdout.txt'), + clientOutput.stdout + ); - await fs.writeFile(path.join(resultDir, 'stderr.txt'), clientOutput.stderr); + await fs.writeFile( + path.join(resultDir, 'stderr.txt'), + clientOutput.stderr + ); - console.error(`Results saved to ${resultDir}`); + console.error(`Results saved to ${resultDir}`); + } return { checks, @@ -244,11 +256,15 @@ export function printClientResults( export async function runInteractiveMode( scenarioName: string, - verbose: boolean = false + verbose: boolean = false, + outputDir?: string ): Promise { - await ensureResultsDir(); - const resultDir = createResultDir(scenarioName); - await fs.mkdir(resultDir, { recursive: true }); + let resultDir: string | undefined; + + if (outputDir) { + resultDir = createResultDir(outputDir, scenarioName); + await fs.mkdir(resultDir, { recursive: true }); + } // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; @@ -257,23 +273,29 @@ export async function runInteractiveMode( const urls = await scenario.start(); console.log(`Server URL: ${urls.serverUrl}`); - console.log('Press Ctrl+C to stop and save checks...'); + console.log('Press Ctrl+C to stop...'); const handleShutdown = async () => { console.log('\nShutting down...'); const checks = scenario.getChecks(); - await fs.writeFile( - path.join(resultDir, 'checks.json'), - JSON.stringify(checks, null, 2) - ); + + if (resultDir) { + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); + } if (verbose) { console.log(`\nChecks:\n${JSON.stringify(checks, null, 2)}`); } else { console.log(`\nChecks:\n${formatPrettyChecks(checks)}`); } - console.log(`\nChecks saved to ${resultDir}/checks.json`); + + if (resultDir) { + console.log(`\nChecks saved to ${resultDir}/checks.json`); + } await scenario.stop(); process.exit(0); diff --git a/src/runner/index.ts b/src/runner/index.ts index d48a291..a39a86c 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -15,7 +15,6 @@ export { // Export utilities export { - ensureResultsDir, createResultDir, formatPrettyChecks, getStatusColor, diff --git a/src/runner/server.ts b/src/runner/server.ts index 18c8254..a7d8449 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { ConformanceCheck } from '../types'; import { getClientScenario } from '../scenarios'; -import { ensureResultsDir, createResultDir, formatPrettyChecks } from './utils'; +import { createResultDir, formatPrettyChecks } from './utils'; /** * Format markdown-style text for terminal output using ANSI codes @@ -19,15 +19,19 @@ function formatMarkdown(text: string): string { export async function runServerConformanceTest( serverUrl: string, - scenarioName: string + scenarioName: string, + outputDir?: string ): Promise<{ checks: ConformanceCheck[]; - resultDir: string; + resultDir?: string; scenarioDescription: string; }> { - await ensureResultsDir(); - const resultDir = createResultDir(scenarioName, 'server'); - await fs.mkdir(resultDir, { recursive: true }); + let resultDir: string | undefined; + + if (outputDir) { + resultDir = createResultDir(outputDir, scenarioName, 'server'); + await fs.mkdir(resultDir, { recursive: true }); + } // Scenario is guaranteed to exist by CLI validation const scenario = getClientScenario(scenarioName)!; @@ -38,12 +42,14 @@ export async function runServerConformanceTest( const checks = await scenario.run(serverUrl); - await fs.writeFile( - path.join(resultDir, 'checks.json'), - JSON.stringify(checks, null, 2) - ); + if (resultDir) { + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); - console.log(`Results saved to ${resultDir}`); + console.log(`Results saved to ${resultDir}`); + } return { checks, diff --git a/src/runner/utils.ts b/src/runner/utils.ts index 6841bc6..14e9432 100644 --- a/src/runner/utils.ts +++ b/src/runner/utils.ts @@ -1,4 +1,3 @@ -import { promises as fs } from 'fs'; import path from 'path'; import { ConformanceCheck } from '../types'; @@ -51,14 +50,12 @@ export function formatPrettyChecks(checks: ConformanceCheck[]): string { .join('\n'); } -export async function ensureResultsDir(): Promise { - const resultsDir = path.join(process.cwd(), 'results'); - await fs.mkdir(resultsDir, { recursive: true }); - return resultsDir; -} - -export function createResultDir(scenario: string, prefix = ''): string { +export function createResultDir( + baseDir: string, + scenario: string, + prefix = '' +): string { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const dirName = prefix ? `${prefix}-${scenario}` : scenario; - return path.join('results', `${dirName}-${timestamp}`); + return path.join(baseDir, `${dirName}-${timestamp}`); } From 0675e9b382c1304c17941c59f0b0da3857e55a3f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Mon, 19 Jan 2026 18:46:09 +0200 Subject: [PATCH 061/226] update npm audit (#108) --- examples/servers/typescript/package.json | 2 +- package-lock.json | 40 +++++++++--------------- package.json | 2 +- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 5819d2a..eab4eb7 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -15,7 +15,7 @@ "testing" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^1.25.2", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", diff --git a/package-lock.json b/package-lock.json index 5335f3f..f197f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.8", + "version": "0.1.9", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.25.2", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -853,9 +853,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", - "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.7", @@ -1736,7 +1736,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -1954,7 +1953,6 @@ "integrity": "sha512-EiPEgGwNa2uHyyKgeoWTL6wWHKUBmF3xsfZ3OHofk7TxUuxb2mpLG5igEuaBe8iUwkCUl9uZgJvOu6o0wE5NSA==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsgo": "bin/tsgo.js" }, @@ -2196,7 +2194,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2643,9 +2640,9 @@ } }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2815,7 +2812,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3047,7 +3043,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -3404,9 +3399,9 @@ } }, "node_modules/hono": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz", - "integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", "peer": true, "engines": { @@ -4117,7 +4112,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4212,9 +4206,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4315,7 +4309,6 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, - "peer": true, "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -4839,7 +4832,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4886,7 +4878,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5672,7 +5663,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index dab5ea1..e117e09 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "vitest": "^4.0.16" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.1", + "@modelcontextprotocol/sdk": "^1.25.2", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", From 4b5c835b70826a6abcc789f29b2a48f302500ef2 Mon Sep 17 00:00:00 2001 From: adam jones Date: Mon, 19 Jan 2026 16:47:12 +0000 Subject: [PATCH 062/226] chore: update licensing to Apache 2.0 for new contributions (#100) --- LICENSE | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 136059a..4a93985 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,193 @@ +The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0. + +Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License. + +No rights beyond those granted by the applicable original license are conveyed for such contributions. + +--- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright + owner or by an individual or Legal Entity authorized to submit on behalf + of the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- + MIT License -Copyright (c) 2024 Anthropic, PBC +Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +205,12 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. + +--- + +Creative Commons Attribution 4.0 International (CC-BY-4.0) + +Documentation in this project (excluding specifications) is licensed under +CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for +the full license text. From 7a3b13a404c9a5650d4d4698013030cd431772e5 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 20 Jan 2026 15:15:11 +0000 Subject: [PATCH 063/226] 0.1.10 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f197f79..0972f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.9", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.9", + "version": "0.1.10", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/package.json b/package.json index e117e09..0ed78f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From aeaa406f304cb29cbd165e7ec3f097955546af33 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:30:05 +0000 Subject: [PATCH 064/226] chore: add .idea/ to .gitignore (#111) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5c745fc..bd9f0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ results/ lefthook-local.yml dist/ .vscode/ +.idea/ From cc784d96e2ab7b5562c8c23cee1647ffa5c97367 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 22 Jan 2026 11:39:07 +0100 Subject: [PATCH 065/226] Update warning message in README.md (#112) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f1e7ddf..5ef29f3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ 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. +> [!WARNING] +> This repository is a work in progress and is unstable. Join the conversation in the #conformance-testing-wg in the MCP Contributors discord. ## Quick Start From 83ff334e0ecbe40186c3802743d637bd31f6d20e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:41:04 +0000 Subject: [PATCH 066/226] feat: add --expected-failures baseline and composite GitHub Action (#113) Add support for SDKs that don't yet pass all conformance tests to still run conformance in CI without failing, by specifying a YAML baseline file of known/expected failures. CLI changes: - Add --expected-failures flag to both server and client commands - Exit 0 if only expected failures occur - Exit 1 if unexpected failures occur (regression) - Exit 1 if an expected failure now passes (stale baseline, must update) GitHub Action (action.yml): - Composite action that builds from source and runs conformance tests - SDKs reference it as: uses: modelcontextprotocol/conformance@ - Inputs: mode, url, command, expected-failures, suite, scenario, etc. New files: - src/expected-failures.ts: YAML loading, baseline evaluation, reporting - src/expected-failures.test.ts: 16 unit tests - action.yml: composite GitHub Action Closes #99 --- README.md | 91 +++++++++++- action.yml | 97 +++++++++++++ package-lock.json | 16 +++ package.json | 1 + src/expected-failures.test.ts | 261 ++++++++++++++++++++++++++++++++++ src/expected-failures.ts | 166 +++++++++++++++++++++ src/index.ts | 66 +++++++++ 7 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 action.yml create mode 100644 src/expected-failures.test.ts create mode 100644 src/expected-failures.ts diff --git a/README.md b/README.md index 5ef29f3..bbc3c8c 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ 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") +- `--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 @@ -79,7 +80,10 @@ npx @modelcontextprotocol/conformance server --url [--scenario ] **Options:** - `--url` - URL of the server to test -- `--scenario ` - Test scenario to run (e.g., "server-initialize". Runs all available scenarios by default +- `--scenario ` - Test scenario to run (e.g., "server-initialize"). Runs all available scenarios by default +- `--suite ` - Suite to run: "active" (default), "all", or "pending" +- `--expected-failures ` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures)) +- `--verbose` - Show verbose output ## Test Results @@ -93,6 +97,91 @@ npx @modelcontextprotocol/conformance server --url [--scenario ] - `checks.json` - Array of conformance check results with pass/fail status +## 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. + +Create a YAML file listing expected failures by mode: + +```yaml +# conformance-baseline.yml +server: + - tools-call-with-progress + - resources-subscribe +client: + - sse-retry +``` + +Then pass it to the CLI: + +```bash +npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp --expected-failures ./conformance-baseline.yml +``` + +**Exit code behavior:** + +| Scenario Result | In Baseline? | Outcome | +| --------------- | ------------ | ----------------------------------------- | +| Fails | Yes | Exit 0 — expected failure | +| Fails | No | Exit 1 — unexpected regression | +| Passes | Yes | Exit 1 — stale baseline, remove the entry | +| Passes | No | Exit 0 — normal pass | + +This ensures: + +- CI passes when only known failures occur +- CI fails on new regressions (unexpected failures) +- CI fails when a fix lands but the baseline isn't updated (stale entries) + +## GitHub Action + +This repo provides a composite GitHub Action so SDK repos don't need to write their own conformance scripts. + +### Server Testing + +```yaml +steps: + - uses: actions/checkout@v4 + + # Start your server (SDK-specific) + - run: | + my-server --port 3001 & + timeout 15 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' + + - uses: modelcontextprotocol/conformance@v0.1.11 + with: + mode: server + url: http://localhost:3001/mcp + expected-failures: ./conformance-baseline.yml # optional +``` + +### Client Testing + +```yaml +steps: + - uses: actions/checkout@v4 + + - uses: modelcontextprotocol/conformance@v0.1.11 + with: + mode: client + command: 'python tests/conformance/client.py' + expected-failures: ./conformance-baseline.yml # optional +``` + +### Action Inputs + +| Input | Required | Description | +| ------------------- | ----------- | ----------------------------------------------- | +| `mode` | Yes | `server` or `client` | +| `url` | Server mode | URL of the server to test | +| `command` | Client mode | Command to run the client under test | +| `expected-failures` | No | Path to YAML baseline file | +| `suite` | No | Test suite to run | +| `scenario` | No | Run a single scenario by name | +| `timeout` | No | Timeout in ms for client tests (default: 30000) | +| `verbose` | No | Show verbose output (default: false) | +| `node-version` | No | Node.js version (default: 20) | + ## Example Clients - `examples/clients/typescript/everything-client.ts` - Single client that handles all scenarios based on scenario name (recommended) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e5fe549 --- /dev/null +++ b/action.yml @@ -0,0 +1,97 @@ +name: 'MCP Conformance Tests' +description: 'Run MCP conformance tests against a server or client implementation' +inputs: + mode: + description: 'Test mode: "server" or "client"' + required: true + url: + description: 'Server URL to test against (required for server mode)' + required: false + command: + description: 'Command to run the client under test (required for client mode)' + required: false + expected-failures: + description: 'Path to YAML file listing expected failures (baseline)' + required: false + suite: + description: 'Test suite to run (server: "active"|"all"|"pending", client: "all"|"auth"|"metadata"|"sep-835")' + required: false + scenario: + description: 'Run a single scenario by name' + required: false + timeout: + description: 'Timeout in milliseconds for client tests (default: 30000)' + required: false + default: '30000' + verbose: + description: 'Show verbose output (default: false)' + required: false + default: 'false' + node-version: + description: 'Node.js version to use (default: 20)' + required: false + default: '20' +runs: + using: 'composite' + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - name: Build conformance tests + shell: bash + run: | + cd "${{ github.action_path }}" + npm ci + npm run build + + - name: Run conformance tests + shell: bash + run: | + CONFORMANCE="${{ github.action_path }}/dist/index.js" + + # Build the command arguments + ARGS="${{ inputs.mode }}" + + # Mode-specific required options + if [ "${{ inputs.mode }}" = "server" ]; then + if [ -z "${{ inputs.url }}" ]; then + echo "::error::The 'url' input is required for server mode" + exit 1 + fi + ARGS="${ARGS} --url ${{ inputs.url }}" + elif [ "${{ inputs.mode }}" = "client" ]; then + if [ -z "${{ inputs.command }}" ]; then + echo "::error::The 'command' input is required for client mode" + exit 1 + fi + ARGS="${ARGS} --command '${{ inputs.command }}'" + else + echo "::error::Invalid mode '${{ inputs.mode }}'. Must be 'server' or 'client'." + exit 1 + fi + + # Optional arguments + if [ -n "${{ inputs.expected-failures }}" ]; then + ARGS="${ARGS} --expected-failures ${{ inputs.expected-failures }}" + fi + + if [ -n "${{ inputs.suite }}" ]; then + ARGS="${ARGS} --suite ${{ inputs.suite }}" + fi + + if [ -n "${{ inputs.scenario }}" ]; then + ARGS="${ARGS} --scenario ${{ inputs.scenario }}" + fi + + if [ "${{ inputs.mode }}" = "client" ]; then + ARGS="${ARGS} --timeout ${{ inputs.timeout }}" + fi + + if [ "${{ inputs.verbose }}" = "true" ]; then + ARGS="${ARGS} --verbose" + fi + + echo "Running: node ${CONFORMANCE} ${ARGS}" + eval "node ${CONFORMANCE} ${ARGS}" diff --git a/package-lock.json b/package-lock.json index 0972f6b..645e101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", + "yaml": "^2.8.2", "zod": "^3.25.76" }, "bin": { @@ -5645,6 +5646,21 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0ed78f9..39532db 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", + "yaml": "^2.8.2", "zod": "^3.25.76" } } diff --git a/src/expected-failures.test.ts b/src/expected-failures.test.ts new file mode 100644 index 0000000..05cfc9c --- /dev/null +++ b/src/expected-failures.test.ts @@ -0,0 +1,261 @@ +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 { ConformanceCheck } from './types'; + +function makeCheck( + id: string, + status: 'SUCCESS' | 'FAILURE' | 'WARNING' | 'SKIPPED' | 'INFO' +): ConformanceCheck { + return { + id, + name: id, + description: `Check ${id}`, + status, + timestamp: new Date().toISOString() + }; +} + +describe('loadExpectedFailures', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'conformance-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true }); + }); + + it('loads a valid YAML file with both server and client entries', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile( + filePath, + `server: + - tools-call-with-progress + - resources-subscribe +client: + - sse-retry + - auth/basic-dcr +` + ); + + const result = await loadExpectedFailures(filePath); + expect(result.server).toEqual([ + 'tools-call-with-progress', + 'resources-subscribe' + ]); + expect(result.client).toEqual(['sse-retry', 'auth/basic-dcr']); + }); + + it('loads a file with only server entries', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile( + filePath, + `server: + - tools-call-with-progress +` + ); + + const result = await loadExpectedFailures(filePath); + expect(result.server).toEqual(['tools-call-with-progress']); + expect(result.client).toBeUndefined(); + }); + + it('handles an empty file', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, ''); + + const result = await loadExpectedFailures(filePath); + expect(result).toEqual({}); + }); + + it('throws on invalid structure (array at top level)', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, '- foo\n- bar\n'); + + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + 'expected an object' + ); + }); + + it('throws if server is not an array', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, 'server: not-an-array\n'); + + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + "'server' must be an array" + ); + }); + + it('throws if client is not an array', async () => { + const filePath = path.join(tmpDir, 'baseline.yml'); + await fs.writeFile(filePath, 'client: 123\n'); + + await expect(loadExpectedFailures(filePath)).rejects.toThrow( + "'client' must be an array" + ); + }); + + it('throws on missing file', async () => { + await expect( + loadExpectedFailures('/nonexistent/path.yml') + ).rejects.toThrow(); + }); +}); + +describe('evaluateBaseline', () => { + it('returns exit 0 when all failures are expected', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'SUCCESS'), makeCheck('check2', 'FAILURE')] + }, + { + scenario: 'scenario-b', + checks: [makeCheck('check3', 'FAILURE')] + } + ]; + + const result = evaluateBaseline(results, ['scenario-a', 'scenario-b']); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual(['scenario-a', 'scenario-b']); + expect(result.unexpectedFailures).toEqual([]); + expect(result.staleEntries).toEqual([]); + }); + + it('returns exit 0 when no failures at all and no baseline', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'SUCCESS')] + } + ]; + + const result = evaluateBaseline(results, []); + expect(result.exitCode).toBe(0); + expect(result.expectedFailures).toEqual([]); + expect(result.unexpectedFailures).toEqual([]); + expect(result.staleEntries).toEqual([]); + }); + + it('returns exit 1 for unexpected failures', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'FAILURE')] + } + ]; + + const result = evaluateBaseline(results, []); + expect(result.exitCode).toBe(1); + expect(result.unexpectedFailures).toEqual(['scenario-a']); + }); + + it('returns exit 1 for stale baseline entries (scenario now passes)', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'SUCCESS')] + } + ]; + + const result = evaluateBaseline(results, ['scenario-a']); + expect(result.exitCode).toBe(1); + expect(result.staleEntries).toEqual(['scenario-a']); + }); + + it('returns exit 1 when both stale and unexpected failures exist', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'SUCCESS')] // was expected to fail but passes + }, + { + scenario: 'scenario-b', + checks: [makeCheck('check2', 'FAILURE')] // unexpected failure + } + ]; + + const result = evaluateBaseline(results, ['scenario-a']); + expect(result.exitCode).toBe(1); + expect(result.staleEntries).toEqual(['scenario-a']); + expect(result.unexpectedFailures).toEqual(['scenario-b']); + }); + + it('handles warnings as failures', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'WARNING')] + } + ]; + + // Not in baseline → unexpected + const result1 = evaluateBaseline(results, []); + expect(result1.exitCode).toBe(1); + expect(result1.unexpectedFailures).toEqual(['scenario-a']); + + // In baseline → expected + const result2 = evaluateBaseline(results, ['scenario-a']); + expect(result2.exitCode).toBe(0); + expect(result2.expectedFailures).toEqual(['scenario-a']); + }); + + it('ignores baseline entries for scenarios not in the run', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [makeCheck('check1', 'SUCCESS')] + } + ]; + + // scenario-z is in baseline but not in the results - should not be stale + const result = evaluateBaseline(results, ['scenario-z']); + expect(result.exitCode).toBe(0); + expect(result.staleEntries).toEqual([]); + }); + + it('handles mixed expected/unexpected/passing scenarios', () => { + const results = [ + { + scenario: 'expected-fail', + checks: [makeCheck('c1', 'FAILURE')] + }, + { + scenario: 'unexpected-fail', + checks: [makeCheck('c2', 'FAILURE')] + }, + { + scenario: 'normal-pass', + checks: [makeCheck('c3', 'SUCCESS')] + } + ]; + + const result = evaluateBaseline(results, ['expected-fail']); + expect(result.exitCode).toBe(1); + expect(result.expectedFailures).toEqual(['expected-fail']); + expect(result.unexpectedFailures).toEqual(['unexpected-fail']); + expect(result.staleEntries).toEqual([]); + }); + + it('skipped and info checks do not count as failures', () => { + const results = [ + { + scenario: 'scenario-a', + checks: [ + makeCheck('c1', 'SUCCESS'), + makeCheck('c2', 'SKIPPED'), + makeCheck('c3', 'INFO') + ] + } + ]; + + // In baseline but passes (only SUCCESS/SKIPPED/INFO) → stale + const result = evaluateBaseline(results, ['scenario-a']); + expect(result.exitCode).toBe(1); + expect(result.staleEntries).toEqual(['scenario-a']); + }); +}); diff --git a/src/expected-failures.ts b/src/expected-failures.ts new file mode 100644 index 0000000..8d0655f --- /dev/null +++ b/src/expected-failures.ts @@ -0,0 +1,166 @@ +import { promises as fs } from 'fs'; +import { parse as parseYaml } from 'yaml'; +import { ConformanceCheck } from './types'; +import { COLORS } from './runner/utils'; + +export interface ExpectedFailures { + server?: string[]; + client?: string[]; +} + +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) */ + unexpectedFailures: string[]; + /** Scenarios in baseline that now pass (stale entries) */ + staleEntries: string[]; + /** Scenarios that failed as expected */ + expectedFailures: string[]; +} + +/** + * Load and parse an expected-failures YAML file. + * + * Expected format: + * ```yaml + * server: + * - scenario-name-1 + * - scenario-name-2 + * client: + * - scenario-name-3 + * ``` + */ +export async function loadExpectedFailures( + filePath: string +): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed = parseYaml(content); + + if (parsed === null || parsed === undefined) { + return {}; + } + + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error( + `Invalid expected-failures file: expected an object with 'server' and/or 'client' keys` + ); + } + + const result: ExpectedFailures = {}; + + if (parsed.server !== undefined) { + if (!Array.isArray(parsed.server)) { + throw new Error( + `Invalid expected-failures file: 'server' 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); + } + + return result; +} + +/** + * Evaluate scenario results against an expected-failures baseline. + * + * Rules: + * - 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) + */ +export function evaluateBaseline( + results: { scenario: string; checks: ConformanceCheck[] }[], + expectedScenarios: string[] +): BaselineResult { + const expectedSet = new Set(expectedScenarios); + const unexpectedFailures: string[] = []; + const staleEntries: string[] = []; + const expectedFailures: string[] = []; + + const seenScenarios = new Set(); + + 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); + } + // !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 exitCode = + unexpectedFailures.length > 0 || staleEntries.length > 0 ? 1 : 0; + + return { exitCode, unexpectedFailures, staleEntries, expectedFailures }; +} + +/** + * Print baseline evaluation results. + */ +export function printBaselineResults(result: BaselineResult): void { + if (result.expectedFailures.length > 0) { + console.log( + `\n${COLORS.YELLOW}Expected failures (in baseline):${COLORS.RESET}` + ); + for (const scenario of result.expectedFailures) { + console.log(` ~ ${scenario}`); + } + } + + if (result.staleEntries.length > 0) { + console.log( + `\n${COLORS.RED}Stale baseline entries (now passing - remove from baseline):${COLORS.RESET}` + ); + for (const scenario of result.staleEntries) { + console.log(` ✓ ${scenario}`); + } + } + + if (result.unexpectedFailures.length > 0) { + console.log( + `\n${COLORS.RED}Unexpected failures (not in baseline):${COLORS.RESET}` + ); + for (const scenario of result.unexpectedFailures) { + console.log(` ✗ ${scenario}`); + } + } + + if (result.exitCode === 0) { + console.log( + `\n${COLORS.GREEN}Baseline check passed: all failures are expected.${COLORS.RESET}` + ); + } 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}` + ); + } + if (result.unexpectedFailures.length > 0) { + console.log( + `\n${COLORS.RED}Unexpected failures detected: these scenarios are not in your expected-failures baseline.${COLORS.RESET}` + ); + } + } +} diff --git a/src/index.ts b/src/index.ts index 3b78b46..7a6aa15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,11 @@ import { } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; +import { + loadExpectedFailures, + evaluateBaseline, + printBaselineResults +} from './expected-failures'; import packageJson from '../package.json'; const program = new Command(); @@ -39,6 +44,10 @@ program .option('--scenario ', 'Scenario to test') .option('--suite ', 'Run a suite of tests in parallel (e.g., "auth")') .option('--timeout ', 'Timeout in milliseconds', '30000') + .option( + '--expected-failures ', + 'Path to YAML file listing expected failures (baseline)' + ) .option('-o, --output-dir ', 'Save results to this directory') .option('--verbose', 'Show verbose output') .action(async (options) => { @@ -149,6 +158,17 @@ program console.log( `\nTotal: ${totalPassed} passed, ${totalFailed} failed, ${totalWarnings} warnings` ); + + if (options.expectedFailures) { + const expectedFailuresConfig = await loadExpectedFailures( + options.expectedFailures + ); + const baselineScenarios = expectedFailuresConfig.client ?? []; + const baselineResult = evaluateBaseline(results, baselineScenarios); + printBaselineResults(baselineResult); + process.exit(baselineResult.exitCode); + } + process.exit(totalFailed > 0 || totalWarnings > 0 ? 1 : 0); } @@ -183,6 +203,20 @@ program verbose, result.clientOutput ); + + if (options.expectedFailures) { + const expectedFailuresConfig = await loadExpectedFailures( + options.expectedFailures + ); + const baselineScenarios = expectedFailuresConfig.client ?? []; + const baselineResult = evaluateBaseline( + [{ scenario: validated.scenario, checks: result.checks }], + baselineScenarios + ); + printBaselineResults(baselineResult); + process.exit(baselineResult.exitCode); + } + process.exit(overallFailure ? 1 : 0); } catch (error) { if (error instanceof ZodError) { @@ -213,6 +247,10 @@ program 'Suite to run: "active" (default, excludes pending), "all", or "pending"', 'active' ) + .option( + '--expected-failures ', + 'Path to YAML file listing expected failures (baseline)' + ) .option('-o, --output-dir ', 'Save results to this directory') .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { @@ -236,6 +274,20 @@ program result.scenarioDescription, verbose ); + + if (options.expectedFailures) { + const expectedFailuresConfig = await loadExpectedFailures( + options.expectedFailures + ); + const baselineScenarios = expectedFailuresConfig.server ?? []; + const baselineResult = evaluateBaseline( + [{ scenario: validated.scenario!, checks: result.checks }], + baselineScenarios + ); + printBaselineResults(baselineResult); + process.exit(baselineResult.exitCode); + } + process.exit(failed > 0 ? 1 : 0); } else { // Run scenarios based on suite @@ -290,6 +342,20 @@ program } const { totalFailed } = printServerSummary(allResults); + + if (options.expectedFailures) { + const expectedFailuresConfig = await loadExpectedFailures( + options.expectedFailures + ); + const baselineScenarios = expectedFailuresConfig.server ?? []; + const baselineResult = evaluateBaseline( + allResults, + baselineScenarios + ); + printBaselineResults(baselineResult); + process.exit(baselineResult.exitCode); + } + process.exit(totalFailed > 0 ? 1 : 0); } } catch (error) { From d0b2b4914d24a3530e354a667b21f991ed756653 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 18:37:48 +0000 Subject: [PATCH 067/226] feat: add DNS rebinding protection conformance tests (#115) * feat: add DNS rebinding protection conformance tests Add a new server conformance scenario to verify that localhost MCP servers properly validate Host headers to prevent DNS rebinding attacks. Checks: - localhost-host-rebinding-rejected: Verifies server returns 403 for non-localhost Host headers (e.g., evil.example.com) - localhost-host-valid-accepted: Verifies server accepts requests with valid localhost Host headers Also updates the everything-server example to use createMcpExpressApp() which includes DNS rebinding protection by default. Closes #103 Co-Authored-By: Claude * feat: add negative test case for DNS rebinding protection Add a minimal MCP server that intentionally omits DNS rebinding protection to serve as a negative test case. This server is expected to FAIL the dns-rebinding-protection scenario. Also update README to document the negative test case. Co-Authored-By: Claude * fix: accept any 4xx status code for DNS rebinding rejection Update the DNS rebinding protection test to accept any 4xx status code as a valid rejection response, not just 403. This accommodates different HTTP semantics: - 403 Forbidden: Per MCP spec - 421 Misdirected Request: RFC 7540 (semantically correct for DNS rebinding) - Other 4xx: Implementation-specific error codes * refactor: address PR review comments - Simplify negative test server (remove event store, sessions) - Update spec reference URL to security_best_practices - Clarify scope: localhost servers without HTTPS/auth - DRY up checks.push using spread operator - Fix valid host check to require 2xx response * fix: include error details in catch block * refactor: simplify negative test server to stateless * refactor: check both Host and Origin headers for DNS rebinding protection - Send both Host and Origin headers in test requests so servers checking either header will pass the conformance test - Add second spec reference for Origin check (transports#security-warning) - Update descriptions to mention both Host and Origin headers - Fix protocol version to 2025-11-25 - Remove unnecessary 'as const' type assertions --------- Co-authored-by: Claude --- examples/servers/typescript/README.md | 18 ++ .../servers/typescript/everything-server.ts | 6 +- .../typescript/no-dns-rebinding-protection.ts | 64 +++++ package-lock.json | 10 + package.json | 1 + src/scenarios/index.ts | 7 +- src/scenarios/server/dns-rebinding.ts | 238 ++++++++++++++++++ 7 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 examples/servers/typescript/no-dns-rebinding-protection.ts create mode 100644 src/scenarios/server/dns-rebinding.ts diff --git a/examples/servers/typescript/README.md b/examples/servers/typescript/README.md index 5beacf1..53c28fd 100644 --- a/examples/servers/typescript/README.md +++ b/examples/servers/typescript/README.md @@ -170,3 +170,21 @@ If you're implementing MCP in another language/SDK: 5. **Handle Notifications Carefully**: Catch/ignore errors when no client is connected **Goal**: All SDK example servers provide the same interface, enabling a single test suite to verify conformance across all implementations. + +## Negative Test Cases + +### no-dns-rebinding-protection.ts + +A minimal MCP server that intentionally omits DNS rebinding protection. This is a **negative test case** that demonstrates what a vulnerable server looks like and is expected to **FAIL** the `dns-rebinding-protection` conformance scenario. + +```bash +# Run the vulnerable server +npx tsx no-dns-rebinding-protection.ts + +# This should FAIL the dns-rebinding-protection checks +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3003/mcp \ + --scenario dns-rebinding-protection +``` + +**DO NOT** use this pattern in production servers. Always use `createMcpExpressApp()` or the `localhostHostValidation()` middleware for localhost servers. diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 9dd382a..374e48c 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -18,6 +18,7 @@ import { EventId, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { ElicitResultSchema, ListToolsRequestSchema, @@ -26,7 +27,6 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { z } from 'zod'; -import express from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; @@ -1055,8 +1055,8 @@ function isInitializeRequest(body: any): boolean { // ===== EXPRESS APP ===== -const app = express(); -app.use(express.json()); +// Use createMcpExpressApp for DNS rebinding protection on localhost +const app = createMcpExpressApp(); // Configure CORS to expose Mcp-Session-Id header for browser-based clients app.use( diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts new file mode 100644 index 0000000..f1ff754 --- /dev/null +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +/** + * MCP Server WITHOUT DNS Rebinding Protection - Negative Test Case + * + * This is the simplest possible vulnerable server to demonstrate what happens + * when DNS rebinding protection is omitted. DO NOT use this pattern in production. + * + * This server should FAIL the dns-rebinding-protection conformance scenario. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; + +// Create minimal MCP server +const server = new McpServer({ + name: 'no-dns-rebinding-protection-server', + version: '1.0.0' +}); + +// Add a simple tool +server.tool( + 'echo', + 'Echo the input back', + { message: { type: 'string' } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) +); + +// === VULNERABLE EXPRESS APP === +// This intentionally does NOT use createMcpExpressApp() or localhostHostValidation() +const app = express(); +app.use(express.json()); +// NO DNS rebinding protection middleware here! + +app.post('/mcp', async (req, res) => { + try { + // Stateless: no session ID + 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 || '3003', 10); +app.listen(PORT, '127.0.0.1', () => { + console.log(`Vulnerable server running on http://localhost:${PORT}/mcp`); + console.log(`WARNING: No DNS rebinding protection enabled!`); +}); diff --git a/package-lock.json b/package-lock.json index 645e101..c790e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", + "undici": "^7.19.0", "yaml": "^2.8.2", "zod": "^3.25.76" }, @@ -4926,6 +4927,15 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/undici": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz", + "integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==", + "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", diff --git a/package.json b/package.json index 39532db..861a257 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", + "undici": "^7.19.0", "yaml": "^2.8.2", "zod": "^3.25.76" } diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 470ffed..6dc8070 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -51,6 +51,8 @@ import { PromptsGetWithImageScenario } from './server/prompts'; +import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; + import { authScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; @@ -123,7 +125,10 @@ const allClientScenariosList: ClientScenario[] = [ new PromptsGetSimpleScenario(), new PromptsGetWithArgsScenario(), new PromptsGetEmbeddedResourceScenario(), - new PromptsGetWithImageScenario() + new PromptsGetWithImageScenario(), + + // Security scenarios + new DNSRebindingProtectionScenario() ]; // Active client scenarios (excludes pending) diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts new file mode 100644 index 0000000..2f21f8f --- /dev/null +++ b/src/scenarios/server/dns-rebinding.ts @@ -0,0 +1,238 @@ +/** + * DNS Rebinding Protection test scenarios for MCP servers + * + * Tests that localhost MCP servers properly validate Host or Origin headers + * to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details. + */ + +import { ClientScenario, ConformanceCheck } from '../../types'; +import { request } from 'undici'; + +const SPEC_REFERENCES = [ + { + id: 'MCP-DNS-Rebinding-Protection', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise' + }, + { + id: 'MCP-Transport-Security', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning' + } +]; + +/** + * Check if URL is a localhost URL + */ +function isLocalhostUrl(serverUrl: string): boolean { + const url = new URL(serverUrl); + const hostname = url.hostname.toLowerCase(); + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ); +} + +/** + * Get the host header value from a URL (hostname:port) + */ +function getHostFromUrl(serverUrl: string): string { + const url = new URL(serverUrl); + return url.host; // includes port if present +} + +/** + * Send an MCP initialize 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 }> { + const response = await request(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Host: hostOrOrigin, + Origin: `http://${hostOrOrigin}`, + Accept: 'application/json, text/event-stream' + }, + 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' } + } + }) + }); + + let body: unknown; + try { + body = await response.body.json(); + } catch { + body = null; + } + + return { + statusCode: response.statusCode, + body + }; +} + +export class DNSRebindingProtectionScenario implements ClientScenario { + name = 'dns-rebinding-protection'; + description = `Test DNS rebinding protection for localhost servers. + +**Scope:** This test applies to localhost MCP servers running without HTTPS and without +authentication. These servers are vulnerable to DNS rebinding attacks where a malicious +website tricks a user's browser into making requests to the local server. + +**Attack scenario:** +1. User visits malicious website (e.g., evil.com) +2. evil.com's DNS is configured to resolve to 127.0.0.1 +3. Browser makes request to evil.com which actually goes to localhost +4. Without Host/Origin header validation, the local MCP server processes the request + +**Requirements:** +- Server **MUST** validate the Host or Origin header on incoming requests +- Server **MUST** reject requests with non-localhost Host/Origin headers (HTTP 4xx) +- Server **MUST** accept requests with valid localhost Host/Origin headers + +**Valid localhost values:** \`localhost\`, \`127.0.0.1\`, \`[::1]\` (with optional port) + +**Note:** This test requires a localhost server URL. Non-localhost URLs will fail. + +See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const timestamp = new Date().toISOString(); + + // Common check properties + const rejectedCheckBase = { + id: 'localhost-host-rebinding-rejected', + name: 'DNSRebindingRejected', + description: + 'Server rejects requests with non-localhost Host/Origin headers', + timestamp, + specReferences: SPEC_REFERENCES + }; + + const acceptedCheckBase = { + id: 'localhost-host-valid-accepted', + name: 'LocalhostHostAccepted', + description: + 'Server accepts requests with valid localhost Host/Origin headers', + timestamp, + specReferences: SPEC_REFERENCES + }; + + // First check: Is this a localhost URL? + if (!isLocalhostUrl(serverUrl)) { + const errorMessage = + 'DNS rebinding tests require a localhost server URL (localhost, 127.0.0.1, or [::1])'; + const details = { serverUrl, reason: 'non-localhost-url' }; + + checks.push({ + ...rejectedCheckBase, + status: 'FAILURE', + errorMessage, + details + }); + checks.push({ + ...acceptedCheckBase, + status: 'FAILURE', + errorMessage, + details + }); + return checks; + } + + const validHost = getHostFromUrl(serverUrl); + const attackerHost = 'evil.example.com'; + + // Check 1: Invalid Host/Origin headers should be rejected with a 4xx error + try { + const response = await sendRequestWithHostAndOrigin( + serverUrl, + attackerHost + ); + const isRejected = + response.statusCode >= 400 && response.statusCode < 500; + + const details = { + hostHeader: attackerHost, + originHeader: `http://${attackerHost}`, + statusCode: response.statusCode, + body: response.body + }; + + if (isRejected) { + checks.push({ + ...rejectedCheckBase, + status: 'SUCCESS', + details + }); + } else { + checks.push({ + ...rejectedCheckBase, + status: 'FAILURE', + errorMessage: `Expected HTTP 4xx for invalid Host/Origin headers, got ${response.statusCode}`, + details + }); + } + } catch (error) { + checks.push({ + ...rejectedCheckBase, + status: 'FAILURE', + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + details: { + hostHeader: attackerHost, + originHeader: `http://${attackerHost}` + } + }); + } + + // Check 2: Valid localhost Host/Origin headers should be accepted (2xx response) + try { + const response = await sendRequestWithHostAndOrigin(serverUrl, validHost); + const isAccepted = + response.statusCode >= 200 && response.statusCode < 300; + + const details = { + hostHeader: validHost, + originHeader: `http://${validHost}`, + statusCode: response.statusCode, + body: response.body + }; + + if (isAccepted) { + checks.push({ + ...acceptedCheckBase, + status: 'SUCCESS', + details + }); + } else { + checks.push({ + ...acceptedCheckBase, + status: 'FAILURE', + errorMessage: `Expected HTTP 2xx for valid localhost Host/Origin headers, got ${response.statusCode}`, + details + }); + } + } catch (error) { + checks.push({ + ...acceptedCheckBase, + status: 'FAILURE', + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + details: { hostHeader: validHost, originHeader: `http://${validHost}` } + }); + } + + return checks; + } +} From 5dca74be9adc52def1168e1b8117f08428b00a7a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 22 Jan 2026 21:48:12 +0000 Subject: [PATCH 068/226] feat: add core and extensions suites for SDK tiering (#117) * feat: add core and extensions suites for SDK tiering Add new suites to support SDK tiering requirements: Client suites: - core (19 scenarios): Tier 1 requirements (base + auth) - extensions (2 scenarios): Optional protocol extensions (client credentials) - all (21 scenarios): Everything (core + extensions) - auth (15 scenarios): Auth scenarios only (excludes extensions) Server suites: - core: Alias for 'active' (tier 1 requirements) This allows SDK developers to run --suite core for tier 1 certification while keeping extension tests available but not blocking. * feat: promote elicitation tests from pending to active Move 3 elicitation scenarios to active suite: - elicitation-sep1330-enums - tools-call-elicitation - elicitation-sep1034-defaults These tests are now passing in the Python SDK and ready for SDK tiering requirements. Remaining pending tests (2): - json-schema-2020-12 (blocked on SDK PR #1135) - server-sse-polling (blocked on SDK PR #1129) --- src/index.ts | 15 +++++++++++---- src/scenarios/client/auth/index.ts | 7 ++++++- src/scenarios/index.ts | 30 ++++++++++++++++++++---------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7a6aa15..46ed8a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,9 @@ import { listActiveClientScenarios, listPendingClientScenarios, listAuthScenarios, - listMetadataScenarios + listMetadataScenarios, + listCoreScenarios, + listExtensionScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -65,6 +67,8 @@ program const suites: Record string[]> = { all: listScenarios, + core: listCoreScenarios, + extensions: listExtensionScenarios, auth: listAuthScenarios, metadata: listMetadataScenarios, 'sep-835': () => @@ -177,7 +181,9 @@ program console.error('Either --scenario or --suite is required'); console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); - console.error('\nAvailable suites: all, auth, metadata, sep-835'); + console.error( + '\nAvailable suites: all, core, extensions, auth, metadata, sep-835' + ); process.exit(1); } @@ -296,13 +302,14 @@ program if (suite === 'all') { scenarios = listClientScenarios(); - } else if (suite === 'active') { + } else if (suite === 'active' || suite === 'core') { + // 'core' is an alias for 'active' - tier 1 requirements scenarios = listActiveClientScenarios(); } else if (suite === 'pending') { scenarios = listPendingClientScenarios(); } else { console.error(`Unknown suite: ${suite}`); - console.error('Available suites: active, all, pending'); + console.error('Available suites: active, all, core, pending'); process.exit(1); } diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 6a3ad1c..805781a 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -22,6 +22,7 @@ import { ClientCredentialsBasicScenario } from './client-credentials'; +// Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ ...metadataScenarios, new AuthBasicCIMDScenario(), @@ -34,7 +35,11 @@ export const authScenariosList: Scenario[] = [ new ScopeRetryLimitScenario(), new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), - new PublicClientAuthScenario(), + new PublicClientAuthScenario() +]; + +// Extension scenarios (optional for tier 1 - protocol extensions) +export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario() ]; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 6dc8070..0c3e4aa 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -53,24 +53,16 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; -import { authScenariosList } from './client/auth/index'; +import { authScenariosList, extensionScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ - // Elicitation scenarios (SEP-1330) - new ElicitationEnumsScenario(), - // JSON Schema 2020-12 (SEP-1613) // This test is pending until the SDK includes PR #1135 which preserves // $schema, $defs, and additionalProperties fields in tool schemas. new JsonSchema2020_12Scenario(), - // On hold until elicitation schema types are fixed - // https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1863 - new ToolsCallElicitationScenario(), - new ElicitationDefaultsScenario(), - // On hold until server-side SSE improvements are made // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 new ServerSSEPollingScenario() @@ -145,8 +137,18 @@ export const clientScenarios = new Map( allClientScenariosList.map((scenario) => [scenario.name, scenario]) ); -// Scenario scenarios +// All client test scenarios (core + extensions) const scenariosList: Scenario[] = [ + new InitializeScenario(), + new ToolsCallScenario(), + new ElicitationClientDefaultsScenario(), + new SSERetryScenario(), + ...authScenariosList, + ...extensionScenariosList +]; + +// Core scenarios (tier 1 requirements) +const coreScenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), @@ -191,4 +193,12 @@ export function listAuthScenarios(): string[] { return authScenariosList.map((scenario) => scenario.name); } +export function listCoreScenarios(): string[] { + return coreScenariosList.map((scenario) => scenario.name); +} + +export function listExtensionScenarios(): string[] { + return extensionScenariosList.map((scenario) => scenario.name); +} + export { listMetadataScenarios }; From 245e44e882a987ca9767c0ec12c9ab2160fa5ca3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 11:43:56 +0000 Subject: [PATCH 069/226] feat: add pre-registration conformance test (#120) * feat: add pre-registration conformance test Add conformance test for OAuth pre-registration flow where the server does NOT support Dynamic Client Registration (DCR) and clients must use pre-configured static credentials. This addresses issue #34 (Client Registration Methods) - specifically the pre-registration section that was previously not covered. Changes: - Add disableDynamicRegistration option to createAuthServer helper - Add auth/pre-registration scenario with pre-registered client creds - Add MCP_PREREGISTRATION spec reference - Add context schema for pre-registration credentials - Add withOAuthRetryWithProvider helper for pre-configured providers - Add negative test client (auth-test-attempts-dcr.ts) that ignores pre-registered credentials and fails The scenario verifies that when a server does not advertise registration_endpoint in its OAuth metadata, compliant clients use pre-registered credentials passed via context instead of attempting DCR. Closes #34 * fix: add redirect_uris to saveClientInformation call * chore: remove unused WithOAuthRetryOptions interface --- .../typescript/auth-test-attempts-dcr.ts | 50 ++++++ .../clients/typescript/everything-client.ts | 70 +++++++- .../typescript/helpers/withOAuthRetry.ts | 17 +- .../client/auth/helpers/createAuthServer.ts | 7 +- src/scenarios/client/auth/index.ts | 4 +- src/scenarios/client/auth/pre-registration.ts | 151 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 4 + src/schemas/context.ts | 5 + 8 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 examples/clients/typescript/auth-test-attempts-dcr.ts create mode 100644 src/scenarios/client/auth/pre-registration.ts diff --git a/examples/clients/typescript/auth-test-attempts-dcr.ts b/examples/clients/typescript/auth-test-attempts-dcr.ts new file mode 100644 index 0000000..f9a6510 --- /dev/null +++ b/examples/clients/typescript/auth-test-attempts-dcr.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { withOAuthRetry } from './helpers/withOAuthRetry'; +import { runAsCli } from './helpers/cliRunner'; +import { logger } from './helpers/logger'; + +/** + * Non-compliant client that ignores pre-registered credentials and attempts DCR. + * + * This client intentionally ignores the client_id and client_secret passed via + * MCP_CONFORMANCE_CONTEXT and instead attempts to do Dynamic Client Registration. + * When run against a server that does not support DCR (no registration_endpoint), + * this client will fail. + * + * Used to test that conformance checks detect clients that don't properly + * use pre-registered credentials when server doesn't support DCR. + */ +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-attempts-dcr', version: '1.0.0' }, + { capabilities: {} } + ); + + // Non-compliant: ignores pre-registered credentials from context + // and creates a fresh provider that will attempt DCR + const oauthFetch = withOAuthRetry( + 'test-auth-client-attempts-dcr', + new URL(serverUrl) + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Connected to MCP server (attempted DCR instead of pre-reg)'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +runAsCli(runClient, import.meta.url, 'auth-test-attempts-dcr '); diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 4491fe6..3e6a07a 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -21,7 +21,12 @@ import { } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; -import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js'; +import { + withOAuthRetry, + withOAuthRetryWithProvider, + handle401 +} from './helpers/withOAuthRetry.js'; +import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; import { logger } from './helpers/logger.js'; /** @@ -300,6 +305,69 @@ export async function runClientCredentialsBasic( registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// Pre-registration scenario +// ============================================================================ + +/** + * Pre-registration: client uses pre-registered credentials (no DCR). + * + * Server does not advertise registration_endpoint, so client must use + * pre-configured client_id and client_secret passed via context. + */ +export async function runPreRegistration(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/pre-registration') { + throw new Error(`Expected pre-registration context, got ${ctx.name}`); + } + + const client = new Client( + { name: 'conformance-pre-registration', version: '1.0.0' }, + { capabilities: {} } + ); + + // Create provider with pre-registered credentials + const provider = new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: 'conformance-pre-registration', + redirect_uris: ['http://localhost:3000/callback'] + } + ); + + // Pre-set the client information so the SDK won't attempt DCR + provider.saveClientInformation({ + client_id: ctx.client_id, + client_secret: ctx.client_secret, + redirect_uris: ['http://localhost:3000/callback'] + }); + + // Use the provider-based middleware + const oauthFetch = withOAuthRetryWithProvider( + provider, + new URL(serverUrl), + handle401 + )(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected with pre-registered credentials'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/pre-registration', runPreRegistration); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index 497f4a4..429ca53 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -45,6 +45,7 @@ export const handle401 = async ( } } }; + /** * Creates a fetch wrapper that handles OAuth authentication with retry logic. * @@ -53,8 +54,10 @@ export const handle401 = async ( * - Does not throw UnauthorizedError on redirect, but instead retries * - Calls next() instead of throwing for redirect-based auth * - * @param provider - OAuth client provider for authentication + * @param clientName - Client name for OAuth registration * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @param handle401Fn - Custom 401 handler function + * @param clientMetadataUrl - Optional CIMD URL for URL-based client IDs * @returns A fetch middleware function */ export const withOAuthRetry = ( @@ -71,6 +74,18 @@ export const withOAuthRetry = ( }, clientMetadataUrl ); + return withOAuthRetryWithProvider(provider, baseUrl, handle401Fn); +}; + +/** + * Creates a fetch wrapper using a pre-configured OAuth provider. + * Use this when you need to pre-set client credentials (e.g., for pre-registration tests). + */ +export const withOAuthRetryWithProvider = ( + provider: ConformanceOAuthProvider, + baseUrl?: string | URL, + handle401Fn: typeof handle401 = handle401 +): Middleware => { return (next: FetchLike) => { return async ( input: string | URL, diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 1071828..9d06cfa 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -25,6 +25,8 @@ export interface AuthServerOptions { tokenEndpointAuthMethodsSupported?: string[]; tokenEndpointAuthSigningAlgValuesSupported?: string[]; clientIdMetadataDocumentSupported?: boolean; + /** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */ + disableDynamicRegistration?: boolean; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -65,6 +67,7 @@ export function createAuthServer( tokenEndpointAuthMethodsSupported = ['none'], tokenEndpointAuthSigningAlgValuesSupported, clientIdMetadataDocumentSupported, + disableDynamicRegistration = false, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -114,7 +117,9 @@ export function createAuthServer( issuer: getAuthBaseUrl(), authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, - registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`, + ...(!disableDynamicRegistration && { + registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}` + }), response_types_supported: ['code'], grant_types_supported: grantTypesSupported, code_challenge_methods_supported: ['S256'], diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 805781a..91da1ef 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -21,6 +21,7 @@ import { ClientCredentialsJwtScenario, ClientCredentialsBasicScenario } from './client-credentials'; +import { PreRegistrationScenario } from './pre-registration'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -35,7 +36,8 @@ export const authScenariosList: Scenario[] = [ new ScopeRetryLimitScenario(), new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), - new PublicClientAuthScenario() + new PublicClientAuthScenario(), + new PreRegistrationScenario() ]; // Extension scenarios (optional for tier 1 - protocol extensions) diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts new file mode 100644 index 0000000..0d95e33 --- /dev/null +++ b/src/scenarios/client/auth/pre-registration.ts @@ -0,0 +1,151 @@ +import type { Scenario, ConformanceCheck, 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'; + +const PRE_REGISTERED_CLIENT_ID = 'pre-registered-client'; +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. + * + * 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'; + description = + 'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + disableDynamicRegistration: true, + tokenEndpointAuthMethodsSupported: ['client_secret_basic'], + onTokenRequest: ({ authorizationHeader, timestamp }) => { + // Verify client used pre-registered credentials via Basic auth + if (!authorizationHeader?.startsWith('Basic ')) { + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: + 'Client did not use Basic authentication with pre-registered credentials', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PREREGISTRATION] + }); + return { + error: 'invalid_client', + errorDescription: 'Missing or invalid Authorization header', + statusCode: 401 + }; + } + + const base64Credentials = authorizationHeader.slice(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString( + 'utf-8' + ); + const [clientId, clientSecret] = credentials.split(':'); + + if ( + clientId !== PRE_REGISTERED_CLIENT_ID || + clientSecret !== PRE_REGISTERED_CLIENT_SECRET + ) { + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: `Client used incorrect pre-registered credentials. Expected client_id '${PRE_REGISTERED_CLIENT_ID}', got '${clientId}'`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PREREGISTRATION], + details: { + expectedClientId: PRE_REGISTERED_CLIENT_ID, + actualClientId: clientId + } + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid pre-registered credentials', + statusCode: 401 + }; + } + + // Success - client used correct pre-registered credentials + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: + 'Client correctly used pre-registered credentials when server does not support DCR', + status: 'SUCCESS', + timestamp, + specReferences: [SpecReferences.MCP_PREREGISTRATION], + details: { clientId } + }); + + return { + token: `test-token-prereg-${Date.now()}`, + scopes: [] + }; + } + }); + + await this.authServer.start(authApp); + + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier + } + ); + + await this.server.start(app); + + return { + serverUrl: `${this.server.getUrl()}/mcp`, + context: { + client_id: PRE_REGISTERED_CLIENT_ID, + client_secret: PRE_REGISTERED_CLIENT_SECRET + } + }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + // Ensure we have the pre-registration check + const hasPreRegCheck = this.checks.some( + (c) => c.id === 'pre-registration-auth' + ); + if (!hasPreRegCheck) { + this.checks.push({ + id: 'pre-registration-auth', + name: 'Pre-registration authentication', + description: 'Client did not make a token request', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SpecReferences.MCP_PREREGISTRATION] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 52a08ca..131a60f 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -72,5 +72,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { SEP_1046_CLIENT_CREDENTIALS: { id: 'SEP-1046-Client-Credentials', url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' + }, + MCP_PREREGISTRATION: { + id: 'MCP-Preregistration', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration' } }; diff --git a/src/schemas/context.ts b/src/schemas/context.ts index d7ea2a2..2a8a907 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -17,6 +17,11 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/client-credentials-basic'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/pre-registration'), + client_id: z.string(), + client_secret: z.string() }) ]); From 2d52de592452c3d915b3888c7f13b59790109a43 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 12:50:11 +0000 Subject: [PATCH 070/226] docs: add SDK integration guide and fix spec references (#121) * docs: add SDK integration guide and fix spec references - Add SDK_INTEGRATION.md with guide for SDK maintainers on integrating conformance tests - Link to guide from README.md - Update spec references to use 2025-11-25 instead of draft URLs * fix: prettier formatting and correct action version to v0.1.10 --- README.md | 2 + SDK_INTEGRATION.md | 208 +++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 16 +- 3 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 SDK_INTEGRATION.md diff --git a/README.md b/README.md index bbc3c8c..da649d5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ A framework for testing MCP (Model Context Protocol) client and server implement > [!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 ### Testing Clients diff --git a/SDK_INTEGRATION.md b/SDK_INTEGRATION.md new file mode 100644 index 0000000..a092115 --- /dev/null +++ b/SDK_INTEGRATION.md @@ -0,0 +1,208 @@ +# Using MCP Conformance Tests in SDK Repositories + +This guide explains how to integrate the MCP conformance test suite into your language SDK repository. The conformance framework tests your MCP implementation against the protocol specification to ensure compatibility. + +## Quick Start + +Install and run conformance tests: + +```bash +# Client testing (framework starts a test server, runs your client against it) +npx @modelcontextprotocol/conformance client --command "your-client-command" --scenario initialize + +# Server testing (your server must already be running) +npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp --scenario server-initialize +``` + +## Two Testing Modes + +### Client Testing + +The framework **starts a test server** and spawns your client against it. Your client receives the server URL as its final command-line argument. + +```bash +# Run a single scenario +npx @modelcontextprotocol/conformance client \ + --command "python tests/conformance/client.py" \ + --scenario initialize + +# Run a suite of tests +npx @modelcontextprotocol/conformance client \ + --command "python tests/conformance/client.py" \ + --suite auth +``` + +**Available client suites:** `all`, `core`, `extensions`, `auth`, `metadata`, `sep-835` + +Your client should: + +1. Accept the server URL as its last argument +2. Read `MCP_CONFORMANCE_SCENARIO` env var to determine which scenario is being tested +3. Read `MCP_CONFORMANCE_CONTEXT` env var for scenario-specific data (e.g., OAuth credentials) + +### Server Testing + +Your server must be **running before** invoking the conformance tool. The framework connects to it as an MCP client. + +```bash +# Start your server first +your-server --port 3001 & + +# Then run conformance tests +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3001/mcp \ + --suite active +``` + +**Available server suites:** `active` (default), `all`, `pending` + +**Note:** Server testing requires you to manage server lifecycle (start, health-check, cleanup) yourself. + +--- + +## Expected Failures (Baseline) File + +The expected-failures feature lets your CI pass while you work on fixing known issues. It catches regressions by failing when: + +- A previously passing test starts failing (regression) +- A previously failing test starts passing (stale baseline - remove the entry) + +### File Format + +Create a YAML file (e.g., `conformance-baseline.yml`): + +```yaml +server: + - tools-call-with-progress + - resources-subscribe +client: + - auth/client-credentials-jwt +``` + +### Usage + +```bash +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3000/mcp \ + --expected-failures ./conformance-baseline.yml +``` + +### Exit Code Behavior + +| Scenario Result | In Baseline? | Exit Code | Meaning | +| --------------- | ------------ | --------- | ----------------------------- | +| Fails | Yes | 0 | Expected failure | +| Fails | No | 1 | Unexpected regression | +| Passes | Yes | 1 | Stale baseline - remove entry | +| Passes | No | 0 | Normal pass | + +--- + +## GitHub Action + +The conformance repo provides a reusable GitHub Action that handles Node.js setup and conformance execution. + +### Client Testing Example + +```yaml +name: Conformance Tests +on: [push, pull_request] + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up your SDK + run: | + # Your SDK setup (pip install, npm install, etc.) + pip install -e . + + - uses: modelcontextprotocol/conformance@v0.1.10 + with: + mode: client + command: 'python tests/conformance/client.py' + suite: auth + expected-failures: ./conformance-baseline.yml +``` + +### Server Testing Example + +```yaml +name: Conformance Tests +on: [push, pull_request] + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up and start server + run: | + pip install -e . + python -m myserver --port 3001 & + # Wait for server to be ready + timeout 15 bash -c 'until curl -s http://localhost:3001/mcp; do sleep 0.5; done' + + - uses: modelcontextprotocol/conformance@v0.1.10 + with: + mode: server + url: http://localhost:3001/mcp + suite: active + expected-failures: ./conformance-baseline.yml +``` + +### Action Inputs + +| Input | Required | Description | +| ------------------- | ----------- | ----------------------------------------------- | +| `mode` | Yes | `server` or `client` | +| `url` | Server mode | URL of the server to test | +| `command` | Client mode | Command to run the client | +| `expected-failures` | No | Path to YAML baseline file | +| `suite` | No | Test suite to run | +| `scenario` | No | Run a single scenario by name | +| `timeout` | No | Timeout in ms for client tests (default: 30000) | +| `verbose` | No | Show verbose output (default: false) | +| `node-version` | No | Node.js version (default: 20) | + +--- + +## Writing Conformance Clients/Servers + +### Example Client Pattern + +See [`src/conformance/everything-client.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-client.ts) in the TypeScript SDK for a reference implementation. The recommended pattern is a single client that routes behavior based on the scenario: + +```python +import os +import sys +import json + +def main(): + server_url = sys.argv[-1] # URL passed as last argument + scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO", "") + context = json.loads(os.environ.get("MCP_CONFORMANCE_CONTEXT", "{}")) + + if scenario.startswith("auth/"): + run_auth_scenario(server_url, scenario, context) + else: + run_default_scenario(server_url) + +if __name__ == "__main__": + main() +``` + +### Example Server Pattern + +See [`src/conformance/everything-server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-server.ts) in the TypeScript SDK for a reference implementation that handles all server scenarios. + +--- + +## Additional Resources + +- [Conformance README](./README.md) +- [Design documentation](./src/runner/DESIGN.md) +- [TypeScript SDK conformance examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/src/conformance) diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 131a60f..9eda138 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -19,15 +19,15 @@ export const SpecReferences: { [key: string]: SpecReference } = { }, MCP_PRM_DISCOVERY: { id: 'MCP-2025-06-18-PRM-discovery', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#protected-resource-metadata-discovery-requirements' }, MCP_AUTH_DISCOVERY: { id: 'MCP-Authorization-metadata-discovery', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery' }, MCP_DCR: { id: 'MCP-Dynamic-client-registration', - url: 'https://modelcontextprotocol.io/specification/draft/basic/client#dynamic-client-registration' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/client#dynamic-client-registration' }, OAUTH_2_1_AUTHORIZATION_ENDPOINT: { id: 'OAUTH-2.1-authorization-endpoint', @@ -39,23 +39,23 @@ export const SpecReferences: { [key: string]: SpecReference } = { }, MCP_ACCESS_TOKEN_USAGE: { id: 'MCP-Access-token-usage', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage' }, MCP_SCOPE_SELECTION_STRATEGY: { id: 'MCP-Scope-selection-strategy', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-selection-strategy' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy' }, MCP_SCOPE_CHALLENGE_HANDLING: { id: 'MCP-Scope-challenge-handling', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#scope-challenge-handling' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-challenge-handling' }, MCP_AUTH_ERROR_HANDLING: { id: 'MCP-Auth-error-handling', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling' }, MCP_CLIENT_ID_METADATA_DOCUMENTS: { id: 'MCP-Client-ID-Metadata-Documents', - url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#client-id-metadata-documents' }, IETF_CIMD: { id: 'IETF-OAuth-Client-ID-Metadata-Document', From 4a2ba8ae7b8ef5695bcb156391165534f19d71d2 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 18:12:44 +0000 Subject: [PATCH 071/226] feat: add PKCE conformance tests (#119) * feat: add PKCE conformance tests Adds conformance checks for PKCE requirements from the MCP auth spec: - pkce-code-challenge-sent: Verifies client sends code_challenge in auth request - pkce-s256-method-used: Verifies client uses S256 code challenge method - pkce-code-verifier-sent: Verifies client sends code_verifier in token request - pkce-verifier-matches-challenge: Validates BASE64URL(SHA256(verifier)) = challenge Also adds auth/pkce-no-s256-support scenario that tests clients correctly refuse when S256 is not in code_challenge_methods_supported. All PKCE checks are added to the createAuthServer helper, so they automatically apply to all existing auth scenarios. Fixes #77 * feat: add bad client example that skips PKCE Adds auth-test-no-pkce.ts which implements a custom OAuth flow that deliberately skips PKCE (no code_challenge in auth request, no code_verifier in token request). Also adds negative test to verify the conformance suite correctly detects this non-compliant behavior. * chore: skip pkce-no-s256-support scenario for now * chore: fix formatting * refactor: remove pkce-no-s256-support scenario and always validate PKCE - Remove pkce-no-s256-support scenario (was skipped anyway) - Make pkce-verifier-matches-challenge fail if either code_challenge or code_verifier is missing, rather than only running when both present - Update negative test to expect the new failure --- .../clients/typescript/auth-test-no-pkce.ts | 173 ++++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 114 +++++++++++- src/scenarios/client/auth/index.test.ts | 13 ++ src/scenarios/client/auth/spec-references.ts | 4 + 4 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 examples/clients/typescript/auth-test-no-pkce.ts diff --git a/examples/clients/typescript/auth-test-no-pkce.ts b/examples/clients/typescript/auth-test-no-pkce.ts new file mode 100644 index 0000000..308351d --- /dev/null +++ b/examples/clients/typescript/auth-test-no-pkce.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +/** + * Broken client that doesn't use PKCE. + * + * BUG: Skips PKCE entirely - doesn't send code_challenge in authorization + * request and doesn't send code_verifier in token request. + * + * Per MCP spec: "MCP clients MUST implement PKCE according to OAuth 2.1" + */ + +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; +} + +/** + * Custom OAuth flow that deliberately skips PKCE. + * This is intentionally broken behavior for conformance testing. + */ +async function oauthFlowWithoutPkce( + _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(); + + // 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-no-pkce', + redirect_uris: ['http://localhost:3000/callback'] + }) + }); + if (!dcrResponse.ok) { + throw new Error(`DCR failed: ${dcrResponse.status}`); + } + const clientInfo = await dcrResponse.json(); + + // 4. Build authorization URL WITHOUT PKCE (BUG!) + 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'); + // BUG: NOT setting code_challenge or code_challenge_method + + // 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. Exchange code for token WITHOUT code_verifier (BUG!) + 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 + // BUG: NOT sending code_verifier + }).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 without PKCE. + */ +function withOAuthNoPkce(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 oauthFlowWithoutPkce(baseUrl, resourceMetadataUrl, next); + response = await makeRequest(); + } + + return response; + }; + }; +} + +export async function runClient(serverUrl: string): Promise { + const client = new Client( + { name: 'test-auth-client-no-pkce', version: '1.0.0' }, + { capabilities: {} } + ); + + const oauthFetch = withOAuthNoPkce(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-no-pkce '); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 9d06cfa..af6fe42 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,9 +1,23 @@ import express, { Request, Response } from 'express'; +import { createHash } from 'crypto'; import type { ConformanceCheck } from '../../../../types'; import { createRequestLogger } from '../../../request-logger'; import { SpecReferences } from '../spec-references'; import { MockTokenVerifier } from './mockTokenVerifier'; +/** + * Compute S256 code challenge from a code verifier. + * BASE64URL(SHA256(code_verifier)) + */ +function computeS256Challenge(codeVerifier: string): string { + const hash = createHash('sha256').update(codeVerifier).digest(); + return hash + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + export interface TokenRequestResult { token: string; scopes: string[]; @@ -27,6 +41,8 @@ export interface AuthServerOptions { clientIdMetadataDocumentSupported?: boolean; /** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */ disableDynamicRegistration?: boolean; + /** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */ + codeChallengeMethodsSupported?: string[] | null; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -68,6 +84,7 @@ export function createAuthServer( tokenEndpointAuthSigningAlgValuesSupported, clientIdMetadataDocumentSupported, disableDynamicRegistration = false, + codeChallengeMethodsSupported = ['S256'], tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -76,6 +93,8 @@ export function createAuthServer( // Track scopes from the most recent authorization request let lastAuthorizationScopes: string[] = []; + // Track PKCE code_challenge for verification in token request + let storedCodeChallenge: string | undefined; const authRoutes = { authorization_endpoint: `${routePrefix}/authorize`, @@ -122,7 +141,10 @@ export function createAuthServer( }), response_types_supported: ['code'], grant_types_supported: grantTypesSupported, - code_challenge_methods_supported: ['S256'], + // PKCE support - null means omit from metadata (for negative testing) + ...(codeChallengeMethodsSupported !== null && { + code_challenge_methods_supported: codeChallengeMethodsSupported + }), token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported, ...(tokenEndpointAuthSigningAlgValuesSupported && { token_endpoint_auth_signing_alg_values_supported: @@ -165,6 +187,41 @@ export function createAuthServer( } }); + // PKCE: Store code_challenge for later verification + const codeChallenge = req.query.code_challenge as string | undefined; + const codeChallengeMethod = req.query.code_challenge_method as + | string + | undefined; + storedCodeChallenge = codeChallenge; + + // PKCE: Check code_challenge is present + checks.push({ + id: 'pkce-code-challenge-sent', + name: 'PKCE Code Challenge', + description: codeChallenge + ? 'Client sent code_challenge in authorization request' + : 'Client MUST send code_challenge in authorization request', + status: codeChallenge ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PKCE] + }); + + // PKCE: Check S256 method is used + checks.push({ + id: 'pkce-s256-method-used', + name: 'PKCE S256 Method', + description: + codeChallengeMethod === 'S256' + ? 'Client used S256 code challenge method' + : 'Client MUST use S256 code challenge method when technically capable', + status: codeChallengeMethod === 'S256' ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PKCE], + details: { + method: codeChallengeMethod || 'not specified' + } + }); + // Track scopes from authorization request for token issuance const scopeParam = req.query.scope as string | undefined; lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : []; @@ -206,6 +263,61 @@ export function createAuthServer( } }); + // PKCE: Check code_verifier is present (only for authorization_code grant) + const codeVerifier = req.body.code_verifier as string | undefined; + if (grantType === 'authorization_code') { + checks.push({ + id: 'pkce-code-verifier-sent', + name: 'PKCE Code Verifier', + description: codeVerifier + ? 'Client sent code_verifier in token request' + : 'Client MUST send code_verifier in token request', + status: codeVerifier ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PKCE] + }); + + // PKCE: Validate code_verifier matches code_challenge (S256) + // Fail if either is missing + const computedChallenge = + codeVerifier && storedCodeChallenge + ? computeS256Challenge(codeVerifier) + : undefined; + const matches = + computedChallenge !== undefined && + computedChallenge === storedCodeChallenge; + + let description: string; + if (!storedCodeChallenge && !codeVerifier) { + description = + 'Neither code_challenge nor code_verifier were sent - PKCE is required'; + } else if (!storedCodeChallenge) { + description = + 'code_challenge was not sent in authorization request - PKCE is required'; + } else if (!codeVerifier) { + description = + 'code_verifier was not sent in token request - PKCE is required'; + } else if (matches) { + description = 'code_verifier correctly matches code_challenge (S256)'; + } else { + description = 'code_verifier does not match code_challenge'; + } + + checks.push({ + id: 'pkce-verifier-matches-challenge', + name: 'PKCE Verifier Validation', + description, + status: matches ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: [SpecReferences.MCP_PKCE], + details: { + matches, + storedChallenge: storedCodeChallenge || 'not sent', + computedChallenge: computedChallenge || 'not computed' + } + }); + } + let token = `test-token-${Date.now()}`; let scopes: string[] = lastAuthorizationScopes; diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 6dcc020..bda5d40 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -9,6 +9,7 @@ import { runClient as ignoreScopeClient } from '../../../../examples/clients/typ 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 { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -99,4 +100,16 @@ describe('Negative tests', () => { allowClientError: true }); }); + + test('client does not use PKCE', async () => { + const runner = new InlineClientRunner(noPkceClient); + await runClientAgainstScenario(runner, 'auth/metadata-default', { + expectedFailureSlugs: [ + 'pkce-code-challenge-sent', + 'pkce-s256-method-used', + 'pkce-code-verifier-sent', + 'pkce-verifier-matches-challenge' + ] + }); + }); }); diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 9eda138..a8800cd 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -76,5 +76,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { MCP_PREREGISTRATION: { id: 'MCP-Preregistration', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration' + }, + MCP_PKCE: { + id: 'MCP-PKCE-requirement', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection' } }; From 67ad3580197622e00bb4c56bfd47f98dcfce07f5 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 18:21:46 +0000 Subject: [PATCH 072/226] feat: add resource parameter validation tests (RFC 8707) (#118) * feat: add resource parameter validation tests (RFC 8707) Adds conformance tests for OAuth Resource Indicators (RFC 8707) implementation: 1. Resource parameter checks added to token-endpoint-auth-basic scenario: - resource-parameter-in-authorization: Verify resource in auth request - resource-parameter-in-token: Verify resource in token request - resource-parameter-valid-uri: Verify valid canonical URI - resource-parameter-consistency: Verify consistency between requests 2. New auth/resource-mismatch scenario: - Tests that client rejects when PRM resource doesn't match server URL - Server returns mismatched resource in PRM - Test passes if client does NOT proceed with authorization Also adds spec references for RFC 8707 and MCP resource parameter spec. Closes #33 * fix: make resource consistency check a FAILURE and remove dead code - Change resource parameter consistency from WARNING to FAILURE - Remove unreachable protocol check in validateCanonicalUri (URL constructor already validates scheme presence) --- .../clients/typescript/everything-client.ts | 4 +- .../client/auth/helpers/createAuthServer.ts | 2 + .../client/auth/helpers/createServer.ts | 11 +- src/scenarios/client/auth/index.test.ts | 4 +- src/scenarios/client/auth/index.ts | 2 + .../client/auth/resource-mismatch.ts | 112 +++++++++++++++ src/scenarios/client/auth/spec-references.ts | 8 ++ .../client/auth/token-endpoint-auth.ts | 127 +++++++++++++++++- 8 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 src/scenarios/client/auth/resource-mismatch.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 3e6a07a..93fd142 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -144,7 +144,9 @@ registerScenarios( // Token endpoint auth method scenarios 'auth/token-endpoint-auth-basic', 'auth/token-endpoint-auth-post', - 'auth/token-endpoint-auth-none' + 'auth/token-endpoint-auth-none', + // Resource mismatch (client should error when PRM resource doesn't match) + 'auth/resource-mismatch' ], runAuthClient ); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index af6fe42..8621490 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -59,6 +59,7 @@ export interface AuthServerOptions { onAuthorizationRequest?: (requestData: { clientId?: string; scope?: string; + resource?: string; timestamp: string; }) => void; onRegistrationRequest?: (req: Request) => { @@ -230,6 +231,7 @@ export function createAuthServer( onAuthorizationRequest({ clientId: req.query.client_id as string | undefined, scope: scopeParam, + resource: req.query.resource as string | undefined, timestamp }); } diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index de230c8..35b89b1 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -22,6 +22,8 @@ export interface ServerOptions { includeScopeInWwwAuth?: boolean; authMiddleware?: express.RequestHandler; tokenVerifier?: MockTokenVerifier; + /** Override the resource field in PRM response (for testing resource mismatch) */ + prmResourceOverride?: string; } export function createServer( @@ -36,7 +38,8 @@ export function createServer( scopesSupported, includePrmInWwwAuth = true, includeScopeInWwwAuth = false, - tokenVerifier + tokenVerifier, + prmResourceOverride } = options; const server = new Server( { @@ -107,10 +110,12 @@ export function createServer( // Resource is usually $baseUrl/mcp, but if PRM is at the root, // the resource identifier is the root. + // Can be overridden via prmResourceOverride for testing resource mismatch. const resource = - prmPath === '/.well-known/oauth-protected-resource' + prmResourceOverride ?? + (prmPath === '/.well-known/oauth-protected-resource' ? getBaseUrl() - : `${getBaseUrl()}/mcp`; + : `${getBaseUrl()}/mcp`); const prmResponse: any = { resource, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index bda5d40..daef5ea 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -23,7 +23,9 @@ const skipScenarios = new Set([ const allowClientErrorScenarios = new Set([ // Client is expected to give up (error) after limited retries, but check should pass - 'auth/scope-retry-limit' + 'auth/scope-retry-limit', + // Client is expected to error when PRM resource doesn't match server URL + 'auth/resource-mismatch' ]); describe('Client Auth Scenarios', () => { diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 91da1ef..115ded5 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -21,6 +21,7 @@ import { ClientCredentialsJwtScenario, ClientCredentialsBasicScenario } from './client-credentials'; +import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; // Auth scenarios (required for tier 1) @@ -37,6 +38,7 @@ export const authScenariosList: Scenario[] = [ new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), new PublicClientAuthScenario(), + new ResourceMismatchScenario(), new PreRegistrationScenario() ]; diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts new file mode 100644 index 0000000..4783bc8 --- /dev/null +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -0,0 +1,112 @@ +import type { Scenario, ConformanceCheck } 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'; + +/** + * Scenario: Resource Mismatch Detection + * + * Tests that clients correctly detect and reject when the Protected Resource + * Metadata returns a `resource` field that doesn't match the server URL + * the client is trying to access. + * + * Per RFC 8707 and MCP spec, clients MUST validate that the resource from + * PRM matches the expected server before proceeding with authorization. + * + * Setup: + * - Server returns PRM with resource: "https://evil.example.com/mcp" (different origin) + * - Client is trying to access the actual server at localhost:/mcp + * + * Expected behavior: + * - Client should NOT proceed with authorization + * - Client should abort due to resource mismatch + * - Test passes if client does NOT complete the auth flow (no authorization request) + */ +export class ResourceMismatchScenario implements Scenario { + name = 'auth/resource-mismatch'; + description = + 'Tests that client rejects when PRM resource does not match server URL'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private authorizationRequestMade = false; + + async start(): Promise { + this.checks = []; + this.authorizationRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + tokenEndpointAuthMethodsSupported: ['none'], + onAuthorizationRequest: () => { + // If we get here, the client incorrectly proceeded with auth + this.authorizationRequestMade = true; + }, + onRegistrationRequest: () => ({ + clientId: `test-client-${Date.now()}`, + clientSecret: undefined, + tokenEndpointAuthMethod: 'none' + }) + }); + await this.authServer.start(authApp); + + // Create server that returns a mismatched resource in PRM + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier, + // Return a different origin in PRM - this should be rejected by the client + prmResourceOverride: 'https://evil.example.com/mcp' + } + ); + 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(); + const specRefs = [ + SpecReferences.RFC_8707_RESOURCE_INDICATORS, + SpecReferences.MCP_RESOURCE_PARAMETER + ]; + + // The test passes if the client did NOT make an authorization request + // (meaning it correctly rejected the mismatched resource) + 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 + } + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index a8800cd..a24e987 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -73,6 +73,14 @@ export const SpecReferences: { [key: string]: SpecReference } = { id: 'SEP-1046-Client-Credentials', url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' }, + RFC_8707_RESOURCE_INDICATORS: { + id: 'RFC-8707-Resource-Indicators', + url: 'https://www.rfc-editor.org/rfc/rfc8707.html' + }, + MCP_RESOURCE_PARAMETER: { + id: 'MCP-Resource-Parameter-Implementation', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#resource-parameter-implementation' + }, MCP_PREREGISTRATION: { id: 'MCP-Preregistration', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration' diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 4203789..80b80f7 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -51,6 +51,10 @@ class TokenEndpointAuthScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + // Track resource parameters for RFC 8707 validation + private authorizationResource?: string; + private tokenResource?: string; + constructor(expectedAuthMethod: AuthMethod) { this.expectedAuthMethod = expectedAuthMethod; this.name = `auth/token-endpoint-auth-${expectedAuthMethod === 'client_secret_basic' ? 'basic' : expectedAuthMethod === 'client_secret_post' ? 'post' : 'none'}`; @@ -59,12 +63,19 @@ class TokenEndpointAuthScenario implements Scenario { async start(): Promise { this.checks = []; + this.authorizationResource = undefined; + this.tokenResource = undefined; const tokenVerifier = new MockTokenVerifier(this.checks, []); const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], + onAuthorizationRequest: ({ resource }) => { + this.authorizationResource = resource; + }, onTokenRequest: ({ authorizationHeader, body, timestamp }) => { + // Track resource from token request for RFC 8707 validation + this.tokenResource = body.resource; const bodyClientSecret = body.client_secret; const actualMethod = detectAuthMethod( authorizationHeader, @@ -145,18 +156,132 @@ class TokenEndpointAuthScenario implements Scenario { } getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) { this.checks.push({ id: 'token-endpoint-auth-method', name: 'Token endpoint authentication method', description: 'Client did not make a token request', status: 'FAILURE', - timestamp: new Date().toISOString(), + timestamp, specReferences: [SpecReferences.OAUTH_2_1_TOKEN] }); } + + // RFC 8707 Resource Parameter Validation Checks + this.addResourceParameterChecks(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 { From c2f3fdaf781dcd5a862cb0d2f6454c1c210bf0f0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 18:33:26 +0000 Subject: [PATCH 073/226] 0.1.11 (#122) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c790e4d..026f973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.10", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.10", + "version": "0.1.11", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/package.json b/package.json index 861a257..36b855b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.10", + "version": "0.1.11", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 0064ea0c9530dd794cd13bb88636fde639fbe812 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Mon, 26 Jan 2026 12:31:54 -0600 Subject: [PATCH 074/226] Add allowClientError to Scenario interface for expected client failures (#123) * Add allowClientError to Scenario interface for expected client failures * Apply suggestion from @pcarleton Co-authored-by: Paul Carleton --------- Co-authored-by: Paul Carleton --- src/index.ts | 3 ++- src/runner/client.ts | 14 ++++++++++---- src/scenarios/client/auth/resource-mismatch.ts | 1 + src/scenarios/client/auth/scope-handling.ts | 1 + src/types.ts | 5 +++++ 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 46ed8a5..039b042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -207,7 +207,8 @@ program const { overallFailure } = printClientResults( result.checks, verbose, - result.clientOutput + result.clientOutput, + result.allowClientError ); if (options.expectedFailures) { diff --git a/src/runner/client.ts b/src/runner/client.ts index 27a8b6e..4525416 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -97,6 +97,7 @@ export async function runConformanceTest( checks: ConformanceCheck[]; clientOutput: ClientExecutionResult; resultDir?: string; + allowClientError?: boolean; }> { let resultDir: string | undefined; @@ -164,7 +165,8 @@ export async function runConformanceTest( return { checks, clientOutput, - resultDir + resultDir, + allowClientError: scenario.allowClientError }; } finally { await scenario.stop(); @@ -174,7 +176,8 @@ export async function runConformanceTest( export function printClientResults( checks: ConformanceCheck[], verbose: boolean = false, - clientOutput?: ClientExecutionResult + clientOutput?: ClientExecutionResult, + allowClientError: boolean = false ): { passed: number; failed: number; @@ -195,7 +198,10 @@ export function printClientResults( ? clientOutput.exitCode !== 0 : false; const overallFailure = - failed > 0 || warnings > 0 || clientTimedOut || clientExitedWithError; + failed > 0 || + warnings > 0 || + clientTimedOut || + (clientExitedWithError && !allowClientError); if (verbose) { // Verbose mode: JSON goes to stdout for piping to jq/jless @@ -215,7 +221,7 @@ export function printClientResults( console.error(`\n⚠️ CLIENT TIMED OUT - Test incomplete`); } - if (clientExitedWithError && !clientTimedOut) { + if (clientExitedWithError && !clientTimedOut && !allowClientError) { console.error( `\n⚠️ CLIENT EXITED WITH ERROR (code ${clientOutput?.exitCode}) - Test may be incomplete` ); diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index 4783bc8..b38968f 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -29,6 +29,7 @@ export class ResourceMismatchScenario implements Scenario { name = 'auth/resource-mismatch'; description = 'Tests that client rejects when PRM resource does not match server URL'; + allowClientError = true; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index d94760b..b9865bd 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -479,6 +479,7 @@ export class ScopeRetryLimitScenario implements Scenario { name = 'auth/scope-retry-limit'; description = 'Tests that client implements retry limits to prevent infinite authorization loops on repeated 403 responses'; + allowClientError = true; private authServer = new ServerLifecycle(); private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; diff --git a/src/types.ts b/src/types.ts index d5192b7..5dd6421 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,11 @@ export interface ScenarioUrls { export interface Scenario { name: string; description: string; + /** + * 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; stop(): Promise; getChecks(): ConformanceCheck[]; From 4f91d7f324da11244849cf8e70d72a758bfc2b1e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 28 Jan 2026 15:23:41 +0000 Subject: [PATCH 075/226] ci: migrate to OIDC trusted publishing, add workflow_dispatch for prerelease testing (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove NPM_TOKEN / NODE_AUTH_TOKEN — publish now uses OIDC trusted publishing (no secrets needed) - Add workflow_dispatch trigger with prerelease checkbox: publishes to alpha dist-tag so we can verify OIDC works without touching latest - Drop --provenance flag (included automatically with trusted publishing) The release event path still publishes to latest as before; the only change is the auth mechanism. To test: bump version to x.y.z-alpha.0 on a branch, then run workflow with prerelease checked. --- .github/workflows/ci.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f4711..7401cfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: pull_request: release: types: [published] + workflow_dispatch: + inputs: + prerelease: + description: 'Publish as prerelease (alpha tag). Version must already be bumped in package.json on the branch.' + type: boolean + default: false permissions: contents: read @@ -31,7 +37,7 @@ jobs: publish: runs-on: ubuntu-latest - if: github.event_name == 'release' + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.prerelease == 'true') environment: release needs: [test] @@ -48,6 +54,11 @@ jobs: registry-url: 'https://registry.npmjs.org' - run: npm ci - - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish to npm + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + npm publish --access public --tag alpha + else + npm publish --access public + fi From df870994315bf268bbf50bf97c79017e237d9a55 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 Jan 2026 18:14:49 +0000 Subject: [PATCH 076/226] feat: move march back-compat auth tests to optional backcompat suite (#127) Move auth/2025-03-26-oauth-metadata-backcompat and auth/2025-03-26-oauth-endpoint-fallback out of the required authScenariosList into a new backcompatScenariosList. These test backward compatibility with the old 2025-03-26 auth spec (no PRM, OAuth metadata at server root) which is not part of the current spec requirements. - Add new 'backcompat' client suite - Back-compat scenarios remain in the 'all' suite - Removed from 'core' and 'auth' suites - Add separate test describe block for back-compat scenarios Closes #126 --- src/index.ts | 6 ++++-- src/scenarios/client/auth/index.test.ts | 15 ++++++++++++++- src/scenarios/client/auth/index.ts | 8 ++++++-- src/scenarios/index.ts | 13 +++++++++++-- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 039b042..7be7ed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,8 @@ import { listAuthScenarios, listMetadataScenarios, listCoreScenarios, - listExtensionScenarios + listExtensionScenarios, + listBackcompatScenarios } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; @@ -69,6 +70,7 @@ program all: listScenarios, core: listCoreScenarios, extensions: listExtensionScenarios, + backcompat: listBackcompatScenarios, auth: listAuthScenarios, metadata: listMetadataScenarios, 'sep-835': () => @@ -182,7 +184,7 @@ program console.error('\nAvailable client scenarios:'); listScenarios().forEach((s) => console.error(` - ${s}`)); console.error( - '\nAvailable suites: all, core, extensions, auth, metadata, sep-835' + '\nAvailable suites: all, core, extensions, backcompat, auth, metadata, sep-835' ); process.exit(1); } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index daef5ea..e43f0d3 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,4 +1,4 @@ -import { authScenariosList } from './index'; +import { authScenariosList, backcompatScenariosList } from './index'; import { runClientAgainstScenario, InlineClientRunner @@ -48,6 +48,19 @@ describe('Client Auth Scenarios', () => { } }); +describe('Client Back-compat Scenarios', () => { + for (const scenario of backcompatScenariosList) { + 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); + }); + } +}); + describe('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 115ded5..7f75113 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -28,8 +28,6 @@ import { PreRegistrationScenario } from './pre-registration'; export const authScenariosList: Scenario[] = [ ...metadataScenarios, new AuthBasicCIMDScenario(), - new Auth20250326OAuthMetadataBackcompatScenario(), - new Auth20250326OEndpointFallbackScenario(), new ScopeFromWwwAuthenticateScenario(), new ScopeFromScopesSupportedScenario(), new ScopeOmittedWhenUndefinedScenario(), @@ -42,6 +40,12 @@ export const authScenariosList: Scenario[] = [ new PreRegistrationScenario() ]; +// Back-compat scenarios (optional - backward compatibility with older spec versions) +export const backcompatScenariosList: Scenario[] = [ + new Auth20250326OAuthMetadataBackcompatScenario(), + new Auth20250326OEndpointFallbackScenario() +]; + // Extension scenarios (optional for tier 1 - protocol extensions) export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 0c3e4aa..6e17101 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -53,7 +53,11 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; -import { authScenariosList, extensionScenariosList } from './client/auth/index'; +import { + authScenariosList, + backcompatScenariosList, + extensionScenariosList +} from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; // Pending client scenarios (not yet fully tested/implemented) @@ -137,13 +141,14 @@ export const clientScenarios = new Map( allClientScenariosList.map((scenario) => [scenario.name, scenario]) ); -// All client test scenarios (core + extensions) +// All client test scenarios (core + backcompat + extensions) const scenariosList: Scenario[] = [ new InitializeScenario(), new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), new SSERetryScenario(), ...authScenariosList, + ...backcompatScenariosList, ...extensionScenariosList ]; @@ -201,4 +206,8 @@ export function listExtensionScenarios(): string[] { return extensionScenariosList.map((scenario) => scenario.name); } +export function listBackcompatScenarios(): string[] { + return backcompatScenariosList.map((scenario) => scenario.name); +} + export { listMetadataScenarios }; From 4bfafd5e31fed11d929bbef94186dedd7b17c0f4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 29 Jan 2026 18:19:35 +0000 Subject: [PATCH 077/226] chore: 0.1.12 (#128) * chore: bump version to 0.1.12-alpha.0 for OIDC trusted publishing test * ci: rename prerelease checkbox to publish_alpha, clarify no-publish default * 0.1.12 --- .github/workflows/ci.yml | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7401cfa..ea43b51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,8 @@ on: types: [published] workflow_dispatch: inputs: - prerelease: - description: 'Publish as prerelease (alpha tag). Version must already be bumped in package.json on the branch.' + publish_alpha: + description: 'Publish to npm as alpha prerelease. If unchecked, only tests run (no publish). Version must already be bumped in package.json on the selected branch.' type: boolean default: false @@ -37,7 +37,7 @@ jobs: publish: runs-on: ubuntu-latest - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.prerelease == 'true') + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_alpha == 'true') environment: release needs: [test] diff --git a/package-lock.json b/package-lock.json index 026f973..45e5fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.11", + "version": "0.1.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.11", + "version": "0.1.12", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/package.json b/package.json index 36b855b..81a7ce0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.11", + "version": "0.1.12", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 189a31dc4768bcaa0ff3a7f0b9f140579f8a790e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:59:34 +0000 Subject: [PATCH 078/226] Bump hono from 4.11.4 to 4.11.7 (#124) Bumps [hono](https://github.com/honojs/hono) from 4.11.4 to 4.11.7. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.11.4...v4.11.7) --- updated-dependencies: - dependency-name: hono dependency-version: 4.11.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45e5fa7..7a9ab2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3401,9 +3401,9 @@ } }, "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", + "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", "peer": true, "engines": { From 22196ca809d2b37716a152271ae42c13d6aa3ae3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 15:43:21 +0000 Subject: [PATCH 079/226] fix: bump SDK to 1.26.0, add session management to tools_call scenario (#134) * fix: bump SDK to 1.26.0, add session management to tools_call scenario SDK 1.26.0 made Protocol.connect() throw if already connected to a transport. The tools_call scenario was calling server.connect(transport) on every request with the same Server instance, which now throws. Fixed by adding proper session management: each initialize request creates a new Server + Transport pair, and subsequent requests are routed to the correct transport via the mcp-session-id header. * fix: return 404 for invalid session IDs in tools_call POST handler The else branch was returning 400 for both missing-session and invalid/stale-session cases. The MCP spec requires 404 for invalid session IDs and 400 only for non-initialization requests without any session ID. * refactor: keep tools_call stateless, create fresh server per request Instead of adding session management, simply create a new Server instance per request. This preserves the original stateless design while fixing the SDK 1.26.0 Protocol.connect() restriction. --- package-lock.json | 60 ++++++++++++++++++------------ package.json | 2 +- src/scenarios/client/tools_call.ts | 10 ++++- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a9ab2e..1ccf3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.12", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -752,9 +752,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -855,12 +855,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", - "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -868,14 +868,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" @@ -3042,17 +3043,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -3083,10 +3086,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.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -3405,7 +3411,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3501,6 +3506,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "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==", + "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", @@ -3556,9 +3570,9 @@ } }, "node_modules/jose": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", - "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 81a7ce0..19b4cee 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "vitest": "^4.0.16" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index ab7e312..b074773 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -9,7 +9,7 @@ import express, { Request, Response } from 'express'; import { ScenarioUrls } from '../../types'; import { createRequestLogger } from '../request-logger'; -function createServer(checks: ConformanceCheck[]): express.Application { +function createMcpServer(checks: ConformanceCheck[]): Server { const server = new Server( { name: 'add-numbers-server', @@ -84,6 +84,10 @@ function createServer(checks: ConformanceCheck[]): express.Application { throw new Error(`Unknown tool: ${request.params.name}`); }); + return server; +} + +function createServerApp(checks: ConformanceCheck[]): express.Application { const app = express(); app.use(express.json()); @@ -96,6 +100,8 @@ function createServer(checks: ConformanceCheck[]): express.Application { ); 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 }); @@ -116,7 +122,7 @@ export class ToolsCallScenario implements Scenario { async start(): Promise { this.checks = []; - this.app = createServer(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` }; From a1b8aaae5e1ebbcb8d460f325cc9e3a652c09da4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 16:10:50 +0000 Subject: [PATCH 080/226] 0.1.13 (#136) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ccf3ee..936103b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.12", + "version": "0.1.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.12", + "version": "0.1.13", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 19b4cee..09ff1d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.12", + "version": "0.1.13", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 37225cef7d64fb9a26f9ef7c35b9aff166ce2935 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:22:16 +0000 Subject: [PATCH 081/226] fix: create fresh Server per request in auth test helpers (#141) The auth test server helper and no-dns-rebinding-protection example reused a single Server instance across requests, calling connect() on each new transport. SDK v1.26.0's security fix (GHSA-345p-7cg4-v4c7) now throws "Already connected to a transport" in this case. Fix by wrapping Server creation in a factory function called per request, matching the pattern already used by tools_call.ts and everything-server.ts. Also updates no-dns-rebinding-protection.ts to use registerTool() instead of the deprecated tool() API. Co-authored-by: Claude Opus 4.6 --- .../typescript/no-dns-rebinding-protection.ts | 35 +++++---- .../client/auth/helpers/createServer.ts | 77 ++++++++++--------- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts index f1ff754..1e0fc3c 100644 --- a/examples/servers/typescript/no-dns-rebinding-protection.ts +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -13,21 +13,27 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -// Create minimal MCP server -const server = new McpServer({ - name: 'no-dns-rebinding-protection-server', - version: '1.0.0' -}); +// Create a fresh MCP server per request to avoid "Already connected" errors +// after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7) +function createMcpServer() { + const server = new McpServer({ + name: 'no-dns-rebinding-protection-server', + version: '1.0.0' + }); -// Add a simple tool -server.tool( - 'echo', - 'Echo the input back', - { message: { type: 'string' } }, - async ({ message }) => ({ - content: [{ type: 'text', text: `Echo: ${message}` }] - }) -); + server.registerTool( + 'echo', + { + description: 'Echo the input back', + inputSchema: { message: { type: 'string' } } + }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Echo: ${message}` }] + }) + ); + + return server; +} // === VULNERABLE EXPRESS APP === // This intentionally does NOT use createMcpExpressApp() or localhostHostValidation() @@ -37,6 +43,7 @@ app.use(express.json()); app.post('/mcp', async (req, res) => { try { + const server = createMcpServer(); // Stateless: no session ID const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 35b89b1..c836630 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -41,43 +41,49 @@ export function createServer( tokenVerifier, prmResourceOverride } = options; - const server = new Server( - { - name: 'auth-prm-pathbased-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'test-tool', - inputSchema: { type: 'object' } + // Factory: create a fresh Server per request to avoid "Already connected" errors + // after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7) + function createMcpServer() { + const server = new Server( + { + name: 'auth-prm-pathbased-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} } - ] - }; - }); - - server.setRequestHandler( - CallToolRequestSchema, - async (request): Promise => { - if (request.params.name === 'test-tool') { - return { - content: [{ type: 'text', text: 'test' }] - }; } - throw new McpError( - ErrorCode.InvalidParams, - `Tool ${request.params.name} not found` - ); - } - ); + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'test-tool', + inputSchema: { type: 'object' } + } + ] + }; + }); + + server.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + if (request.params.name === 'test-tool') { + return { + content: [{ type: 'text', text: 'test' }] + }; + } + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} not found` + ); + } + ); + + return server; + } const app = express(); app.use(express.json()); @@ -151,6 +157,7 @@ export function createServer( authMiddleware(req, res, async (err?: any) => { if (err) return next(err); + const server = createMcpServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); From 3b4a92baada2066433e078603e8f425fa31192e7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:59:06 +0000 Subject: [PATCH 082/226] feat: SDK tier assessment CLI and skill (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add tier-check CLI for SDK tier assessment Adds a 'tier-check' subcommand to the conformance tool that automates SDK tier assessment against SEP-1730 criteria. Checks performed: - Conformance test pass rate (via everything-server) - GitHub label taxonomy (priority/status/area labels) - Issue triage SLA compliance - P0 bug resolution tracking - Stable release detection - Required file existence (CHANGELOG, SECURITY, etc.) - Spec tracking (SDK release within 30d of spec release) Also includes a Claude Code skill (skills/mcp-sdk-tier-audit/) for judgment-based checks that require codebase analysis (feature coverage, docs quality, policy evaluation). Usage: npx tsx src/index.ts tier-check --repo modelcontextprotocol/typescript-sdk npx tsx src/index.ts tier-check --repo ... --conformance-server-cmd '...' \ --conformance-server-cwd ... --conformance-server-url ... --output json * refactor: revise tier-check CLI and skill based on review feedback - Move skill to .claude/skills/ so it's auto-available in Claude Code - Remove feature-coverage subagent (redundant with conformance tests) - Remove hardcoded ~/src/mcp paths from all skill files - Trim conformance server table to TS + Python only - Rename file_existence check to policy_signals (informational, not blocking) - Add GitHub native issue types detection to labels check - Add missing features to docs-coverage checklist (tasks, elicitation URL mode, JSON Schema 2020-12) - Add README with CLI quick start and escape hatch for non-Claude-Code users - Use --limit 500 instead of --limit 100 for gh issue list Co-Authored-By: Claude Opus 4.6 * fix: address review feedback on skill and README - Use npx @modelcontextprotocol/conformance instead of node dist/index.js - Add full GitHub auth instructions (gh auth login, GITHUB_TOKEN, --token) - Point TS SDK conformance server to typescript-sdk/test/conformance/ - Fix Python SDK URL to localhost:3001/mcp (not TBD) - Remove manual gh issue list / gh release list from SKILL.md (CLI handles it) - Remove Claude Code-specific subagent_type references - Assume user is already in conformance repo - Clean up policy-evaluation-prompt.md: remove redundant grep commands, focus on content evaluation Co-Authored-By: Claude Opus 4.6 * fix: resolve lint errors in conformance.ts Remove unused variable assignments flagged by eslint. Co-Authored-By: Claude Opus 4.6 * style: apply prettier formatting Co-Authored-By: Claude Opus 4.6 * docs: add npm run tier-check script, update docs with examples - Add "tier-check" npm script so users can run `npm run tier-check --` instead of `node dist/index.js tier-check` - Update SKILL.md, README, and skill README to use npm run tier-check - Add full conformance examples with --conformance-server-cmd/cwd/url flags and realistic paths (~/src/mcp/typescript-sdk) Co-Authored-By: Claude Opus 4.6 * style: fix prettier formatting in SKILL.md * fix: add per-scenario timeout, support url-only conformance, fix stdout pollution - Add 30s per-scenario timeout to prevent tier-check from hanging - Allow --conformance-server-url without --conformance-server-cmd (server already running) - Move runner status logs to stderr so --output json produces clean JSON - Update SKILL.md and README with --silent flag and pre-start server workflow * refactor: skill takes local path + server URL instead of repo name The skill now requires two arguments: 1. Local path to the SDK checkout (for direct file inspection) 2. URL where the everything server is already running The GitHub owner/repo is derived from git remote. This eliminates cloning, server startup complexity, and branch confusion (v1.x vs main). * refactor: write detailed reports to files, show concise summary Reports go to results/tier-audits/-/ (already gitignored). Claude's console output is now just the tier classification, pass/fail summary line, top 3 actions, and file paths. * docs: update READMEs for new skill interface and pre-start workflow * simplify: flat file output instead of nested directory * fix: remediation always shows path to Tier 2 and Tier 1 * feat: add client conformance testing to tier-check CLI and skill - Add checkClientConformance() that runs core client scenarios (initialize, tools_call, elicitation-defaults, sse-retry, auth) by spawning the SDK's conformance client via --client-cmd - Add client_conformance to TierScorecard type - Wire --client-cmd option into the CLI - Update tier logic: both server + client conformance feed into Tier 1 (100%) and Tier 2 (>=80%) requirements - Update terminal and markdown output to show both conformance types - Update skill to auto-detect conformance client or accept explicit client-cmd argument - Update README with new option and examples * improve: table summary output, write reports via subagents - Change executive summary from pipe-delimited line to a readable table with T2/T1 columns - Move assessment and remediation file writing into parallel subagents to keep the main conversation thread clean * improve: list tier gaps as numbered items instead of one-line blob * improve: finalize summary format with separator, high-priority fixes, numbered gaps * improve: add pre-flight checks for gh auth and server reachability Fail fast with clear error messages if GitHub CLI is not authenticated or if the conformance server URL is not reachable, rather than failing deep into the scorecard run. * docs: improve README and fix skill auto-detection paths - Claude Code section: explain client-cmd auto-detection for TS/Python, show explicit 3-arg form for other SDKs, add examples for all three - Fix TypeScript build command (npm run build, not pnpm build:all) - Fix Python server command (add --port, use uv sync --package) - Fix Python client path (.github/actions/conformance/client.py) - Expand 'Other SDKs' section with guidance on everything server - Add gh auth login prerequisite to Claude Code steps * simplify: remove client-cmd auto-detection, require explicit argument Client command is now always passed as the third argument. If omitted, client conformance is skipped and noted as a gap. No more magic path detection — clearer and more predictable. * fix: align docs table with canonical list (48 features), simplify policy eval Docs coverage: - Table now has numbered rows matching all 48 non-experimental features from the canonical list (was missing 7: tools text/image/audio/embedded/ error/notifications, protocol version negotiation) - Hardcode total as 48 in summary so agents don't miscount Policy evaluation: - Simplified from deep content analysis to file-existence checks - Dependency policy: DEPENDENCY_POLICY.md, dependabot.yml, or CONTRIBUTING.md section - Roadmap: ROADMAP.md must exist (GitHub milestones alone not sufficient) - Versioning: VERSIONING.md or CONTRIBUTING.md section - Removed GitHub API calls for milestones and releases from policy eval * refactor: extract canonical feature list into single source of truth Create references/feature-list.md with all 48 non-experimental + 5 experimental features. The docs-coverage prompt now references this file instead of duplicating the list. One place to update when features change. * fix: separate deterministic file checks from AI content evaluation CLI (files.ts): now checks all policy files deterministically — DEPENDENCY_POLICY.md, docs/dependency-policy.md, dependabot.yml, renovate.json, ROADMAP.md, docs/roadmap.md, VERSIONING.md, docs/versioning.md, BREAKING_CHANGES.md (in addition to existing CHANGELOG.md, SECURITY.md, CONTRIBUTING.md). AI policy eval: receives CLI output showing which files exist, then reads ONLY those files to judge content quality. No longer searches the repo for files — clean separation of concerns. * style: apply prettier formatting * revert: undo unrelated console.log change in runner/server.ts * refactor: shell out to conformance CLI instead of reimplementing runner Address PR feedback: conformance.ts was duplicating the normal conformance running code. Now shells out to 'node dist/index.js server/client' with -o to save results to a temp dir, then parses the checks.json files. Also removes --conformance-server-cmd and --conformance-server-cwd options since the server must be pre-started. * docs: add Go and C# SDK examples to README and SKILL.md * fix: add --framework net9.0 to C# server command * rename: conformance.ts -> test-conformance-results.ts Avoids confusion with src/runner/ (the actual conformance runner). This file just invokes the CLI and parses output. * style: prettier formatting * fix: reconcile conformance results against full scenario list The tier-check CLI was only counting scenarios that produced a checks.json file. Scenarios that crashed or failed to run (e.g., auth scenarios when OAuth is not implemented) were invisible, making the denominator artificially small (e.g., 4/4 instead of 4/23). Now both checkConformance and checkClientConformance reconcile their parsed results against the known scenario lists, adding failure entries for any expected scenario that didn't produce results. Co-Authored-By: Claude Opus 4.6 * docs: tighten documentation evaluation criteria Clarify what counts as documented vs just having code: - Conformance test servers don't count as docs or examples - Examples without prose = PARTIAL, not PASS - Go Example* test functions explicitly allowed - Clear PASS/PARTIAL/FAIL verdict definitions * docs: add Labels and Spec Tracking rows to audit report templates The executive summary and assessment report were missing two SEP-1730 requirements: label taxonomy compliance and spec tracking (new protocol features timeline). Co-Authored-By: Claude Opus 4.6 * fix: reuse ConformanceCheck type from src/types.ts instead of redefining --------- Co-authored-by: Claude Opus 4.6 --- .claude/skills/mcp-sdk-tier-audit/README.md | 258 +++ .claude/skills/mcp-sdk-tier-audit/SKILL.md | 276 +++ .../references/docs-coverage-prompt.md | 139 ++ .../references/feature-list.md | 80 + .../references/policy-evaluation-prompt.md | 145 ++ .../references/report-template.md | 144 ++ .../references/tier-requirements.md | 103 + README.md | 23 + package-lock.json | 1922 +++++++---------- package.json | 4 +- src/index.ts | 4 + src/tier-check/checks/files.ts | 58 + src/tier-check/checks/labels.ts | 72 + src/tier-check/checks/p0.ts | 87 + src/tier-check/checks/release.ts | 59 + src/tier-check/checks/spec-tracking.ts | 79 + .../checks/test-conformance-results.ts | 203 ++ src/tier-check/checks/triage.ts | 109 + src/tier-check/index.ts | 201 ++ src/tier-check/output.ts | 153 ++ src/tier-check/tier-logic.ts | 66 + src/tier-check/types.ts | 87 + 22 files changed, 3140 insertions(+), 1132 deletions(-) create mode 100644 .claude/skills/mcp-sdk-tier-audit/README.md create mode 100644 .claude/skills/mcp-sdk-tier-audit/SKILL.md create mode 100644 .claude/skills/mcp-sdk-tier-audit/references/docs-coverage-prompt.md create mode 100644 .claude/skills/mcp-sdk-tier-audit/references/feature-list.md create mode 100644 .claude/skills/mcp-sdk-tier-audit/references/policy-evaluation-prompt.md create mode 100644 .claude/skills/mcp-sdk-tier-audit/references/report-template.md create mode 100644 .claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md create mode 100644 src/tier-check/checks/files.ts create mode 100644 src/tier-check/checks/labels.ts create mode 100644 src/tier-check/checks/p0.ts create mode 100644 src/tier-check/checks/release.ts create mode 100644 src/tier-check/checks/spec-tracking.ts create mode 100644 src/tier-check/checks/test-conformance-results.ts create mode 100644 src/tier-check/checks/triage.ts create mode 100644 src/tier-check/index.ts create mode 100644 src/tier-check/output.ts create mode 100644 src/tier-check/tier-logic.ts create mode 100644 src/tier-check/types.ts diff --git a/.claude/skills/mcp-sdk-tier-audit/README.md b/.claude/skills/mcp-sdk-tier-audit/README.md new file mode 100644 index 0000000..77fc8cf --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/README.md @@ -0,0 +1,258 @@ +# MCP SDK Tier Audit + +Assess any MCP SDK repository against [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) (the SDK Tiering System). Produces a tier classification (1/2/3) with an evidence-backed scorecard. + +Two components work together: + +- **`tier-check` CLI** — runs deterministic checks (server + client conformance pass rate, issue triage speed, P0 resolution, labels, releases, policy signals). Works standalone, no AI needed. +- **AI-assisted assessment** — an agent uses the CLI scorecard plus judgment-based evaluation (documentation coverage, dependency policy, roadmap) to produce a full tier report with remediation guide. + +## Quick Start: CLI + +The CLI is a subcommand of the [MCP Conformance](https://github.com/modelcontextprotocol/conformance) tool. + +```bash +# Clone and build +git clone https://github.com/modelcontextprotocol/conformance.git +cd conformance +npm install +npm run build + +# Authenticate with GitHub (needed for API access) +gh auth login + +# Run against any MCP SDK repo (without conformance tests) +npm run --silent tier-check -- --repo modelcontextprotocol/typescript-sdk --skip-conformance +``` + +The CLI uses the GitHub API (read-only) for issue metrics, labels, and release checks. Authenticate via one of: + +- **GitHub CLI** (recommended): `gh auth login` — the CLI picks up your token automatically +- **Environment variable**: `export GITHUB_TOKEN=ghp_...` +- **Flag**: `--token ghp_...` + +For public repos, any authenticated token works (no special scopes needed — authentication just avoids rate limits). For a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new), select **Public Repositories (read-only)** with no additional permissions. + +### CLI Options + +``` +--repo GitHub repository (required) +--branch Branch to check +--skip-conformance Skip conformance tests +--conformance-server-url URL of the already-running conformance server +--client-cmd Command to run the SDK conformance client (for client conformance tests) +--days Limit triage analysis to last N days +--output json | markdown | terminal (default: terminal) +--token GitHub token (defaults to GITHUB_TOKEN or gh auth token) +``` + +### What the CLI Checks + +| Check | What it measures | +| ------------------ | ------------------------------------------------------------------------------ | +| Server Conformance | Pass rate of server implementation against the conformance test suite | +| Client Conformance | Pass rate of client implementation against the conformance test suite | +| Labels | Whether SEP-1730 label taxonomy is set up (supports GitHub native issue types) | +| Triage | How quickly issues get labeled after creation | +| P0 Resolution | Whether critical bugs are resolved within SLA | +| Stable Release | Whether a stable release >= 1.0.0 exists | +| Policy Signals | Presence of CHANGELOG, SECURITY, CONTRIBUTING, dependabot, ROADMAP | +| Spec Tracking | Gap between latest spec release and SDK release | + +### Example Output + +``` +Tier Assessment: Tier 2 + +Repo: modelcontextprotocol/typescript-sdk +Timestamp: 2026-02-10T12:00:00Z + +Check Results: + + ✓ Server Conformance 45/45 (100%) + ✓ Client Conformance 4/4 (100%) + ✗ Labels 9/12 required labels + Missing: needs confirmation, needs repro, ready for work + ✓ Triage 92% within 2BD (150 issues, median 8h) + ✓ P0 Resolution 0 open, 3/3 closed within 7d + ✓ Stable Release 2.3.1 + ~ Policy Signals ✓ CHANGELOG.md, ✗ SECURITY.md, ✓ CONTRIBUTING.md, ✓ .github/dependabot.yml, ✗ ROADMAP.md + ✓ Spec Tracking 2d gap +``` + +Use `--output json` to get machine-readable results, or `--output markdown` for a report you can paste into an issue. + +## Full AI-Assisted Assessment + +The CLI produces a deterministic scorecard, but some SEP-1730 requirements need judgment: documentation quality, dependency policy, roadmap substance. An AI agent can evaluate these by reading the repo. + +### Claude Code + +The skill lives in `.claude/skills/` in this repo, so if you open [Claude Code](https://docs.anthropic.com/en/docs/claude-code) in the conformance repo it's already available. + +1. Make sure `gh auth login` is done (the skill checks this upfront) +2. Start the SDK's everything server in a separate terminal +3. Run the skill: + +``` +/mcp-sdk-tier-audit [client-cmd] +``` + +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:** + +```bash +# Terminal 1: start the everything server (build first: npm run build) +cd ~/src/mcp/typescript-sdk && npm run test:conformance:server:run + +# 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" +``` + +**Python SDK example:** + +```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 + +# 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 + +If you use a different agent (Codex, Cursor, Aider, OpenCode, etc.), give it these instructions: + +1. **Run the CLI** to get the deterministic scorecard: + + ```bash + node dist/index.js tier-check --repo --conformance-server-url --output json + ``` + +2. **Evaluate documentation coverage** — check whether MCP features (tools, resources, prompts, sampling, transports, etc.) are documented with examples. See [`references/docs-coverage-prompt.md`](references/docs-coverage-prompt.md) for the full checklist. + +3. **Evaluate policies** — check for dependency update policy, roadmap, and versioning/breaking-change policy. See [`references/policy-evaluation-prompt.md`](references/policy-evaluation-prompt.md) for criteria. + +4. **Apply tier logic** — combine scorecard + evaluations against the thresholds in [`references/tier-requirements.md`](references/tier-requirements.md). + +5. **Generate report** — use [`references/report-template.md`](references/report-template.md) for the output format. + +### Manual Review + +Run the CLI for the scorecard, then review docs and policies yourself using the tier requirements as a checklist: + +| Requirement | Tier 1 | Tier 2 | +| ------------------ | ------------------------------ | ------------------------ | +| Server Conformance | 100% pass | >= 80% pass | +| Client Conformance | 100% pass | >= 80% pass | +| Issue triage | Within 2 business days | Within 1 month | +| P0 resolution | Within 7 days | Within 2 weeks | +| Stable release | >= 1.0.0 with clear versioning | At least one >= 1.0.0 | +| Documentation | All features with examples | Core features documented | +| Dependency policy | Published | Published | +| Roadmap | Published with spec tracking | Plan toward Tier 1 | + +## 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**: + +```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 + +# 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' +``` + +**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' +``` + +**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. + +Start your everything server, then pass `--conformance-server-url`. Pass `--client-cmd` if your SDK has a conformance client. If neither exists yet, use `--skip-conformance` — the scorecard will note this as a gap. + +## Reference Files + +These files in [`references/`](references/) contain the detailed criteria and prompts: + +| File | Purpose | +| ----------------------------- | ------------------------------------------------------- | +| `tier-requirements.md` | Full SEP-1730 requirements with exact thresholds | +| `docs-coverage-prompt.md` | Feature checklist for documentation evaluation | +| `policy-evaluation-prompt.md` | Criteria for dependency, roadmap, and versioning policy | +| `report-template.md` | Output format for the full audit report | diff --git a/.claude/skills/mcp-sdk-tier-audit/SKILL.md b/.claude/skills/mcp-sdk-tier-audit/SKILL.md new file mode 100644 index 0000000..234a257 --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/SKILL.md @@ -0,0 +1,276 @@ +--- +name: mcp-sdk-tier-audit +description: >- + Comprehensive tier assessment for an MCP SDK repository against SEP-1730. + 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]' +--- + +# MCP SDK Tier Audit + +You are performing a comprehensive tier assessment for an MCP SDK repository against SEP-1730 (the SDK Tiering System). Your goal is to produce a definitive tier classification (Tier 1, 2, or 3) backed by evidence. + +## Step 0: Pre-flight Checks + +Before doing anything else, verify GitHub CLI authentication: + +```bash +gh auth status 2>&1 +``` + +If this fails (exit code non-zero or shows "not logged in"), stop immediately and tell the user: + +> GitHub authentication is required for this skill. Please run `gh auth login` first, then re-run the skill. + +Do NOT proceed to any other step if this check fails. + +After parsing arguments (Step 1), also verify the conformance server is reachable: + +```bash +curl -sf -o /dev/null -w '%{http_code}' 2>&1 || true +``` + +If the server is not reachable, stop and tell the user: + +> Conformance server at `` is not reachable. Make sure the everything server is running before invoking this skill. + +## Step 1: Parse Arguments + +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`) +- **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. + +The first two arguments are required. If either is missing, ask the user to provide it. + +Derive the GitHub `owner/repo` from the local checkout: + +```bash +cd && git remote get-url origin | sed 's#.*github.com[:/]##; s#\.git$##' +``` + +## Step 2: Run the Deterministic Scorecard + +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. + +```bash +npm run --silent tier-check -- \ + --repo \ + --conformance-server-url \ + --client-cmd '' \ + --output json +``` + +If no client-cmd was detected, omit the `--client-cmd` flag (client conformance will be skipped). + +The CLI output includes server conformance pass rate, client conformance pass rate, 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. + +### Conformance Baseline Check + +After running the CLI, check for an expected-failures baseline file in the SDK repo: + +```bash +find -name "baseline.yml" -o -name "expected-failures.yml" 2>/dev/null | head -5 +``` + +If found, read the file. It lists known/expected conformance failures. This context is essential for interpreting raw pass rates — a 20% client pass rate due entirely to unimplemented OAuth scenarios is very different from 20% due to broken core functionality. + +## Step 3: Launch Parallel Evaluations + +Launch 2 evaluations in parallel. Each reads the SDK from the local checkout path. + +**IMPORTANT**: Launch both evaluations at the same time (in the same response) so they run in parallel. + +### Evaluation 1: Documentation Coverage + +Use the prompt from `references/docs-coverage-prompt.md`. Pass the local path. + +This evaluation checks: + +- Whether all non-experimental features are documented with examples (Tier 1 requirement) +- Whether core features are documented (Tier 2 requirement) +- Produces an evidence table with file:line references + +### Evaluation 2: Policy Evaluation + +Use the prompt from `references/policy-evaluation-prompt.md`. Pass the local path, the derived `owner/repo`, and the `policy_signals` section from the CLI JSON output. + +The CLI has already checked which policy files exist (ROADMAP.md, DEPENDENCY_POLICY.md, dependabot.yml, VERSIONING.md, etc.). The AI evaluation reads only the files the CLI found to judge whether the content is substantive — it does NOT search for files in other locations. + +This evaluation checks: + +- Dependency update policy (required for Tier 1 and Tier 2) +- Published roadmap (required for Tier 1; plan-toward-Tier-1 for Tier 2) +- Clear versioning with documented breaking change policy (required for Tier 1) +- Produces evidence tables for each policy area + +## Step 4: Compute Final Tier + +Combine the deterministic scorecard (from the CLI) with the evaluation results (docs, policies). Apply the tier logic: + +### Tier 1 requires ALL of: + +- Server conformance test pass rate == 100% +- Client conformance test pass rate == 100% +- 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 +- Clear versioning with documented breaking change policy (evaluation) +- All non-experimental features documented with examples (evaluation) +- Published dependency update policy (evaluation) +- Published roadmap with concrete steps tracking spec components (evaluation) + +### Tier 2 requires ALL of: + +- Server conformance test pass rate >= 80% +- Client conformance test pass rate >= 80% +- Issue triage compliance >= 80% within 1 month +- P0 bugs resolved within 2 weeks +- At least one stable release >= 1.0.0 +- Basic docs covering core features (evaluation) +- Published dependency update policy (evaluation) +- Published plan toward Tier 1 or explanation for remaining Tier 2 (evaluation) + +### Otherwise: Tier 3 + +If any Tier 2 requirement is not met, the SDK is Tier 3. + +**Important edge cases:** + +- If GitHub issue labels are not set up per SEP-1730, triage metrics cannot be computed. Note this as a gap. However, repos may use GitHub's native issue types instead of type labels — the CLI checks for both. +- If client conformance was skipped (no client command found), note this as a gap but do not block tier advancement based on it alone. + +**Client Conformance Splits:** + +When reporting client conformance, always break results into three categories: + +1. **Core suite** — Non-auth scenarios (e.g. initialize, tools_call, elicitation, sse-retry) +2. **Auth suite** — OAuth/authorization scenarios (any scenario starting with `auth/`) +3. **Full suite** — All scenarios combined + +The **full suite** number is used for tier threshold checks. However, the core vs auth split provides essential context. Always present both numbers in the report. + +If the SDK has a `baseline.yml` or expected-failures file, note which failures are known/tracked vs. unexpected regressions. A low full-suite score where all failures are auth scenarios documented in the baseline is a scope gap (OAuth not yet implemented), not a quality problem — flag it accordingly in the assessment. + +**P0 Label Audit Guidance:** + +When evaluating P0 metrics, flag potentially mislabeled P0 issues: + +- If P0 count is high (>2) but other Tier 2 metrics (conformance, triage compliance, docs) are strong, this may indicate P0 labels are being used for enhancements, lower-priority work, or feature requests rather than actual critical bugs. +- In such cases, recommend a P0 label audit as a remediation action. Review open P0 issues to verify they represent genuine blocking defects vs. misclassified work. +- Document this finding in the remediation output with specific issue numbers and suggested re-triage actions. +- Do not treat high P0 count as an automatic hard blocker if the audit reveals mislabeling; instead, note it as a process improvement opportunity. + +## Step 5: Generate Output + +Write detailed reports to files using subagents, then show a concise summary to the user. + +### Output files (write via subagents) + +**IMPORTANT**: Write both report files using parallel subagents (Task tool) so the file-writing work does not pollute the main conversation thread. Launch both subagents at the same time. + +Write two files to `results/` in the conformance repo: + +- `results/--assessment.md` +- `results/--remediation.md` + +For example: `results/2026-02-11-typescript-sdk-assessment.md` + +#### Assessment subagent + +Pass all the gathered data (CLI scorecard JSON, docs evaluation results, policy evaluation results) to a subagent and instruct it to write the assessment file using the template from `references/report-template.md`. This file contains the full requirements table, conformance test details (both server and client), triage metrics, documentation coverage table, and policy evaluation evidence. + +#### Remediation subagent + +Pass all the gathered data to a subagent and instruct it to write the remediation file using the template from `references/report-template.md`. This file always includes both: + +- **Path to Tier 2** (if current tier is 3) -- what's needed to reach Tier 2 +- **Path to Tier 1** (always) -- what's needed to reach Tier 1 + +### Console output (shown to the user) + +After the subagents finish, output a short executive summary directly to the user: + +``` +## — Tier + +| Check | Value | T2 | T1 | +|-------|-------|----|----| +| Server Conformance | / (%) | ✓/✗ | ✓/✗ | +| Client Conformance (full) | / (%) | ✓/✗ | ✓/✗ | +| — Core scenarios | / (%) | — | — | +| — Auth scenarios | / (%) | — | — | +| Issue Triage | % (/) | ✓/✗ | ✓/✗ | +| Labels | / | ✓/✗ | ✓/✗ | +| P0 Resolution | open | ✓/✗ | ✓/✗ | +| Spec Tracking | d gap | ✓/✗ | ✓/✗ | +| Documentation | / features | ✓/✗ | ✓/✗ | +| Dependency Policy | | ✓/✗ | ✓/✗ | +| Roadmap | | ✓/✗ | ✓/✗ | +| Versioning Policy | | N/A | ✓/✗ | +| Stable Release | | ✓/✗ | ✓/✗ | + +If a baseline file was found, add a note below the table: +> **Baseline**: {N} failures in `baseline.yml` ({list of categories, e.g. "18 auth scenarios"}). Core suite: {core_rate}%. + +--- + +**High-Priority Fixes:** +1. + +**For Tier 2:** +1. +2. + +**For Tier 1:** +1. +2. + +Reports: +- results/--assessment.md +- results/--remediation.md +``` + +Use ✓ for pass and ✗ for fail. + +**High-Priority Fixes**: List any issues that need urgent attention (e.g., P0 label audit if P0 count is >2 but other metrics are strong, suggesting mislabeled issues). If none, omit this section. + +**For Tier 2 / For Tier 1**: List each gap as a separate numbered item. Use "All requirements met" if there are no gaps for that tier. Each item should be a concise action (e.g., "Re-triage mislabeled P0s", "Document 16 undocumented core features"). + +## Reference Files + +The following reference files are available in the `references/` directory alongside this skill: + +- `references/feature-list.md` -- Canonical list of 48 non-experimental + 5 experimental features (single source of truth) +- `references/tier-requirements.md` -- Full SEP-1730 requirements table with exact thresholds +- `references/report-template.md` -- Output format template for the audit report +- `references/docs-coverage-prompt.md` -- Evaluation prompt for documentation coverage +- `references/policy-evaluation-prompt.md` -- Evaluation prompt for policy review + +Read these reference files when you need the detailed content for evaluation prompts or report formatting. + +## Usage Examples + +``` +# TypeScript SDK — server + client conformance +/mcp-sdk-tier-audit ~/src/mcp/typescript-sdk http://localhost:3000/mcp "npx tsx ~/src/mcp/typescript-sdk/test/conformance/src/everythingClient.ts" + +# Python SDK — server + client conformance +/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 — server + client conformance +/mcp-sdk-tier-audit ~/src/mcp/go-sdk http://localhost:3002 "/tmp/go-conformance-client" + +# C# SDK — server + client conformance +# Two C#-specific requirements in the client-cmd: +# --framework net9.0 : required because the project targets net8.0/net9.0/net10.0 +# -- $MCP_CONFORMANCE_SCENARIO : the runner sets this env var and uses shell:true, so the +# shell expands it; dotnet passes [scenario, url] to the program +/mcp-sdk-tier-audit ~/src/mcp/csharp-sdk http://localhost:3003 "dotnet run --project ~/src/mcp/csharp-sdk/tests/ModelContextProtocol.ConformanceClient --framework net9.0 -- $MCP_CONFORMANCE_SCENARIO" + +# Any SDK — server conformance only (no client) +/mcp-sdk-tier-audit ~/src/mcp/some-sdk http://localhost:3004 +``` diff --git a/.claude/skills/mcp-sdk-tier-audit/references/docs-coverage-prompt.md b/.claude/skills/mcp-sdk-tier-audit/references/docs-coverage-prompt.md new file mode 100644 index 0000000..86bbcc5 --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/references/docs-coverage-prompt.md @@ -0,0 +1,139 @@ +# Documentation Coverage Subagent Prompt + +You are evaluating the documentation coverage of an MCP SDK repository for the SEP-1730 tier assessment. + +## Input + +- **SDK path**: {local-path} (absolute path to local SDK checkout) + +## Your Task + +Evaluate the documentation quality and coverage of this MCP SDK against the canonical feature list. You need to determine: + +1. **Tier 1 compliance**: Are ALL non-experimental features documented with examples? +2. **Tier 2 compliance**: Are core features documented (basic docs)? + +## Steps + +### 1. Read the canonical feature list + +Read `references/feature-list.md` for the definitive list of 48 non-experimental features (plus 5 experimental) to evaluate. That file is the single source of truth — use every feature listed there, in order. + +### 2. Find all documentation sources + +The SDK is available at `{local-path}`. Search for documentation in these locations: + +- `README.md` (root and any subdirectory READMEs) +- `docs/` directory +- `documentation/` directory +- `examples/` directory +- API documentation (generated or hand-written) +- `CONTRIBUTING.md` +- Inline code comments and docstrings on public API surfaces +- Any `*.md` files in the repo + +```bash +# Find all markdown files +find {local-path} -name "*.md" -not -path "*/node_modules/*" -not -path "*/.git/*" + +# Find example files +find {local-path} -path "*/examples/*" -not -path "*/node_modules/*" + +# Find API docs +find {local-path} -path "*/docs/*" -not -path "*/node_modules/*" +``` + +### 3. Evaluate each feature + +For each of the 48 non-experimental features in the canonical list, determine: + +- **Documented?**: Is there documentation explaining this feature? (Yes/No) +- **Where**: File path and line numbers where documentation exists +- **Has Examples?**: Are there code examples showing how to use this feature? (Yes/No/N/A) +- **Verdict**: PASS (documented with examples), PARTIAL (documented but no examples), or FAIL (not documented) + +## Required Output Format + +Produce your assessment in this exact format: + +```markdown +### Documentation Coverage Assessment + +**SDK path**: {local-path} +**Documentation locations found**: + +- {path1}: {description} +- {path2}: {description} +- ... + +#### Feature Documentation Table + +One row per feature from `references/feature-list.md`. Use the exact feature numbers and names from that file. + +| # | Feature | Documented? | Where | Has Examples? | Verdict | +| --- | -------------------------- | ----------- | -------------- | ----------------------- | ----------------- | +| 1 | Tools - listing | Yes/No | {file}:{lines} | Yes ({N} examples) / No | PASS/PARTIAL/FAIL | +| 2 | Tools - calling | Yes/No | {file}:{lines} | Yes ({N} examples) / No | PASS/PARTIAL/FAIL | +| ... | ... | ... | ... | ... | ... | +| 48 | JSON Schema 2020-12 | Yes/No | {file}:{lines} | Yes ({N} examples) / No | PASS/PARTIAL/FAIL | +| — | Tasks - get (experimental) | Yes/No | {file}:{lines} | Yes ({N} examples) / No | INFO | +| ... | ... | ... | ... | ... | ... | + +All 48 non-experimental features MUST appear in the table. Do not skip or merge rows. + +#### Summary + +**Total non-experimental features**: 48 +**PASS (documented with examples)**: {N}/48 +**PARTIAL (documented, no examples)**: {N}/48 +**FAIL (not documented)**: {N}/48 + +**Core features documented**: {N}/{total core} ({percentage}%) +**All features documented with examples**: {N}/48 ({percentage}%) + +#### Tier Verdicts + +**Tier 1** (all non-experimental features documented with examples): **PASS/FAIL** + +- {If FAIL: list the features missing documentation or examples} + +**Tier 2** (basic docs covering core features): **PASS/FAIL** + +- {If FAIL: list the core features missing documentation} +``` + +## What Counts as "Documented" + +A feature is "documented" only if there is **prose documentation** (in README, docs/, or similar) explaining what the feature does, when to use it, and how it works. The following do **not** count as documentation on their own: + +- Example code without accompanying prose explanation +- Conformance test servers or test fixtures +- Source code, even with comments or docstrings +- Mere existence of an API (e.g., a function existing in the SDK) + +**Examples supplement documentation but do not replace it.** A feature with a working example in `examples/` but no prose explaining the feature is PARTIAL, not PASS. A feature with only a conformance server implementation and no user-facing docs is FAIL. + +### Verdict criteria + +- **PASS**: Prose documentation exists explaining the feature AND at least one runnable or near-runnable code example +- **PARTIAL**: Either prose docs exist but no examples, OR examples exist but no prose docs +- **FAIL**: No prose documentation and no examples. Also use FAIL if the feature is only demonstrated in test/conformance code with no user-facing docs or examples + +### What counts as an "example" + +- Runnable code in an `examples/` directory +- Code snippets embedded in prose documentation (README, docs/\*.md) +- Go `Example*` test functions (these render on pkg.go.dev and are a language convention) +- Examples in test files count only if they are clearly labeled as examples or referenced from documentation + +### What does NOT count as an example + +- Conformance test server implementations +- Internal test fixtures +- Source code of the SDK itself + +## Other Important Notes + +- If the SDK does not implement a feature at all, mark it as "FAIL" for documentation but note "Not implemented" in the Where column. +- Be thorough: check README, docs/, examples/, API references, and inline docstrings. +- Apply these criteria consistently across all features. Do not give credit for documentation that doesn't exist. diff --git a/.claude/skills/mcp-sdk-tier-audit/references/feature-list.md b/.claude/skills/mcp-sdk-tier-audit/references/feature-list.md new file mode 100644 index 0000000..b0e019a --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/references/feature-list.md @@ -0,0 +1,80 @@ +# MCP SDK Canonical Feature List + +Single source of truth for all MCP features evaluated in the tier audit. **48 non-experimental features** plus 5 experimental (informational only). + +When updating this list, also update the total count referenced in `docs-coverage-prompt.md`. + +## Non-Experimental Features (48 total) + +### Core Features (36) + +| # | Feature | Protocol Method | +| --- | ----------------------------------- | -------------------------------------- | +| 1 | Tools - listing | `tools/list` | +| 2 | Tools - calling | `tools/call` | +| 3 | Tools - text results | | +| 4 | Tools - image results | | +| 5 | Tools - audio results | | +| 6 | Tools - embedded resources | | +| 7 | Tools - error handling | | +| 8 | Tools - change notifications | `notifications/tools/list_changed` | +| 9 | Resources - listing | `resources/list` | +| 10 | Resources - reading text | `resources/read` | +| 11 | Resources - reading binary | `resources/read` | +| 12 | Resources - templates | `resources/templates/list` | +| 13 | Resources - template reading | | +| 14 | Resources - subscribing | `resources/subscribe` | +| 15 | Resources - unsubscribing | `resources/unsubscribe` | +| 16 | Resources - change notifications | `notifications/resources/list_changed` | +| 17 | Prompts - listing | `prompts/list` | +| 18 | Prompts - getting simple | `prompts/get` | +| 19 | Prompts - getting with arguments | `prompts/get` | +| 20 | Prompts - embedded resources | | +| 21 | Prompts - image content | | +| 22 | Prompts - change notifications | `notifications/prompts/list_changed` | +| 23 | Sampling - creating messages | `sampling/createMessage` | +| 24 | Elicitation - form mode | `elicitation/create` | +| 25 | Elicitation - URL mode | `elicitation/create` (mode: "url") | +| 26 | Elicitation - schema validation | | +| 27 | Elicitation - default values | | +| 28 | Elicitation - enum values | | +| 29 | Elicitation - complete notification | `notifications/elicitation/complete` | +| 30 | Roots - listing | `roots/list` | +| 31 | Roots - change notifications | `notifications/roots/list_changed` | +| 32 | Logging - sending log messages | `notifications/message` | +| 33 | Logging - setting level | `logging/setLevel` | +| 34 | Completions - resource argument | `completion/complete` | +| 35 | Completions - prompt argument | `completion/complete` | +| 36 | Ping | `ping` | + +### Transport Features (6) + +| # | Feature | +| --- | ---------------------------------- | +| 37 | Streamable HTTP transport (client) | +| 38 | Streamable HTTP transport (server) | +| 39 | SSE transport - legacy (client) | +| 40 | SSE transport - legacy (server) | +| 41 | stdio transport (client) | +| 42 | stdio transport (server) | + +### Protocol Features (6) + +| # | Feature | +| --- | ---------------------------- | +| 43 | Progress notifications | +| 44 | Cancellation | +| 45 | Pagination | +| 46 | Capability negotiation | +| 47 | Protocol version negotiation | +| 48 | JSON Schema 2020-12 support | + +## Experimental Features (5, informational only) + +| # | Feature | Protocol Method | +| --- | ---------------------------- | ---------------------------- | +| — | Tasks - get | `tasks/get` | +| — | Tasks - result | `tasks/result` | +| — | Tasks - cancel | `tasks/cancel` | +| — | Tasks - list | `tasks/list` | +| — | Tasks - status notifications | `notifications/tasks/status` | diff --git a/.claude/skills/mcp-sdk-tier-audit/references/policy-evaluation-prompt.md b/.claude/skills/mcp-sdk-tier-audit/references/policy-evaluation-prompt.md new file mode 100644 index 0000000..b3960e6 --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/references/policy-evaluation-prompt.md @@ -0,0 +1,145 @@ +# Policy Evaluation Prompt + +You are evaluating the governance and policy documentation of an MCP SDK repository for the SEP-1730 tier assessment. + +## Input + +- **SDK path**: {local-path} (absolute path to local SDK checkout) +- **Repository**: {repo} (GitHub `owner/repo`, derived from git remote) +- **CLI policy_signals**: {policy_signals_json} (from the tier-check CLI output — shows which files exist) + +## Your Task + +The CLI has already determined which policy files exist in the repository. Your job is to **read and evaluate the content** of the files that were found. Do NOT search for files in other locations — only evaluate what the CLI reported as present. + +Three policy areas to evaluate: + +1. **Dependency update policy** (required for Tier 1 and Tier 2) +2. **Roadmap** (Tier 1: published roadmap; Tier 2: published plan toward Tier 1) +3. **Versioning policy** (Tier 1 only: documented breaking change policy) + +## Steps + +### 1. Identify which files exist from CLI output + +From the `policy_signals.files` object in the CLI JSON output, note which files have `true` (exist) vs `false` (missing). + +The CLI checks these files: + +**Dependency policy**: `DEPENDENCY_POLICY.md`, `docs/dependency-policy.md`, `.github/dependabot.yml`, `.github/renovate.json`, `renovate.json` + +**Roadmap**: `ROADMAP.md`, `docs/roadmap.md` + +**Versioning**: `VERSIONING.md`, `docs/versioning.md`, `BREAKING_CHANGES.md` + +**General** (may contain relevant sections): `CONTRIBUTING.md` + +### 2. Read and evaluate files that exist + +For each file that the CLI reported as present, read its content at `{local-path}/{file}` and evaluate: + +- Is the content substantive (not just a placeholder title)? +- Does it meet the criteria below? + +**Do NOT** search the repo for policy information in other files. If the dedicated file doesn't exist, the policy is not published. + +## Evaluation Criteria + +### Dependency Update Policy + +**PASS** if any of these exist with substantive content: + +- `DEPENDENCY_POLICY.md` or `docs/dependency-policy.md` — must describe how and when dependencies are updated +- `.github/dependabot.yml` or `.github/renovate.json` or `renovate.json` — automated tooling counts as a published policy in practice + +**FAIL** if none of the above exist (per CLI output). + +### Roadmap + +**PASS for Tier 1**: `ROADMAP.md` or `docs/roadmap.md` exists with concrete work items tracking MCP spec components. + +**PASS for Tier 2**: Same file exists with at least a plan toward Tier 1, or explanation for remaining at Tier 2. + +**FAIL** if no roadmap file exists (per CLI output). + +### Versioning Policy + +**PASS for Tier 1** if any of these exist with substantive content: + +- `VERSIONING.md` or `docs/versioning.md` or `BREAKING_CHANGES.md` +- A clearly labeled "Versioning" or "Breaking Changes" section in `CONTRIBUTING.md` (only check if CONTRIBUTING.md exists per CLI output) + +The content must describe: what constitutes a breaking change, how breaking changes are communicated, and the versioning scheme. + +**Not required for Tier 2.** + +**FAIL** if no versioning documentation found in the above files. + +## Required Output Format + +```markdown +### Policy Evaluation Assessment + +**SDK path**: {local-path} +**Repository**: {repo} + +--- + +#### 1. Dependency Update Policy: {PASS/FAIL} + +| File | Exists (CLI) | Content Verdict | +| ------------------------- | ------------ | ------------------------------- | +| DEPENDENCY_POLICY.md | Yes/No | Substantive / Placeholder / N/A | +| docs/dependency-policy.md | Yes/No | Substantive / Placeholder / N/A | +| .github/dependabot.yml | Yes/No | Configured / N/A | +| .github/renovate.json | Yes/No | Configured / N/A | + +**Verdict**: **PASS/FAIL** — {one-line explanation} + +--- + +#### 2. Roadmap: {PASS/FAIL} + +| File | Exists (CLI) | Content Verdict | +| --------------- | ------------ | ------------------------------- | +| ROADMAP.md | Yes/No | Substantive / Placeholder / N/A | +| docs/roadmap.md | Yes/No | Substantive / Placeholder / N/A | + +**Verdict**: + +- **Tier 1**: **PASS/FAIL** — {one-line explanation} +- **Tier 2**: **PASS/FAIL** — {one-line explanation} + +--- + +#### 3. Versioning Policy: {PASS/FAIL} + +| File | Exists (CLI) | Content Verdict | +| ------------------------------------ | ------------ | ------------------------------- | +| VERSIONING.md | Yes/No | Substantive / Placeholder / N/A | +| docs/versioning.md | Yes/No | Substantive / Placeholder / N/A | +| BREAKING_CHANGES.md | Yes/No | Substantive / Placeholder / N/A | +| CONTRIBUTING.md (versioning section) | Yes/No | Found / Not found / N/A | + +**Verdict**: + +- **Tier 1**: **PASS/FAIL** — {one-line explanation} +- **Tier 2**: **N/A** — only requires stable release + +--- + +#### Overall Policy Summary + +| Policy Area | Tier 1 | Tier 2 | +| ------------------------ | --------- | --------- | +| Dependency Update Policy | PASS/FAIL | PASS/FAIL | +| Roadmap | PASS/FAIL | PASS/FAIL | +| Versioning Policy | PASS/FAIL | N/A | +``` + +## Important Notes + +- Only evaluate files the CLI reported as existing. Do not search the repo for alternatives. +- If a file exists but is just a placeholder (e.g., only has a title with no content), mark it as "Placeholder" and FAIL. +- Dependabot/Renovate config files pass automatically if they exist and are properly configured. +- CHANGELOG.md showing past releases does NOT count as a roadmap. diff --git a/.claude/skills/mcp-sdk-tier-audit/references/report-template.md b/.claude/skills/mcp-sdk-tier-audit/references/report-template.md new file mode 100644 index 0000000..d77e199 --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/references/report-template.md @@ -0,0 +1,144 @@ +# Report Templates + +Write two files to `results/` in the conformance repo: + +- `results/--assessment.md` +- `results/--remediation.md` + +## assessment.md + +```markdown +# MCP SDK Tier Audit: {repo} + +**Date**: {date} +**Branch**: {branch} +**Auditor**: mcp-sdk-tier-audit skill (automated + subagent evaluation) + +## Tier Assessment: Tier {X} + +{Brief 1-2 sentence summary of the overall assessment and key factors.} + +### Requirements Summary + +| # | Requirement | Tier 1 Standard | Tier 2 Standard | Current Value | T1? | T2? | Gap | +| --- | ----------------------- | --------------------------------- | ---------------------------- | --------------------------------- | ----------- | ----------- | ------------------ | +| 1a | Server Conformance | 100% pass rate | >= 80% pass rate | {X}% ({passed}/{total}) | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 1b | Client Conformance | 100% pass rate | >= 80% pass rate | {X}% ({passed}/{total}) | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 2 | Issue Triage | >= 90% within 2 biz days | >= 80% within 1 month | {compliance}% ({triaged}/{total}) | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 2b | Labels | 12 required labels | 12 required labels | {present}/{required} | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 3 | Critical Bug Resolution | All P0s within 7 days | All P0s within 2 weeks | {open P0 count} open | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 4 | Stable Release | Required + clear versioning | At least one stable release | {version} | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 4b | Spec Tracking | Timeline agreed per release | Within 6 months | {days_gap}d gap ({PASS/FAIL}) | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 5 | Documentation | Comprehensive w/ examples | Basic docs for core features | {pass}/{total} features | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 6 | Dependency Policy | Published update policy | Published update policy | {Found/Not found} | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 7 | Roadmap | Published roadmap | Plan toward Tier 1 | {Found/Not found} | {PASS/FAIL} | {PASS/FAIL} | {detail or "None"} | +| 8 | Versioning Policy | Documented breaking change policy | N/A | {Found/Not found} | {PASS/FAIL} | N/A | {detail or "None"} | + +### Tier Determination + +- Tier 1: {PASS/FAIL} -- {count}/8 requirements met (failing: {list}) +- Tier 2: {PASS/FAIL} -- {count}/7 requirements met (failing: {list}) +- **Final Tier: {X}** + +--- + +## Server Conformance Details + +Pass rate: {X}% ({passed}/{total}) + +| Scenario | Status | Checks | +| -------- | ----------- | ---------------- | +| {name} | {PASS/FAIL} | {passed}/{total} | +| ... | ... | ... | + +--- + +## Client Conformance Details + +Full suite pass rate: {X}% ({passed}/{total}) + +> **Suite breakdown**: Core: {core_pass}/{core_total} ({core_rate}%), Auth: {auth_pass}/{auth_total} ({auth_rate}%) +> **Baseline**: {N} known expected failures documented in `{baseline_file}` ({categories}) + +### Core Scenarios + +| Scenario | Status | Checks | +| -------- | ----------- | ---------------- | +| {name} | {PASS/FAIL} | {passed}/{total} | +| ... | ... | ... | + +### Auth Scenarios + +| Scenario | Status | Checks | Notes | +| -------- | ----------- | ---------------- | --------------------------- | +| {name} | {PASS/FAIL} | {passed}/{total} | {in baseline? / unexpected} | +| ... | ... | ... | ... | + +--- + +## Issue Triage Details + +Analysis period: Last {N} issues +Labels: {present/missing list} + +| Metric | Value | T1 Req | T2 Req | Verdict | +| --------------- | ----- | ------ | ------ | --------- | +| Compliance rate | {X}% | >= 90% | >= 80% | {verdict} | +| Exceeding SLA | {N} | -- | -- | -- | +| Open P0s | {N} | 0 | 0 | {verdict} | + +{If open P0s, list them with issue number, title, age} + +--- + +## Documentation Coverage + +{Paste subagent 1 output: feature table with Documented/Where/Examples/Verdict columns} + +--- + +## Policy Evaluation + +{Paste subagent 2 output: dependency policy, roadmap, versioning policy sections with evidence tables} +``` + +## remediation.md + +```markdown +# Remediation Guide: {repo} + +**Date**: {date} +**Current Tier**: {X} + +## Path to Tier 2 + +{Only include this section if current tier is 3. List every requirement not met for Tier 2.} + +| # | Action | Requirement | Effort | Where | +| --- | ------------- | ------------- | -------------------- | ------------ | +| 1 | {description} | {requirement} | {Small/Medium/Large} | {file paths} | +| ... | ... | ... | ... | ... | + +## Path to Tier 1 + +{Always include this section. List every requirement not met for Tier 1 (including any Tier 2 gaps).} + +| # | Action | Requirement | Effort | Where | +| --- | ------------- | ------------- | -------------------- | ------------ | +| 1 | {description} | {requirement} | {Small/Medium/Large} | {file paths} | +| ... | ... | ... | ... | ... | + +## Recommended Next Steps + +1. {First priority action with brief rationale} +2. {Second priority action} +3. {Third priority action} +``` + +## Formatting Rules + +1. Every PASS/FAIL must be based on evidence, not assumption. +2. If data is unavailable, mark as "N/A - {reason}" and note in remediation. +3. All file references must include file path and line numbers where possible. +4. Remediation items ordered by impact: tier-advancing items first. +5. Effort estimates: Small (< 1 day), Medium (1-3 days), Large (> 3 days). diff --git a/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md b/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md new file mode 100644 index 0000000..b36a2d2 --- /dev/null +++ b/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md @@ -0,0 +1,103 @@ +# SEP-1730: SDK Tier Requirements Reference + +This is the authoritative reference table for MCP SDK tiering requirements, extracted from SEP-1730. + +Source: `modelcontextprotocol/docs/community/sdk-tiers.mdx` in the spec repository + +## Full Requirements Table + +| Requirement | Tier 1: Fully Supported | Tier 2: Commitment to Full Support | Tier 3: Experimental | +| --------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------- | +| **Conformance Tests** | 100% pass rate | >= 80% pass rate | No minimum | +| **New Protocol Features** | Before new spec version release, timeline agreed per release based on feature complexity | Within 6 months | No timeline commitment | +| **Issue Triage** | Within 2 business days | Within a month | No requirement | +| **Critical Bug Resolution** | Within 7 days | Within two weeks | No requirement | +| **Stable Release** | Required with clear versioning | At least one stable release | Not required | +| **Documentation** | Comprehensive with examples for all features | Basic documentation covering core features | No minimum | +| **Dependency Policy** | Published update policy | Published update policy | Not required | +| **Roadmap** | Published roadmap | Published plan toward Tier 1 or explanation for remaining Tier 2 | Not required | + +## Exact Thresholds for Automated Checking + +| Metric | Tier 1 Threshold | Tier 2 Threshold | How to Measure | +| ---------------------- | ------------------------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------------------- | +| Conformance pass rate | == 100% | >= 80% | `passed / (passed + failed) * 100` from conformance suite | +| Issue triage time | <= 2 business days | <= 1 month (30 calendar days) | Time from issue creation to first label application | +| P0 resolution time | <= 7 calendar days | <= 14 calendar days | Time from P0 label application to issue close | +| Stable release version | >= 1.0.0, no pre-release suffix | >= 1.0.0 (at least one) | Check `gh release list` for version matching `^[0-9]+\.[0-9]+\.[0-9]+$` where major >= 1 | +| Documentation coverage | All non-experimental features documented with examples | Core features documented | Subagent evaluation | +| Dependency policy | Published and findable in repo | Published and findable in repo | Subagent evaluation | +| Roadmap | Published with concrete steps tracking spec components | Published plan toward Tier 1 | Subagent evaluation | +| Versioning policy | Documented breaking change policy | N/A (just needs stable release) | Subagent evaluation | + +## Conformance Score Calculation + +Conformance scores are calculated against **applicable required tests** only: + +- Tests for the specification version the SDK targets +- Excluding tests marked as pending or skipped +- Excluding tests for experimental features +- Excluding legacy backward-compatibility tests (unless the SDK claims legacy support) + +## Tier Relegation Rules + +- **Tier 1 to Tier 2**: Any conformance test fails continuously for 4 weeks +- **Tier 2 to Tier 3**: More than 20% of conformance tests fail continuously for 4 weeks + +## Issue Triage Label Taxonomy + +SDK repositories must use these consistent labels to enable automated reporting. + +### Type Labels (pick one) + +| Label | Description | +| ------------- | ----------------------------- | +| `bug` | Something isn't working | +| `enhancement` | Request for new feature | +| `question` | Further information requested | + +Note: Repositories using GitHub's native issue types satisfy this requirement without needing type labels. + +### Status Labels (pick one) + +| Label | Description | +| -------------------- | ------------------------------------------------------- | +| `needs confirmation` | Unclear if still relevant | +| `needs repro` | Insufficient information to reproduce | +| `ready for work` | Has enough information to start | +| `good first issue` | Good for newcomers | +| `help wanted` | Contributions welcome from those familiar with codebase | + +### Priority Labels (only if actionable) + +| Label | Description | +| ----- | --------------------------------------------------------------- | +| `P0` | Critical: core functionality failures or high-severity security | +| `P1` | Significant bug affecting many users | +| `P2` | Moderate issues, valuable feature requests | +| `P3` | Nice to haves, rare edge cases | + +**Total: 12 labels** (3 type + 5 status + 4 priority) + +## Key Definitions + +### Issue Triage + +Labeling and determining whether an issue is valid. This is NOT the same as resolving the issue. An issue is considered triaged when it receives its first label. + +### Critical Bug (P0) + +- **Security vulnerabilities** with CVSS score >= 7.0 (High or Critical severity) +- **Core functionality failures** that prevent basic MCP operations: connection establishment, message exchange, or use of core primitives (tools, resources, prompts) + +### Stable Release + +A published version explicitly marked as production-ready. Specifically: version `1.0.0` or higher without pre-release identifiers like `-alpha`, `-beta`, or `-rc`. + +### Clear Versioning + +Following idiomatic versioning patterns with documented breaking change policies, so users can understand compatibility expectations when upgrading. + +### Roadmap + +Outlines concrete steps and work items that track implementation of required MCP specification components (non-experimental features and optional capabilities), giving users visibility into upcoming feature support. diff --git a/README.md b/README.md index da649d5..8096214 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,29 @@ Run `npx @modelcontextprotocol/conformance list --server` to see all available s - **resources-\*** - Resource management scenarios - **prompts-\*** - Prompt management scenarios +## 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): + +```bash +# Without conformance tests (fastest) +gh auth login +npm run --silent tier-check -- --repo modelcontextprotocol/typescript-sdk --skip-conformance + +# With conformance tests (start the everything server first) +npm run --silent tier-check -- \ + --repo modelcontextprotocol/typescript-sdk \ + --conformance-server-url http://localhost:3000/mcp +``` + +For a full AI-assisted assessment with remediation guide, use Claude Code: + +``` +/mcp-sdk-tier-audit +``` + +See [`.claude/skills/mcp-sdk-tier-audit/README.md`](.claude/skills/mcp-sdk-tier-audit/README.md) for full documentation. + ## Architecture See `src/runner/DESIGN.md` for detailed architecture documentation. diff --git a/package-lock.json b/package-lock.json index 936103b..250c002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "@octokit/rest": "^22.0.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -39,14 +40,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -76,13 +77,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -92,9 +93,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -106,9 +107,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", - "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -118,9 +119,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", - "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -140,9 +141,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", - "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -157,9 +158,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", - "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -174,9 +175,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", - "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "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==", "cpu": [ "arm64" ], @@ -191,9 +192,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", - "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "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==", "cpu": [ "x64" ], @@ -208,9 +209,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", - "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -225,9 +226,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", - "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "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==", "cpu": [ "x64" ], @@ -242,9 +243,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", - "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -259,9 +260,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", - "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -276,9 +277,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", - "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -293,9 +294,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", - "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -310,9 +311,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", - "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -327,9 +328,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", - "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -344,9 +345,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", - "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -361,9 +362,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", - "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -378,9 +379,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", - "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -395,9 +396,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", - "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -412,9 +413,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", - "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -429,9 +430,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", - "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "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==", "cpu": [ "arm64" ], @@ -446,9 +447,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", - "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -463,9 +464,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", - "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "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==", "cpu": [ "arm64" ], @@ -480,9 +481,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", - "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "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==", "cpu": [ "x64" ], @@ -497,9 +498,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", - "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "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==", "cpu": [ "arm64" ], @@ -514,9 +515,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", - "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -531,9 +532,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", - "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -548,9 +549,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", - "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -565,9 +566,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", - "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "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==", "cpu": [ "x64" ], @@ -582,9 +583,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -651,7 +652,7 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-helpers/node_modules/@eslint/core": { + "node_modules/@eslint/core": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", @@ -664,23 +665,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -690,7 +678,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -701,10 +689,34 @@ "url": "https://opencollective.com/eslint" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -738,19 +750,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -894,39 +893,175 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/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==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", - "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", + "@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" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" } }, "node_modules/@oxc-project/types": { @@ -940,13 +1075,13 @@ } }, "node_modules/@quansync/fs": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-0.1.5.tgz", - "integrity": "sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", + "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", "dev": true, "license": "MIT", "dependencies": { - "quansync": "^0.2.11" + "quansync": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" @@ -1198,9 +1333,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "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" ], @@ -1212,9 +1347,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "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" ], @@ -1226,9 +1361,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "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" ], @@ -1240,9 +1375,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "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" ], @@ -1254,9 +1389,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "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" ], @@ -1268,9 +1403,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "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" ], @@ -1282,9 +1417,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "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" ], @@ -1296,9 +1431,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "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" ], @@ -1310,9 +1445,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "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" ], @@ -1324,9 +1459,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "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" ], @@ -1338,9 +1473,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "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" ], @@ -1352,9 +1487,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "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" ], @@ -1366,9 +1501,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "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" ], @@ -1380,9 +1515,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "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" ], @@ -1394,9 +1529,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "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" ], @@ -1408,9 +1543,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "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" ], @@ -1422,9 +1557,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "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" ], @@ -1436,9 +1571,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "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" ], @@ -1450,9 +1585,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "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" ], @@ -1464,9 +1599,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "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" ], @@ -1478,9 +1613,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "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" ], @@ -1492,9 +1627,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "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" ], @@ -1506,9 +1641,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "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" ], @@ -1520,9 +1655,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "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" ], @@ -1534,9 +1669,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "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" ], @@ -1612,21 +1747,21 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, "license": "MIT", "dependencies": { @@ -1647,13 +1782,15 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.12.tgz", - "integrity": "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog==", + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } @@ -1673,9 +1810,9 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", - "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1951,28 +2088,28 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-EiPEgGwNa2uHyyKgeoWTL6wWHKUBmF3xsfZ3OHofk7TxUuxb2mpLG5igEuaBe8iUwkCUl9uZgJvOu6o0wE5NSA==", + "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==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251201.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251201.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20251201.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251201.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20251201.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251201.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20251201.1" + "@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" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-PY0BrlRF3YCZEMxzuk79IFSgpGqUErkdrW7Aq+/mF8DEET5uaDypTMb8Vz4CLYJ7Xvvxz8eZsLimPbv6hYDIvA==", + "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==", "cpu": [ "arm64" ], @@ -1984,9 +2121,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-YeDrjnsvXwm/MNG8aURT3J+cmHQIhpiElBKOVOy/H6ky4S2Ro9ufG+Bj9CqS3etbTCLhV5btk+QNh86DZ4VDkQ==", + "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==", "cpu": [ "x64" ], @@ -1998,9 +2135,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-gr2EQYK888YdGROMc7l3N3MeKY1V3QVImKIQZNgqprV+N2rXaFnxGAZ+gql3LqZgRGel4a12vCUJeP7Pjl2gww==", + "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==", "cpu": [ "arm" ], @@ -2012,9 +2149,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-HbEn+SBTDZEtwN/VUxA2To+6vEr7x++SCRc6yGp5y4onpBL2xnH17UoxWiqN9J4Bu1DbQ9jZv3D5CzwBlofPQA==", + "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==", "cpu": [ "arm64" ], @@ -2026,9 +2163,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-q94K/LZ3Ab/SbUBMBsf37VdsumeZ1dZmymJYlhGBqk/fdXBayL0diLR3RdzyeQWbCXAxWL5KFKLIiIc3cI/fcA==", + "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==", "cpu": [ "x64" ], @@ -2040,9 +2177,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-/AFwpsX/G05bBsfVURfg4+/JC6gfvqj9jfFe/7oe1Y1J42koN5C8TH+eSmMOOEcPYpFjR1e+NWckqBJKaCXJ4A==", + "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==", "cpu": [ "arm64" ], @@ -2054,9 +2191,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20251201.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251201.1.tgz", - "integrity": "sha512-vTUCDEuSP4ifLHqb8aljuj44v6+M1HDKo1WLnboTDpwU7IIrTux/0jzkPfEHd9xd5FU4EhSA8ZrYDwKI0BcRcg==", + "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==", "cpu": [ "x64" ], @@ -2068,16 +2205,16 @@ ] }, "node_modules/@vitest/expect": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", - "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -2086,13 +2223,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", - "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.16", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2113,9 +2250,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", - "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { @@ -2126,13 +2263,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", - "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.16", + "@vitest/utils": "4.0.18", "pathe": "^2.0.3" }, "funding": { @@ -2140,13 +2277,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", - "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2155,9 +2292,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", - "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", "funding": { @@ -2165,13 +2302,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", - "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.16", + "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2209,21 +2346,21 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2247,28 +2384,6 @@ } } }, - "node_modules/ajv-formats/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==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2313,13 +2428,13 @@ } }, "node_modules/ast-kit": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.3.tgz", - "integrity": "sha512-TH+b3Lv6pUjy/Nu0m6A2JULtdzLpmqF9x1Dhj00ZoEiML8qvVA9j1flkzTKNYgdEhWrjDwtWNpyyCUbfQe514g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.4", + "@babel/parser": "^7.28.5", "pathe": "^2.0.3" }, "engines": { @@ -2329,24 +2444,6 @@ "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/async-generator-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-generator-function/-/async-generator-function-1.0.0.tgz", - "integrity": "sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2354,10 +2451,16 @@ "dev": true, "license": "MIT" }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, "node_modules/birpc": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz", - "integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", "dev": true, "license": "MIT", "funding": { @@ -2365,9 +2468,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", @@ -2376,7 +2479,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" }, @@ -2521,9 +2624,9 @@ "license": "MIT" }, "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { "node": ">=20" @@ -2537,15 +2640,16 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -2576,9 +2680,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", @@ -2586,6 +2690,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -2653,13 +2761,13 @@ } }, "node_modules/dts-resolver": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.2.tgz", - "integrity": "sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz", + "integrity": "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.0" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" @@ -2750,9 +2858,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", - "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2763,32 +2871,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.11", - "@esbuild/android-arm": "0.25.11", - "@esbuild/android-arm64": "0.25.11", - "@esbuild/android-x64": "0.25.11", - "@esbuild/darwin-arm64": "0.25.11", - "@esbuild/darwin-x64": "0.25.11", - "@esbuild/freebsd-arm64": "0.25.11", - "@esbuild/freebsd-x64": "0.25.11", - "@esbuild/linux-arm": "0.25.11", - "@esbuild/linux-arm64": "0.25.11", - "@esbuild/linux-ia32": "0.25.11", - "@esbuild/linux-loong64": "0.25.11", - "@esbuild/linux-mips64el": "0.25.11", - "@esbuild/linux-ppc64": "0.25.11", - "@esbuild/linux-riscv64": "0.25.11", - "@esbuild/linux-s390x": "0.25.11", - "@esbuild/linux-x64": "0.25.11", - "@esbuild/netbsd-arm64": "0.25.11", - "@esbuild/netbsd-x64": "0.25.11", - "@esbuild/openbsd-arm64": "0.25.11", - "@esbuild/openbsd-x64": "0.25.11", - "@esbuild/openharmony-arm64": "0.25.11", - "@esbuild/sunos-x64": "0.25.11", - "@esbuild/win32-arm64": "0.25.11", - "@esbuild/win32-ia32": "0.25.11", - "@esbuild/win32-x64": "0.25.11" + "@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" } }, "node_modules/escape-html": { @@ -2811,19 +2919,20 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "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-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2915,19 +3024,30 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "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==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://eslint.org/donate" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2947,9 +3067,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3033,9 +3153,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3103,6 +3223,22 @@ "express": ">= 4.11" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3162,6 +3298,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, @@ -3170,9 +3307,9 @@ } }, "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", @@ -3183,7 +3320,11 @@ "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/find-up": { @@ -3247,6 +3388,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -3265,29 +3407,17 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-intrinsic": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.1.tgz", - "integrity": "sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "async-generator-function": "^1.0.0", "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "generator-function": "^2.0.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", @@ -3315,9 +3445,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.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", "dependencies": { @@ -3407,9 +3537,9 @@ } }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -3423,34 +3553,29 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "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" @@ -3612,10 +3737,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-schema-typed": { @@ -3642,31 +3766,32 @@ } }, "node_modules/lefthook": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.0.2.tgz", - "integrity": "sha512-2lrSva53G604ZWjK5kHYvDdwb5GzbhciIPWhebv0A8ceveqSsnG2JgVEt+DnhOPZ4VfNcXvt3/ohFBPNpuAlVw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.0.tgz", + "integrity": "sha512-+vS+yywGQW6CN1J1hbGkez//6ixGHIQqfxDN/d3JDm531w9GfGt2lAWTDfZTw/CEl80XsN0raFcnEraR3ldw9g==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.0.2", - "lefthook-darwin-x64": "2.0.2", - "lefthook-freebsd-arm64": "2.0.2", - "lefthook-freebsd-x64": "2.0.2", - "lefthook-linux-arm64": "2.0.2", - "lefthook-linux-x64": "2.0.2", - "lefthook-openbsd-arm64": "2.0.2", - "lefthook-openbsd-x64": "2.0.2", - "lefthook-windows-arm64": "2.0.2", - "lefthook-windows-x64": "2.0.2" + "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" } }, "node_modules/lefthook-darwin-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.0.2.tgz", - "integrity": "sha512-x/4AOinpMS2abZyA/krDd50cRPZit/6P670Z1mJjfS0+fPZkFw7AXpjxroiN0rgglg78vD7BwcA5331z4YZa5g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.0.tgz", + "integrity": "sha512-u2hjHLQXWSFfzO7ln2n/uEydSzfC9sc5cDC7tvKSuOdhvBwaJ0AQ7ZeuqqCQ4YfVIJfYOom1SVE9CBd10FVyig==", "cpu": [ "arm64" ], @@ -3678,9 +3803,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.0.2.tgz", - "integrity": "sha512-MSb8XZBfmlNvCpuLiQqrJS+sPiSEAyuoHOMZOHjlceYqO0leVVw9YfePVcb4Vi/PqOYngTdJk83MmYvqhsSNTQ==", + "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==", "cpu": [ "x64" ], @@ -3692,9 +3817,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.0.2.tgz", - "integrity": "sha512-gewPsUPc3J/n2/RrhHLS9jtL3qK4HcTED25vfExhvFRW3eT1SDYaBbXnUUmB8SE0zE8Bl6AfEdT2zzZcPbOFuA==", + "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==", "cpu": [ "arm64" ], @@ -3706,9 +3831,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.0.2.tgz", - "integrity": "sha512-fsLlaChiKAWiSavQO2LXPR8Z9OcBnyMDvmkIlXC0lG3SjBb9xbVdBdDVlcrsUyDCs5YstmGYHuzw6DfJYpAE1g==", + "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==", "cpu": [ "x64" ], @@ -3720,9 +3845,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.0.2.tgz", - "integrity": "sha512-vNl3HiZud9T2nGHMngvLw3hSJgutjlN/Lzf5/5jKt/2IIuyd9L3UYktWC9HLUb03Zukr7jeaxG3+VxdAohQwAw==", + "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==", "cpu": [ "arm64" ], @@ -3734,9 +3859,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.0.2.tgz", - "integrity": "sha512-0ghHMPu4fixIieS8V2k2yZHvcFd9pP0q+sIAIaWo8x7ce/AOQIXFCPHGPAOc8/wi5uVtfyEvCnhxIDKf+lHA2A==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.0.tgz", + "integrity": "sha512-6Bxmv+l7LiYq9W0IE6v2lmlRtBp6pisnlzhcouMGvH3rDwEGw11NAyRJZA3IPGEMAkIuhnlnVTUwAUzKomfJLg==", "cpu": [ "x64" ], @@ -3748,9 +3873,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.0.2.tgz", - "integrity": "sha512-qfXnDM8jffut9rylvi3T+HOqlNRkFYqIDUXeVXlY7dmwCW4u2K46p0W4M3BmAVUeL/MRxBRnjze//Yy6aCbGQw==", + "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==", "cpu": [ "arm64" ], @@ -3762,9 +3887,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.0.2.tgz", - "integrity": "sha512-RXqR0FiDTwsQv1X3QVsuBFneWeNXS+tmPFIX8F6Wz9yDPHF8+vBnkWCju6HdkTVTY71Ba5HbYGKEVDvscJkU7Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.0.tgz", + "integrity": "sha512-8k9lQsMYqQGu4spaQ8RNSOJidxIcOyfaoF2FPZhthtBfRV3cgVFGrsQ0hbIi5pvQRGUlCqYuCN79qauXHmnL3Q==", "cpu": [ "x64" ], @@ -3776,9 +3901,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.0.2.tgz", - "integrity": "sha512-KfLKhiUPHP9Aea+9D7or2hgL9wtKEV+GHpx7LBg82ZhCXkAml6rop7mWsBgL80xPYLqMahKolZGO+8z5H6W4HQ==", + "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==", "cpu": [ "arm64" ], @@ -3790,9 +3915,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.0.2.tgz", - "integrity": "sha512-TdysWxGRNtuRg5bN6Uj00tZJIsHTrF/7FavoR5rp1sq21QJhJi36M4I3UVlmOKAUCKhibAIAauZWmX7yaW3eHA==", + "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==", "cpu": [ "x64" ], @@ -3837,7 +3962,8 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", @@ -3889,15 +4015,19 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/minimatch": { @@ -4188,6 +4318,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -4237,9 +4368,9 @@ } }, "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", "dev": true, "funding": [ { @@ -4263,15 +4394,15 @@ } }, "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" @@ -4325,6 +4456,7 @@ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz", "integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==", "dev": true, + "license": "MIT", "dependencies": { "@oxc-project/types": "=0.95.0", "@rolldown/pluginutils": "1.0.0-beta.45" @@ -4353,24 +4485,24 @@ } }, "node_modules/rolldown-plugin-dts": { - "version": "0.17.3", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.3.tgz", - "integrity": "sha512-8mGnNUVNrqEdTnrlcaDxs4sAZg0No6njO+FuhQd4L56nUbJO1tHxOoKDH3mmMJg7f/BhEj/1KjU5W9kZ9zM/kQ==", + "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==", "dev": true, "license": "MIT", "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", - "ast-kit": "^2.1.3", - "birpc": "^2.6.1", - "debug": "^4.4.3", - "dts-resolver": "^2.1.2", + "ast-kit": "^2.2.0", + "birpc": "^2.8.0", + "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.0", - "magic-string": "^0.30.21" + "magic-string": "^0.30.21", + "obug": "^2.0.0" }, "engines": { - "node": ">=20.18.0" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/sponsors/sxzz" @@ -4398,9 +4530,9 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "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": { @@ -4414,31 +4546,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@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" } }, @@ -4458,26 +4590,6 @@ "node": ">= 18" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4485,9 +4597,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4498,31 +4610,35 @@ } }, "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", @@ -4532,6 +4648,10 @@ }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -4544,6 +4664,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4762,9 +4883,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "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==", "dev": true, "license": "MIT", "engines": { @@ -4843,13 +4964,13 @@ "optional": true }, "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": { @@ -4894,6 +5015,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4927,24 +5049,40 @@ } }, "node_modules/unconfig": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.3.3.tgz", - "integrity": "sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==", + "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": "^0.1.5", + "@quansync/fs": "^1.0.0", "defu": "^6.1.4", - "jiti": "^2.5.1", - "quansync": "^0.2.11" + "jiti": "^2.6.1", + "quansync": "^1.0.0", + "unconfig-core": "7.4.2" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/undici": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz", - "integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -4957,6 +5095,12 @@ "dev": true, "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4986,9 +5130,9 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { @@ -5060,504 +5204,20 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, "node_modules/vitest": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", - "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.16", - "@vitest/mocker": "4.0.16", - "@vitest/pretty-format": "4.0.16", - "@vitest/runner": "4.0.16", - "@vitest/snapshot": "4.0.16", - "@vitest/spy": "4.0.16", - "@vitest/utils": "4.0.16", + "@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", "magic-string": "^0.30.21", @@ -5585,10 +5245,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.16", - "@vitest/browser-preview": "4.0.16", - "@vitest/browser-webdriverio": "4.0.16", - "@vitest/ui": "4.0.16", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -5667,7 +5327,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, "node_modules/yaml": { diff --git a/package.json b/package.json index 09ff1d0..a9f1e8f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "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", "check": "npm run typecheck && npm run lint", "typecheck": "tsgo --noEmit", "prepack": "npm run build" @@ -52,6 +53,7 @@ "jose": "^6.1.2", "undici": "^7.19.0", "yaml": "^2.8.2", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@octokit/rest": "^22.0.0" } } diff --git a/src/index.ts b/src/index.ts index 7be7ed0..d51eb0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { evaluateBaseline, printBaselineResults } from './expected-failures'; +import { createTierCheckCommand } from './tier-check'; import packageJson from '../package.json'; const program = new Command(); @@ -383,6 +384,9 @@ program } }); +// Tier check command +program.addCommand(createTierCheckCommand()); + // List scenarios command program .command('list') diff --git a/src/tier-check/checks/files.ts b/src/tier-check/checks/files.ts new file mode 100644 index 0000000..1fd768d --- /dev/null +++ b/src/tier-check/checks/files.ts @@ -0,0 +1,58 @@ +import { Octokit } from '@octokit/rest'; +import { PolicySignalsResult } from '../types'; + +// Policy files checked deterministically by the CLI. +// The AI policy evaluation then reads ONLY files that exist here +// to judge whether content is substantive — it does not search for +// files in other locations. +const POLICY_SIGNAL_FILES = [ + // General project health + 'CHANGELOG.md', + 'SECURITY.md', + 'CONTRIBUTING.md', + // Dependency update policy + 'DEPENDENCY_POLICY.md', + 'docs/dependency-policy.md', + '.github/dependabot.yml', + '.github/renovate.json', + 'renovate.json', + // Roadmap + 'ROADMAP.md', + 'docs/roadmap.md', + // Versioning / breaking change policy + 'VERSIONING.md', + 'docs/versioning.md', + 'BREAKING_CHANGES.md' +]; + +export async function checkPolicySignals( + octokit: Octokit, + owner: string, + repo: string, + branch?: string +): Promise { + const files: Record = {}; + + for (const filePath of POLICY_SIGNAL_FILES) { + try { + await octokit.repos.getContent({ + owner, + repo, + path: filePath, + ...(branch ? { ref: branch } : {}) + }); + files[filePath] = true; + } catch { + files[filePath] = false; + } + } + + return { + status: Object.values(files).every((v) => v) + ? 'pass' + : Object.values(files).some((v) => v) + ? 'partial' + : 'fail', + files + }; +} diff --git a/src/tier-check/checks/labels.ts b/src/tier-check/checks/labels.ts new file mode 100644 index 0000000..a2b37b7 --- /dev/null +++ b/src/tier-check/checks/labels.ts @@ -0,0 +1,72 @@ +import { Octokit } from '@octokit/rest'; +import { LabelsResult } from '../types'; + +// Type labels can be satisfied by GitHub's native issue types (Bug, Enhancement, Question) +const TYPE_LABELS = ['bug', 'enhancement', 'question']; + +const STATUS_LABELS = [ + 'needs confirmation', + 'needs repro', + 'ready for work', + 'good first issue', + 'help wanted' +]; + +const PRIORITY_LABELS = ['P0', 'P1', 'P2', 'P3']; + +export async function checkLabels( + octokit: Octokit, + owner: string, + repo: string +): Promise { + const labels: string[] = []; + let page = 1; + while (true) { + const { data } = await octokit.issues.listLabelsForRepo({ + owner, + repo, + per_page: 100, + page + }); + labels.push(...data.map((l) => l.name)); + if (data.length < 100) break; + page++; + } + + // Check if the repo uses GitHub's native issue types + // If so, type labels (bug/enhancement/question) are satisfied + let usesIssueTypes = false; + try { + const { data: repoData } = await octokit.request( + 'GET /repos/{owner}/{repo}', + { owner, repo } + ); + // Repos with issue types enabled have them configured at the org or repo level. + // We detect this by checking for the presence of issue type configuration. + // As a heuristic: if the repo has no type labels but has issues, it likely uses types. + usesIssueTypes = !!(repoData as Record).issue_types; + } catch { + // If we can't determine, assume labels are needed + } + + const labelSet = new Set(labels.map((l) => l.toLowerCase())); + + // Build required labels list, excluding type labels if issue types are used + const requiredLabels = [ + ...(usesIssueTypes ? [] : TYPE_LABELS), + ...STATUS_LABELS, + ...PRIORITY_LABELS + ]; + + const missing = requiredLabels.filter((l) => !labelSet.has(l.toLowerCase())); + const found = requiredLabels.filter((l) => labelSet.has(l.toLowerCase())); + + return { + status: missing.length === 0 ? 'pass' : 'fail', + present: found.length, + required: requiredLabels.length, + missing, + found, + uses_issue_types: usesIssueTypes + }; +} diff --git a/src/tier-check/checks/p0.ts b/src/tier-check/checks/p0.ts new file mode 100644 index 0000000..25fad15 --- /dev/null +++ b/src/tier-check/checks/p0.ts @@ -0,0 +1,87 @@ +import { Octokit } from '@octokit/rest'; +import { P0Result } from '../types'; + +export async function checkP0Resolution( + octokit: Octokit, + owner: string, + repo: string +): Promise { + // Fetch all issues with P0 label + const p0Issues: Array<{ + number: number; + title: string; + state: string; + created_at: string; + closed_at: string | null; + labels: Array<{ name: string }>; + }> = []; + + let page = 1; + while (true) { + const { data } = await octokit.issues.listForRepo({ + owner, + repo, + labels: 'P0', + state: 'all', + per_page: 100, + page + }); + for (const issue of data) { + if (issue.pull_request) continue; + p0Issues.push({ + number: issue.number, + title: issue.title, + state: issue.state, + created_at: issue.created_at, + closed_at: issue.closed_at ?? null, + labels: issue.labels.filter( + (l): l is { name: string } => + typeof l === 'object' && l !== null && 'name' in l + ) + }); + } + if (data.length < 100) break; + page++; + } + + const openP0s = p0Issues.filter((i) => i.state === 'open'); + const closedP0s = p0Issues.filter((i) => i.state === 'closed' && i.closed_at); + + let closedWithin7d = 0; + let closedWithin14d = 0; + + for (const issue of closedP0s) { + const daysToClose = + (new Date(issue.closed_at!).getTime() - + new Date(issue.created_at).getTime()) / + (1000 * 60 * 60 * 24); + if (daysToClose <= 7) closedWithin7d++; + if (daysToClose <= 14) closedWithin14d++; + } + + const openP0Details = openP0s.map((i) => ({ + number: i.number, + title: i.title, + age_days: Math.round( + (Date.now() - new Date(i.created_at).getTime()) / (1000 * 60 * 60 * 24) + ) + })); + + const allResolved7d = + openP0s.length === 0 && + (closedP0s.length === 0 || closedWithin7d === closedP0s.length); + const allResolved14d = + openP0s.length === 0 && + (closedP0s.length === 0 || closedWithin14d === closedP0s.length); + + return { + status: allResolved7d ? 'pass' : allResolved14d ? 'partial' : 'fail', + open_p0s: openP0s.length, + open_p0_details: openP0Details, + closed_within_7d: closedWithin7d, + closed_within_14d: closedWithin14d, + closed_total: closedP0s.length, + all_p0s_resolved_within_7d: allResolved7d, + all_p0s_resolved_within_14d: allResolved14d + }; +} diff --git a/src/tier-check/checks/release.ts b/src/tier-check/checks/release.ts new file mode 100644 index 0000000..3eb53f1 --- /dev/null +++ b/src/tier-check/checks/release.ts @@ -0,0 +1,59 @@ +import { Octokit } from '@octokit/rest'; +import { ReleaseResult } from '../types'; + +export async function checkStableRelease( + octokit: Octokit, + owner: string, + repo: string +): Promise { + try { + const { data: releases } = await octokit.repos.listReleases({ + owner, + repo, + per_page: 20 + }); + + if (releases.length === 0) { + return { + status: 'fail', + version: null, + is_stable: false, + is_prerelease: false + }; + } + + // Find latest non-draft release + const latest = releases.find((r) => !r.draft); + if (!latest) { + return { + status: 'fail', + version: null, + is_stable: false, + is_prerelease: false + }; + } + + const version = latest.tag_name.replace(/^v/, ''); + const isPrerelease = + latest.prerelease || + /-(alpha|beta|rc|dev|preview|snapshot)/i.test(version); + + // Check if version is >= 1.0.0 + const parts = version.split('.').map((p) => parseInt(p, 10)); + const isStable = !isPrerelease && parts.length >= 2 && parts[0] >= 1; + + return { + status: isStable ? 'pass' : 'fail', + version, + is_stable: isStable, + is_prerelease: isPrerelease + }; + } catch { + return { + status: 'fail', + version: null, + is_stable: false, + is_prerelease: false + }; + } +} diff --git a/src/tier-check/checks/spec-tracking.ts b/src/tier-check/checks/spec-tracking.ts new file mode 100644 index 0000000..ce4c101 --- /dev/null +++ b/src/tier-check/checks/spec-tracking.ts @@ -0,0 +1,79 @@ +import { Octokit } from '@octokit/rest'; +import { SpecTrackingResult } from '../types'; + +export async function checkSpecTracking( + octokit: Octokit, + owner: string, + repo: string +): Promise { + try { + // Get latest spec release from modelcontextprotocol/modelcontextprotocol + const { data: specReleases } = await octokit.repos.listReleases({ + owner: 'modelcontextprotocol', + repo: 'modelcontextprotocol', + per_page: 5 + }); + const latestSpec = specReleases.find((r) => !r.draft && !r.prerelease); + + // Get SDK releases (API returns newest-first) + const { data: sdkReleases } = await octokit.repos.listReleases({ + owner, + repo, + per_page: 50 + }); + const nonDraftSdkReleases = sdkReleases.filter((r) => !r.draft); + + if (!latestSpec || nonDraftSdkReleases.length === 0) { + return { + status: 'skipped', + latest_spec_release: latestSpec?.published_at || null, + latest_sdk_release: nonDraftSdkReleases[0]?.published_at || null, + sdk_release_within_30d: null, + days_gap: null + }; + } + + const specDate = new Date(latestSpec.published_at!); + + // 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 + ); + + if (!firstSdkAfterSpec) { + // No SDK release after the latest spec release + const daysSinceSpec = Math.round( + (Date.now() - specDate.getTime()) / (1000 * 60 * 60 * 24) + ); + return { + status: daysSinceSpec <= 30 ? 'pass' : 'fail', + latest_spec_release: latestSpec.published_at, + latest_sdk_release: nonDraftSdkReleases[0]?.published_at || null, + sdk_release_within_30d: daysSinceSpec <= 30, + days_gap: daysSinceSpec + }; + } + + const sdkDate = new Date(firstSdkAfterSpec.published_at!); + const daysGap = Math.round( + (sdkDate.getTime() - specDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + return { + status: daysGap <= 30 ? 'pass' : 'fail', + latest_spec_release: latestSpec.published_at, + latest_sdk_release: firstSdkAfterSpec.published_at, + sdk_release_within_30d: daysGap <= 30, + days_gap: daysGap + }; + } catch { + return { + status: 'skipped', + latest_spec_release: null, + latest_sdk_release: null, + sdk_release_within_30d: null, + days_gap: null + }; + } +} diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts new file mode 100644 index 0000000..bef2059 --- /dev/null +++ b/src/tier-check/checks/test-conformance-results.ts @@ -0,0 +1,203 @@ +import { execSync } from 'child_process'; +import { mkdtempSync, readFileSync, readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { ConformanceResult } from '../types'; +import { listScenarios, listActiveClientScenarios } from '../../scenarios'; +import { ConformanceCheck } from '../../types'; + +/** + * Parse conformance results from an output directory. + * The conformance CLI saves checks.json per scenario under outputDir//server/ or client/. + */ +function parseOutputDir(outputDir: string): ConformanceResult { + if (!existsSync(outputDir)) { + return { + status: 'fail', + pass_rate: 0, + passed: 0, + failed: 0, + total: 0, + details: [] + }; + } + + const details: ConformanceResult['details'] = []; + let totalPassed = 0; + let totalFailed = 0; + + const entries = readdirSync(outputDir); + for (const scenarioName of entries) { + const checksPath = join(outputDir, scenarioName, 'checks.json'); + if (!existsSync(checksPath)) continue; + + try { + const checks: ConformanceCheck[] = JSON.parse( + readFileSync(checksPath, 'utf-8') + ); + const passed = checks.filter((c) => c.status === 'SUCCESS').length; + const failed = checks.filter((c) => c.status === 'FAILURE').length; + const scenarioPassed = failed === 0 && passed > 0; + + totalPassed += scenarioPassed ? 1 : 0; + totalFailed += scenarioPassed ? 0 : 1; + details.push({ + scenario: scenarioName, + passed: scenarioPassed, + checks_passed: passed, + checks_failed: failed + }); + } catch { + totalFailed++; + details.push({ + scenario: scenarioName, + passed: false, + checks_passed: 0, + checks_failed: 1 + }); + } + } + + const total = totalPassed + totalFailed; + const pass_rate = total > 0 ? totalPassed / total : 0; + + return { + status: pass_rate >= 1.0 ? 'pass' : pass_rate >= 0.8 ? 'partial' : 'fail', + pass_rate, + passed: totalPassed, + failed: totalFailed, + total, + details + }; +} + +/** + * Strip the timestamp suffix from a result directory name. + * Result dirs are named `{scenario}-{ISO timestamp}` where the timestamp + * has colons/dots replaced with dashes (e.g., `initialize-2026-02-12T16-08-37-806Z`). + * Server scenarios also have a `server-` prefix (e.g., `server-ping-2026-02-12T16-08-37-806Z`). + */ +function stripTimestamp(dirName: string): string { + return dirName.replace(/-\d{4}-\d{2}-\d{2}T[\d-]+Z$/, ''); +} + +/** + * Reconcile parsed results against the full list of expected scenarios. + * Any expected scenario that didn't produce results is counted as a failure. + * This ensures the denominator reflects the full test suite, not just + * scenarios that ran successfully enough to write checks.json. + */ +function reconcileWithExpected( + result: ConformanceResult, + expectedScenarios: string[], + resultPrefix?: string +): ConformanceResult { + const reportedNames = new Set( + result.details.map((d) => { + let name = stripTimestamp(d.scenario); + if (resultPrefix) { + name = name.replace(new RegExp(`^${resultPrefix}-`), ''); + } + return name; + }) + ); + + for (const expected of expectedScenarios) { + if (!reportedNames.has(expected)) { + result.failed++; + result.total++; + result.details.push({ + scenario: expected, + passed: false, + checks_passed: 0, + checks_failed: 0 + }); + } + } + + result.pass_rate = result.total > 0 ? result.passed / result.total : 0; + result.status = + result.pass_rate >= 1.0 + ? 'pass' + : result.pass_rate >= 0.8 + ? 'partial' + : 'fail'; + + return result; +} + +/** + * Run server conformance tests by shelling out to the conformance CLI. + */ +export async function checkConformance(options: { + serverUrl?: string; + skip?: boolean; +}): Promise { + if (options.skip || !options.serverUrl) { + return { + status: 'skipped', + pass_rate: 0, + passed: 0, + failed: 0, + total: 0, + details: [] + }; + } + + 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 + } + + return reconcileWithExpected( + parseOutputDir(outputDir), + listActiveClientScenarios(), + 'server' + ); +} + +/** + * Run client conformance tests by shelling out to the conformance CLI. + */ +export async function checkClientConformance(options: { + clientCmd?: string; + skip?: boolean; +}): Promise { + if (options.skip || !options.clientCmd) { + return { + status: 'skipped', + pass_rate: 0, + passed: 0, + failed: 0, + total: 0, + details: [] + }; + } + + 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 + } + + return reconcileWithExpected(parseOutputDir(outputDir), listScenarios()); +} diff --git a/src/tier-check/checks/triage.ts b/src/tier-check/checks/triage.ts new file mode 100644 index 0000000..523f4ec --- /dev/null +++ b/src/tier-check/checks/triage.ts @@ -0,0 +1,109 @@ +import { Octokit } from '@octokit/rest'; +import { TriageResult } from '../types'; + +export async function checkTriage( + octokit: Octokit, + owner: string, + repo: string, + days?: number +): Promise { + const since = days + ? new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString() + : undefined; + + // Fetch issues (not PRs) — the API returns labels inline + const issues: Array<{ + number: number; + created_at: string; + labels: string[]; + }> = []; + + let page = 1; + while (true) { + const { data } = await octokit.issues.listForRepo({ + owner, + repo, + state: 'open', + ...(since ? { since } : {}), + per_page: 100, + page, + sort: 'created', + direction: 'desc' + }); + if (data.length === 0) break; + for (const issue of data) { + if (issue.pull_request) continue; + if (since && new Date(issue.created_at) < new Date(since)) continue; + issues.push({ + number: issue.number, + created_at: issue.created_at, + labels: issue.labels + .filter( + (l): l is { name: string } => + typeof l === 'object' && l !== null && 'name' in l + ) + .map((l) => l.name) + }); + } + if (data.length < 100) break; + page++; + } + + if (issues.length === 0) { + return { + status: 'pass', + compliance_rate: 1, + total_issues: 0, + triaged_within_sla: 0, + exceeding_sla: 0, + median_hours: 0, + p95_hours: 0, + days_analyzed: days + }; + } + + // An issue is "triaged" if it has at least one label + const triaged = issues.filter((i) => i.labels.length > 0); + const untriaged = issues.filter((i) => i.labels.length === 0); + + // For untriaged issues, compute how long they've been open without a label + const untriagedAgeHours = untriaged.map( + (i) => (Date.now() - new Date(i.created_at).getTime()) / (1000 * 60 * 60) + ); + untriagedAgeHours.sort((a, b) => a - b); + + const total = issues.length; + const triagedRate = total > 0 ? triaged.length / total : 1; + + // For SLA: issues without labels that are older than 2BD are SLA violations + const TWO_BUSINESS_DAYS_HOURS = 2 * 24; + const exceeding = untriagedAgeHours.filter( + (h) => h > TWO_BUSINESS_DAYS_HOURS + ).length; + + // Median/p95 of untriaged issue ages (0 if all triaged) + const median = + untriagedAgeHours.length > 0 + ? untriagedAgeHours[Math.floor(untriagedAgeHours.length / 2)] + : 0; + const p95 = + untriagedAgeHours.length > 0 + ? untriagedAgeHours[Math.floor(untriagedAgeHours.length * 0.95)] + : 0; + + let status: 'pass' | 'partial' | 'fail'; + if (triagedRate >= 0.9) status = 'pass'; + else if (triagedRate >= 0.8) status = 'partial'; + else status = 'fail'; + + return { + status, + compliance_rate: triagedRate, + total_issues: total, + triaged_within_sla: triaged.length, + exceeding_sla: exceeding, + median_hours: Math.round(median * 10) / 10, + p95_hours: Math.round(p95 * 10) / 10, + days_analyzed: days + }; +} diff --git a/src/tier-check/index.ts b/src/tier-check/index.ts new file mode 100644 index 0000000..5416748 --- /dev/null +++ b/src/tier-check/index.ts @@ -0,0 +1,201 @@ +import { Command } from 'commander'; +import { Octokit } from '@octokit/rest'; +import { + checkConformance, + checkClientConformance +} from './checks/test-conformance-results'; +import { checkLabels } from './checks/labels'; +import { checkTriage } from './checks/triage'; +import { checkP0Resolution } from './checks/p0'; +import { checkStableRelease } from './checks/release'; +import { checkPolicySignals } from './checks/files'; +import { checkSpecTracking } from './checks/spec-tracking'; +import { computeTier } from './tier-logic'; +import { formatJson, formatMarkdown, formatTerminal } from './output'; +import { TierScorecard } from './types'; + +function parseRepo(repo: string): { owner: string; repo: string } { + const parts = repo.split('/'); + if (parts.length !== 2) + throw new Error(`Invalid repo format: ${repo}. Expected owner/repo`); + return { owner: parts[0], repo: parts[1] }; +} + +export function createTierCheckCommand(): Command { + const tierCheck = new Command('tier-check') + .description('Run SDK tier assessment checks against a GitHub repository') + .requiredOption( + '--repo ', + 'GitHub repository (e.g., modelcontextprotocol/typescript-sdk)' + ) + .option('--branch ', 'Branch to check') + .option( + '--conformance-server-url ', + 'URL of the already-running conformance server' + ) + .option( + '--client-cmd ', + 'Command to run the SDK conformance client (for client conformance tests)' + ) + .option('--skip-conformance', 'Skip conformance tests') + .option('--days ', 'Limit triage check to issues created in last N days') + .option( + '--output ', + 'Output format: json, markdown, terminal', + 'terminal' + ) + .option( + '--token ', + 'GitHub token (defaults to GITHUB_TOKEN env var)' + ) + .action(async (options) => { + const { owner, repo } = parseRepo(options.repo); + let token = options.token || process.env.GITHUB_TOKEN; + + if (!token) { + // Try to get token from GitHub CLI + try { + const { execSync } = await import('child_process'); + token = execSync('gh auth token', { encoding: 'utf-8' }).trim(); + } catch { + // gh not installed or not authenticated + } + } + + if (!token) { + console.error( + 'GitHub token required. Either:\n' + + ' gh auth login\n' + + ' export GITHUB_TOKEN=$(gh auth token)\n' + + ' or pass --token ' + ); + process.exit(1); + } + + const octokit = new Octokit({ auth: token }); + const days = options.days ? parseInt(options.days, 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({ + serverUrl: options.conformanceServerUrl, + skip: options.skipConformance + }).then((r) => { + console.error(' ✓ Server Conformance'); + return r; + }), + 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; + }) + ]); + + const checks = { + conformance, + client_conformance: clientConformance, + labels, + triage, + p0_resolution: p0, + stable_release: release, + policy_signals: files, + spec_tracking: specTracking + }; + + const implied_tier = computeTier(checks); + + const scorecard: TierScorecard = { + repo: options.repo, + branch: options.branch || null, + timestamp: new Date().toISOString(), + version: release.version, + checks, + implied_tier + }; + + switch (options.output) { + case 'json': + console.log(formatJson(scorecard)); + break; + case 'markdown': + console.log(formatMarkdown(scorecard)); + break; + default: + formatTerminal(scorecard); + } + }); + + // Subcommands for individual checks + tierCheck + .command('labels') + .description('Check label taxonomy') + .requiredOption('--repo ', 'GitHub repository') + .option('--token ', 'GitHub token') + .action(async (options) => { + const { owner, repo } = parseRepo(options.repo); + const octokit = new Octokit({ + auth: options.token || process.env.GITHUB_TOKEN + }); + const result = await checkLabels(octokit, owner, repo); + console.log(JSON.stringify(result, null, 2)); + }); + + tierCheck + .command('triage') + .description('Check issue triage speed') + .requiredOption('--repo ', 'GitHub repository') + .option('--days ', 'Limit triage check to issues created in last N days') + .option('--token ', 'GitHub token') + .action(async (options) => { + const { owner, repo } = parseRepo(options.repo); + const octokit = new Octokit({ + auth: options.token || process.env.GITHUB_TOKEN + }); + const result = await checkTriage( + octokit, + owner, + repo, + options.days ? parseInt(options.days, 10) : undefined + ); + console.log(JSON.stringify(result, null, 2)); + }); + + return tierCheck; +} diff --git a/src/tier-check/output.ts b/src/tier-check/output.ts new file mode 100644 index 0000000..d7e9fc8 --- /dev/null +++ b/src/tier-check/output.ts @@ -0,0 +1,153 @@ +import { TierScorecard, CheckStatus } from './types'; + +const COLORS = { + RESET: '\x1b[0m', + GREEN: '\x1b[32m', + YELLOW: '\x1b[33m', + RED: '\x1b[31m', + BLUE: '\x1b[36m', + BOLD: '\x1b[1m', + DIM: '\x1b[2m' +}; + +function statusIcon(status: CheckStatus): string { + switch (status) { + case 'pass': + return `${COLORS.GREEN}\u2713${COLORS.RESET}`; + case 'fail': + return `${COLORS.RED}\u2717${COLORS.RESET}`; + case 'partial': + return `${COLORS.YELLOW}~${COLORS.RESET}`; + case 'skipped': + return `${COLORS.DIM}-${COLORS.RESET}`; + } +} + +export function formatJson(scorecard: TierScorecard): string { + return JSON.stringify(scorecard, null, 2); +} + +export function formatMarkdown(scorecard: TierScorecard): string { + const lines: string[] = []; + const c = scorecard.checks; + + lines.push(`# Tier Assessment: Tier ${scorecard.implied_tier.tier}`); + lines.push(''); + lines.push(`**Repo**: ${scorecard.repo}`); + if (scorecard.branch) lines.push(`**Branch**: ${scorecard.branch}`); + if (scorecard.version) lines.push(`**Version**: ${scorecard.version}`); + lines.push(`**Timestamp**: ${scorecard.timestamp}`); + lines.push(''); + lines.push('## Check Results'); + lines.push(''); + lines.push('| Check | Status | Detail |'); + lines.push('|-------|--------|--------|'); + lines.push( + `| Server Conformance | ${c.conformance.status} | ${c.conformance.passed}/${c.conformance.total} scenarios pass (${Math.round(c.conformance.pass_rate * 100)}%) |` + ); + lines.push( + `| Client Conformance | ${c.client_conformance.status} | ${c.client_conformance.passed}/${c.client_conformance.total} scenarios pass (${Math.round(c.client_conformance.pass_rate * 100)}%) |` + ); + lines.push( + `| Labels | ${c.labels.status} | ${c.labels.present}/${c.labels.required} required labels${c.labels.missing.length > 0 ? ` (missing: ${c.labels.missing.join(', ')})` : ''} |` + ); + lines.push( + `| Triage | ${c.triage.status} | ${Math.round(c.triage.compliance_rate * 100)}% within 2BD, median ${c.triage.median_hours}h, p95 ${c.triage.p95_hours}h |` + ); + lines.push( + `| P0 Resolution | ${c.p0_resolution.status} | ${c.p0_resolution.open_p0s} open, ${c.p0_resolution.closed_within_7d}/${c.p0_resolution.closed_total} closed within 7d |` + ); + lines.push( + `| Stable Release | ${c.stable_release.status} | ${c.stable_release.version || 'none'} (stable: ${c.stable_release.is_stable}) |` + ); + lines.push( + `| Policy Signals | ${c.policy_signals.status} | ${Object.entries( + c.policy_signals.files + ) + .map(([f, e]) => `${f}: ${e ? '\u2713' : '\u2717'}`) + .join(', ')} |` + ); + lines.push( + `| Spec Tracking | ${c.spec_tracking.status} | ${c.spec_tracking.days_gap !== null ? `${c.spec_tracking.days_gap}d gap` : 'N/A'} |` + ); + lines.push(''); + + if (scorecard.implied_tier.tier1_blockers.length > 0) { + lines.push('## Tier 1 Blockers'); + lines.push(''); + for (const blocker of scorecard.implied_tier.tier1_blockers) { + lines.push(`- ${blocker}`); + } + lines.push(''); + } + + lines.push(`> ${scorecard.implied_tier.note}`); + + return lines.join('\n'); +} + +export function formatTerminal(scorecard: TierScorecard): void { + const c = scorecard.checks; + const tier = scorecard.implied_tier.tier; + const tierColor = + tier === 1 ? COLORS.GREEN : tier === 2 ? COLORS.YELLOW : COLORS.RED; + + console.log( + `\n${COLORS.BOLD}Tier Assessment: ${tierColor}Tier ${tier}${COLORS.RESET}\n` + ); + console.log(`Repo: ${scorecard.repo}`); + if (scorecard.branch) console.log(`Branch: ${scorecard.branch}`); + if (scorecard.version) console.log(`Version: ${scorecard.version}`); + console.log(`Timestamp: ${scorecard.timestamp}\n`); + + console.log(`${COLORS.BOLD}Check Results:${COLORS.RESET}\n`); + + console.log( + ` ${statusIcon(c.conformance.status)} Server Conformance ${c.conformance.passed}/${c.conformance.total} (${Math.round(c.conformance.pass_rate * 100)}%)` + ); + console.log( + ` ${statusIcon(c.client_conformance.status)} Client Conformance ${c.client_conformance.passed}/${c.client_conformance.total} (${Math.round(c.client_conformance.pass_rate * 100)}%)` + ); + console.log( + ` ${statusIcon(c.labels.status)} Labels ${c.labels.present}/${c.labels.required} required labels` + ); + if (c.labels.missing.length > 0) + console.log( + ` ${COLORS.DIM}Missing: ${c.labels.missing.join(', ')}${COLORS.RESET}` + ); + console.log( + ` ${statusIcon(c.triage.status)} Triage ${Math.round(c.triage.compliance_rate * 100)}% within 2BD (${c.triage.total_issues} issues, median ${c.triage.median_hours}h)` + ); + console.log( + ` ${statusIcon(c.p0_resolution.status)} P0 Resolution ${c.p0_resolution.open_p0s} open, ${c.p0_resolution.closed_within_7d}/${c.p0_resolution.closed_total} closed within 7d` + ); + if (c.p0_resolution.open_p0_details.length > 0) { + for (const p0 of c.p0_resolution.open_p0_details) { + console.log( + ` ${COLORS.RED}#${p0.number} (${p0.age_days}d old): ${p0.title}${COLORS.RESET}` + ); + } + } + console.log( + ` ${statusIcon(c.stable_release.status)} Stable Release ${c.stable_release.version || 'none'}` + ); + console.log( + ` ${statusIcon(c.policy_signals.status)} Policy Signals ${Object.entries( + c.policy_signals.files + ) + .map(([f, e]) => `${e ? '\u2713' : '\u2717'} ${f}`) + .join(', ')}` + ); + console.log( + ` ${statusIcon(c.spec_tracking.status)} Spec Tracking ${c.spec_tracking.days_gap !== null ? `${c.spec_tracking.days_gap}d gap` : 'N/A'}` + ); + + if (scorecard.implied_tier.tier1_blockers.length > 0) { + console.log(`\n${COLORS.BOLD}Tier 1 Blockers:${COLORS.RESET}`); + for (const blocker of scorecard.implied_tier.tier1_blockers) { + console.log(` ${COLORS.RED}\u2022${COLORS.RESET} ${blocker}`); + } + } + + console.log(`\n${COLORS.DIM}${scorecard.implied_tier.note}${COLORS.RESET}\n`); +} diff --git a/src/tier-check/tier-logic.ts b/src/tier-check/tier-logic.ts new file mode 100644 index 0000000..cf096c9 --- /dev/null +++ b/src/tier-check/tier-logic.ts @@ -0,0 +1,66 @@ +import { TierScorecard } from './types'; + +export function computeTier( + checks: TierScorecard['checks'] +): TierScorecard['implied_tier'] { + const tier1Blockers: string[] = []; + + // Check Tier 1 requirements — server conformance + if (checks.conformance.status === 'skipped') { + tier1Blockers.push('server_conformance (skipped)'); + } else if (checks.conformance.pass_rate < 1.0) { + tier1Blockers.push('server_conformance'); + } + + // Check Tier 1 requirements — client conformance + if (checks.client_conformance.status === 'skipped') { + tier1Blockers.push('client_conformance (skipped)'); + } else if (checks.client_conformance.pass_rate < 1.0) { + tier1Blockers.push('client_conformance'); + } + + if (checks.triage.compliance_rate < 0.9) { + tier1Blockers.push('triage'); + } + + if (!checks.p0_resolution.all_p0s_resolved_within_7d) { + tier1Blockers.push('p0_resolution'); + } + + if (!checks.stable_release.is_stable) { + tier1Blockers.push('stable_release'); + } + + // Policy signals (CHANGELOG, SECURITY, etc.) are informational evidence — + // they feed into the skill's judgment-based evaluation but don't independently + // block tier advancement since SEP-1730 doesn't list specific files. + + if (checks.spec_tracking.status === 'fail') { + tier1Blockers.push('spec_tracking'); + } + + if (checks.labels.missing.length > 0) { + tier1Blockers.push('labels'); + } + + // Check Tier 2 requirements + const tier2Met = + (checks.conformance.status === 'skipped' || + checks.conformance.pass_rate >= 0.8) && + (checks.client_conformance.status === 'skipped' || + checks.client_conformance.pass_rate >= 0.8) && + checks.p0_resolution.all_p0s_resolved_within_14d && + checks.stable_release.is_stable; + + const tier = tier1Blockers.length === 0 ? 1 : tier2Met ? 2 : 3; + + return { + tier, + tier1_blockers: tier1Blockers, + tier2_met: tier2Met, + note: + tier === 1 + ? 'All deterministic checks pass. Judgment-based checks (docs, policy, roadmap) require /mcp-sdk-tier-audit skill.' + : 'Partial assessment — judgment-based checks require /mcp-sdk-tier-audit skill' + }; +} diff --git a/src/tier-check/types.ts b/src/tier-check/types.ts new file mode 100644 index 0000000..ff6be5d --- /dev/null +++ b/src/tier-check/types.ts @@ -0,0 +1,87 @@ +export type CheckStatus = 'pass' | 'fail' | 'partial' | 'skipped'; + +export interface CheckResult { + status: CheckStatus; + [key: string]: unknown; +} + +export interface ConformanceResult extends CheckResult { + pass_rate: number; + passed: number; + failed: number; + total: number; + details: Array<{ + scenario: string; + passed: boolean; + checks_passed: number; + checks_failed: number; + }>; +} + +export interface LabelsResult extends CheckResult { + present: number; + required: number; + missing: string[]; + found: string[]; + uses_issue_types: boolean; +} + +export interface TriageResult extends CheckResult { + compliance_rate: number; + total_issues: number; + triaged_within_sla: number; + exceeding_sla: number; + median_hours: number; + p95_hours: number; + days_analyzed: number | undefined; +} + +export interface P0Result extends CheckResult { + open_p0s: number; + open_p0_details: Array<{ number: number; title: string; age_days: number }>; + closed_within_7d: number; + closed_within_14d: number; + closed_total: number; + all_p0s_resolved_within_7d: boolean; + all_p0s_resolved_within_14d: boolean; +} + +export interface ReleaseResult extends CheckResult { + version: string | null; + is_stable: boolean; + is_prerelease: boolean; +} + +export interface PolicySignalsResult extends CheckResult { + files: Record; +} + +export interface SpecTrackingResult extends CheckResult { + latest_spec_release: string | null; + latest_sdk_release: string | null; + sdk_release_within_30d: boolean | null; + days_gap: number | null; +} + +export interface TierScorecard { + repo: string; + branch: string | null; + timestamp: string; + version: string | null; + checks: { + conformance: ConformanceResult; + client_conformance: ConformanceResult; + labels: LabelsResult; + triage: TriageResult; + p0_resolution: P0Result; + stable_release: ReleaseResult; + policy_signals: PolicySignalsResult; + spec_tracking: SpecTrackingResult; + }; + implied_tier: { + tier: 1 | 2 | 3; + tier1_blockers: string[]; + tier2_met: boolean; + note: string; + }; +} From c82fd65cfae70b6f0498b5bdba7518b67fb77730 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:13:48 +0000 Subject: [PATCH 083/226] fix: recurse into subdirectories when parsing conformance output (#148) parseOutputDir only read one level deep, so scenarios with '/' in their name (e.g. auth/metadata-default) were stored in nested subdirectories and never found. This caused tier-check to report 4/23 client conformance when the actual pass rate was 23/23. Also removes accidentally committed pnpm-lock.yaml. --- src/tier-check/checks/test-conformance-results.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index bef2059..55e4f6d 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -1,6 +1,6 @@ import { execSync } from 'child_process'; -import { mkdtempSync, readFileSync, readdirSync, existsSync } from 'fs'; -import { join } from 'path'; +import { mkdtempSync, readFileSync, existsSync, globSync } from 'fs'; +import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { ConformanceResult } from '../types'; import { listScenarios, listActiveClientScenarios } from '../../scenarios'; @@ -26,10 +26,13 @@ function parseOutputDir(outputDir: string): ConformanceResult { let totalPassed = 0; let totalFailed = 0; - const entries = readdirSync(outputDir); - for (const scenarioName of entries) { - const checksPath = join(outputDir, scenarioName, 'checks.json'); - if (!existsSync(checksPath)) continue; + // Find all checks.json files recursively to handle scenarios with '/' in + // their name (e.g. auth/metadata-default) which create nested subdirectories. + const checksFiles = globSync('**/checks.json', { cwd: outputDir }); + + for (const checksFile of checksFiles) { + const scenarioName = dirname(checksFile); + const checksPath = join(outputDir, checksFile); try { const checks: ConformanceCheck[] = JSON.parse( From 83c446ddf51a685a30efda5d4ff629cf642889e8 Mon Sep 17 00:00:00 2001 From: sagar-okta Date: Fri, 13 Feb 2026 23:17:54 +0530 Subject: [PATCH 084/226] feat: add conformance tests for SEP-990 (#110) * feat: add conformance tests for SEP-990 * Resolving review changes: Removed redundant tests, updated audience params * fix: unused serverUrl parameter in runCrossAppAccessTokenExchange * chore: apply prettier formatting * fix: address PR review comments for SEP-990 conformance tests - Delete unused separate token-exchange and jwt-bearer scenarios, keeping only the complete e2e flow (review comment) - Add missing required token exchange params per SEP-990 spec: requested_token_type, audience, resource (review comment) - Use ctx.idp_client_id for token exchange client_id instead of AS client_id (review comment) - Client discovers resource and auth server via PRM metadata instead of receiving auth_server_url via context (review comment) - Server IdP handler verifies all required token exchange params with detailed error messages (review comment) - Add resource, client_id, jti claims to ID-JAG per SEP-990 spec - Verify ID-JAG typ header (oauth-id-jag+jwt) in JWT bearer handler - Remove auth_server_url from context schema * feat: add client auth and ID-JAG validation to XAA conformance test Server-side (AS) now verifies: - client_secret_basic authentication on JWT bearer grant - ID-JAG typ header is oauth-id-jag+jwt - ID-JAG client_id claim matches the authenticating client (Section 5.1) - ID-JAG resource claim matches the MCP server resource identifier - Client credentials provided via context (client_secret) Server-side (IdP) now: - Sets ID-JAG client_id to the MCP Client's AS client_id (not the IdP client_id), per Section 6.1 Example client now: - Authenticates to AS via client_secret_basic (Authorization: Basic) instead of sending client_id in body - Checks AS metadata grant_types_supported includes jwt-bearer before attempting the flow * fix: share MockTokenVerifier and remove unadvertised auth method - Add shared MockTokenVerifier between AS and MCP server so the MCP server only accepts tokens actually issued by the auth server, matching the pattern used by all other auth scenarios - Remove private_key_jwt from tokenEndpointAuthMethodsSupported since the handler only implements client_secret_basic --------- Co-authored-by: Paul Carleton --- .../clients/typescript/everything-client.ts | 157 ++++- src/scenarios/client/auth/cross-app-access.ts | 550 ++++++++++++++++++ src/scenarios/client/auth/index.ts | 4 +- src/scenarios/client/auth/spec-references.ts | 12 + src/schemas/context.ts | 9 + 5 files changed, 728 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/client/auth/cross-app-access.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 93fd142..21804a8 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -361,15 +361,166 @@ export async function runPreRegistration(serverUrl: string): Promise { await client.listTools(); logger.debug('Successfully listed tools'); - await client.callTool({ name: 'test-tool', arguments: {} }); - logger.debug('Successfully called tool'); - await transport.close(); logger.debug('Connection closed successfully'); } registerScenario('auth/pre-registration', runPreRegistration); +// ============================================================================ +// Cross-App Access (SEP-990) scenarios +// ============================================================================ + +/** + * Cross-app access: Complete Flow (SEP-990) + * Tests the complete flow: IDP ID token -> authorization grant -> access token -> MCP access. + */ +export async function runCrossAppAccessCompleteFlow( + serverUrl: string +): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-complete-flow') { + throw new Error( + `Expected cross-app-access-complete-flow context, got ${ctx.name}` + ); + } + + logger.debug('Starting complete cross-app access flow...'); + logger.debug('IDP Issuer:', ctx.idp_issuer); + logger.debug('IDP Token Endpoint:', ctx.idp_token_endpoint); + + // Step 0: Discover resource and auth server from PRM metadata + logger.debug('Step 0: Discovering resource and auth server via PRM...'); + const prmUrl = new URL( + '/.well-known/oauth-protected-resource/mcp', + serverUrl + ); + const prmResponse = await fetch(prmUrl.toString()); + if (!prmResponse.ok) { + throw new Error(`PRM discovery failed: ${prmResponse.status}`); + } + const prm = await prmResponse.json(); + const resource = prm.resource; + const authServerUrl = prm.authorization_servers[0]; + logger.debug('Discovered resource:', resource); + logger.debug('Discovered auth server:', authServerUrl); + + // Discover auth server metadata to find token endpoint + const asMetadataUrl = new URL( + '/.well-known/oauth-authorization-server', + authServerUrl + ); + const asMetadataResponse = await fetch(asMetadataUrl.toString()); + if (!asMetadataResponse.ok) { + throw new Error( + `Auth server metadata discovery failed: ${asMetadataResponse.status}` + ); + } + const asMetadata = await asMetadataResponse.json(); + const asTokenEndpoint = asMetadata.token_endpoint; + const asIssuer = asMetadata.issuer; + logger.debug('Auth server issuer:', asIssuer); + logger.debug('Auth server token endpoint:', asTokenEndpoint); + + // Verify AS supports jwt-bearer grant type + const grantTypes: string[] = asMetadata.grant_types_supported || []; + if (!grantTypes.includes('urn:ietf:params:oauth:grant-type:jwt-bearer')) { + throw new Error( + `Auth server does not support jwt-bearer grant type. Supported: ${grantTypes.join(', ')}` + ); + } + logger.debug('Auth server supports jwt-bearer grant type'); + + // Step 1: Token Exchange at IdP (IDP ID token -> ID-JAG) + logger.debug('Step 1: Exchanging IDP ID token for ID-JAG at IdP...'); + const tokenExchangeParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + audience: asIssuer, + resource: resource, + subject_token: ctx.idp_id_token, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + client_id: ctx.idp_client_id + }); + + const tokenExchangeResponse = await fetch(ctx.idp_token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenExchangeParams + }); + + if (!tokenExchangeResponse.ok) { + const error = await tokenExchangeResponse.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + const tokenExchangeResult = await tokenExchangeResponse.json(); + const idJag = tokenExchangeResult.access_token; // ID-JAG (ID-bound JSON Assertion Grant) + logger.debug('Token exchange successful, ID-JAG obtained'); + logger.debug('Issued token type:', tokenExchangeResult.issued_token_type); + + // Step 2: JWT Bearer Grant at AS (ID-JAG -> access token) + // Client authenticates via client_secret_basic (RFC 7523 Section 5) + logger.debug('Step 2: Exchanging ID-JAG for access token at Auth Server...'); + const jwtBearerParams = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: idJag + }); + + const basicAuth = Buffer.from( + `${encodeURIComponent(ctx.client_id)}:${encodeURIComponent(ctx.client_secret)}` + ).toString('base64'); + + const tokenResponse = await fetch(asTokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basicAuth}` + }, + body: jwtBearerParams + }); + + if (!tokenResponse.ok) { + const error = await tokenResponse.text(); + throw new Error(`JWT bearer grant failed: ${error}`); + } + + const tokenResult = await tokenResponse.json(); + logger.debug('JWT bearer grant successful, access token obtained'); + + // 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' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + requestInit: { + headers: { + Authorization: `Bearer ${tokenResult.access_token}` + } + } + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Complete cross-app access flow completed successfully'); +} + +registerScenario( + 'auth/cross-app-access-complete-flow', + runCrossAppAccessCompleteFlow +); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts new file mode 100644 index 0000000..077889d --- /dev/null +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -0,0 +1,550 @@ +import * as jose from 'jose'; +import type { CryptoKey } from 'jose'; +import express, { type Request, type Response } from 'express'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +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'; + +const CONFORMANCE_TEST_CLIENT_ID = 'conformance-test-xaa-client'; +const CONFORMANCE_TEST_CLIENT_SECRET = 'conformance-test-xaa-secret'; +const IDP_CLIENT_ID = 'conformance-test-idp-client'; +const DEMO_USER_ID = 'demo-user@example.com'; + +/** + * Generate an EC P-256 keypair for IDP ID token signing. + */ +async function generateIdpKeypair(): Promise<{ + publicKey: CryptoKey; + privateKey: CryptoKey; +}> { + const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { + extractable: true + }); + return { publicKey, privateKey }; +} + +/** + * Create a signed ID token from the IDP + */ +async function createIdpIdToken( + privateKey: CryptoKey, + idpIssuer: string, + audience: string, + userId: string = DEMO_USER_ID +): Promise { + return await new jose.SignJWT({ + sub: userId, + email: userId, + aud: audience + }) + .setProtectedHeader({ alg: 'ES256' }) + .setIssuer(idpIssuer) + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); +} + +/** + * Scenario: Complete Cross-App Access Flow + * + * 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'; + description = + 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise Managed OAuth)'; + + private idpServer = new ServerLifecycle(); + private authServer = new ServerLifecycle(); + private mcpServer = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private idpPublicKey?: CryptoKey; + private idpPrivateKey?: CryptoKey; + private grantKeypairs: Map = new Map(); + + async start(): Promise { + this.checks = []; + + // Generate IDP keypair + const { publicKey, privateKey } = await generateIdpKeypair(); + this.idpPublicKey = publicKey; + this.idpPrivateKey = privateKey; + + // Shared token verifier ensures MCP server only accepts tokens + // actually issued by the auth server + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + // Start IDP server + await this.startIdpServer(); + + // 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'], + tokenEndpointAuthMethodsSupported: ['client_secret_basic'], + tokenVerifier, + onTokenRequest: async ({ + grantType, + body, + timestamp, + authBaseUrl, + authorizationHeader + }) => { + // Auth server only handles JWT bearer grant (ID-JAG -> access token) + if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') { + const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`; + return await this.handleJwtBearerGrant( + body, + timestamp, + authBaseUrl, + authorizationHeader, + mcpResourceUrl + ); + } + + return { + error: 'unsupported_grant_type', + errorDescription: `Auth server only supports jwt-bearer grant, got ${grantType}` + }; + } + }); + + await this.authServer.start(authApp); + + // Start MCP server with shared token verifier + const mcpApp = createServer( + this.checks, + this.mcpServer.getUrl, + this.authServer.getUrl, + { tokenVerifier } + ); + + await this.mcpServer.start(mcpApp); + + // Generate IDP ID token for client + const idpIdToken = await createIdpIdToken( + this.idpPrivateKey!, + this.idpServer.getUrl(), + IDP_CLIENT_ID + ); + + return { + serverUrl: `${this.mcpServer.getUrl()}/mcp`, + context: { + client_id: CONFORMANCE_TEST_CLIENT_ID, + client_secret: CONFORMANCE_TEST_CLIENT_SECRET, + idp_client_id: IDP_CLIENT_ID, + idp_id_token: idpIdToken, + idp_issuer: this.idpServer.getUrl(), + idp_token_endpoint: `${this.idpServer.getUrl()}/token` + } + }; + } + + private async startIdpServer(): Promise { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // IDP metadata endpoint + app.get( + '/.well-known/openid-configuration', + (req: Request, res: Response) => { + res.json({ + issuer: this.idpServer.getUrl(), + authorization_endpoint: `${this.idpServer.getUrl()}/authorize`, + token_endpoint: `${this.idpServer.getUrl()}/token`, + jwks_uri: `${this.idpServer.getUrl()}/.well-known/jwks.json`, + grant_types_supported: [ + 'urn:ietf:params:oauth:grant-type:token-exchange' + ] + }); + } + ); + + // IDP token endpoint - handles token exchange (IDP ID token -> ID-JAG) + app.post('/token', async (req: Request, res: Response) => { + const timestamp = new Date().toISOString(); + const grantType = req.body.grant_type; + const subjectToken = req.body.subject_token; + const subjectTokenType = req.body.subject_token_type; + const requestedTokenType = req.body.requested_token_type; + const audience = req.body.audience; + const resource = req.body.resource; + + // Only handle token exchange at IdP + if (grantType !== 'urn:ietf:params:oauth:grant-type:token-exchange') { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: `IdP expected token-exchange grant, got ${grantType}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + }); + res.status(400).json({ + error: 'unsupported_grant_type', + error_description: 'IdP only supports token-exchange' + }); + return; + } + + // Verify all required token exchange parameters per SEP-990 + const missingParams: string[] = []; + if (!subjectToken) missingParams.push('subject_token'); + if (subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token') { + missingParams.push( + `subject_token_type (expected urn:ietf:params:oauth:token-type:id_token, got ${subjectTokenType || 'missing'})` + ); + } + if (requestedTokenType !== 'urn:ietf:params:oauth:token-type:id-jag') { + missingParams.push( + `requested_token_type (expected urn:ietf:params:oauth:token-type:id-jag, got ${requestedTokenType || 'missing'})` + ); + } + if (!audience) missingParams.push('audience'); + if (!resource) missingParams.push('resource'); + + if (missingParams.length > 0) { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: `Token exchange missing or invalid required parameters: ${missingParams.join(', ')}`, + status: 'FAILURE', + timestamp, + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + res.status(400).json({ + error: 'invalid_request', + error_description: `Missing or invalid required parameters: ${missingParams.join(', ')}` + }); + return; + } + + try { + // Verify the IDP ID token + const { payload } = await jose.jwtVerify( + subjectToken, + this.idpPublicKey!, + { + audience: IDP_CLIENT_ID, + issuer: this.idpServer.getUrl() + } + ); + + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: + 'Successfully exchanged IDP ID token for ID-JAG at IdP with all required parameters', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + + // Create ID-JAG (ID-bound JSON Assertion Grant) + // Include resource and client_id claims per SEP-990 + const userId = payload.sub as string; + const { publicKey, privateKey } = await jose.generateKeyPair('ES256'); + this.grantKeypairs.set(userId, publicKey); + + // The IdP uses CONFORMANCE_TEST_CLIENT_ID (the MCP Client's client_id + // at the AS), not the IdP client_id from the request body. + // Per Section 6.1: "the IdP will need to be aware of the MCP Client's + // client_id that it normally uses with the MCP Server." + const idJag = await new jose.SignJWT({ + sub: userId, + resource: resource, + client_id: CONFORMANCE_TEST_CLIENT_ID + }) + .setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' }) + .setIssuer(this.idpServer.getUrl()) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime('5m') + .setJti(crypto.randomUUID()) + .sign(privateKey); + + res.json({ + access_token: idJag, + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: `Token exchange failed: ${errorMessage}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_8693_TOKEN_EXCHANGE] + }); + res.status(400).json({ + error: 'invalid_grant', + error_description: 'Invalid ID token' + }); + } + }); + + await this.idpServer.start(app); + } + + private async handleJwtBearerGrant( + body: Record, + timestamp: string, + authBaseUrl: string, + authorizationHeader?: string, + mcpResourceUrl?: string + ): Promise { + // 1. Verify client authentication (client_secret_basic) + if (!authorizationHeader || !authorizationHeader.startsWith('Basic ')) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: + 'Missing or invalid Authorization header for client_secret_basic authentication', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH], + details: { + expected: 'Authorization: Basic ', + received: authorizationHeader || 'missing' + } + }); + return { + error: 'invalid_client', + errorDescription: + 'Client authentication required (client_secret_basic)', + statusCode: 401 + }; + } + + const base64Credentials = authorizationHeader.slice('Basic '.length); + const decoded = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const separatorIndex = decoded.indexOf(':'); + if (separatorIndex === -1) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: 'Malformed Basic auth header (no colon separator)', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH] + }); + return { + error: 'invalid_client', + errorDescription: 'Malformed Basic auth', + statusCode: 401 + }; + } + + const authClientId = decodeURIComponent(decoded.slice(0, separatorIndex)); + const authClientSecret = decodeURIComponent( + decoded.slice(separatorIndex + 1) + ); + + if ( + authClientId !== CONFORMANCE_TEST_CLIENT_ID || + authClientSecret !== CONFORMANCE_TEST_CLIENT_SECRET + ) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: `Client authentication failed: invalid credentials (client_id: ${authClientId})`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH] + }); + return { + error: 'invalid_client', + errorDescription: 'Invalid client credentials', + statusCode: 401 + }; + } + + // 2. Verify assertion is present + const assertion = body.assertion; + if (!assertion) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: 'Missing assertion in JWT bearer grant', + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_7523_JWT_BEARER] + }); + return { + error: 'invalid_request', + errorDescription: 'Missing assertion' + }; + } + + try { + // 3. Verify the ID-JAG header has the correct typ + const header = jose.decodeProtectedHeader(assertion); + if (header.typ !== 'oauth-id-jag+jwt') { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: `ID-JAG has wrong typ header: expected oauth-id-jag+jwt, got ${header.typ}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH] + }); + return { + error: 'invalid_grant', + errorDescription: 'Invalid ID-JAG typ header' + }; + } + + // 4. Decode and verify the ID-JAG + const decoded = jose.decodeJwt(assertion); + const userId = decoded.sub as string; + const publicKey = this.grantKeypairs.get(userId); + + if (!publicKey) { + throw new Error('Unknown authorization grant'); + } + + // Verify signature and audience + const withoutSlash = authBaseUrl.replace(/\/+$/, ''); + const withSlash = `${withoutSlash}/`; + + await jose.jwtVerify(assertion, publicKey, { + audience: [withoutSlash, withSlash], + clockTolerance: 30 + }); + + // 5. Verify client_id in ID-JAG matches the authenticating client (Section 5.1) + const jagClientId = decoded.client_id as string | undefined; + if (jagClientId !== authClientId) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: `ID-JAG client_id (${jagClientId}) does not match authenticating client (${authClientId})`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH], + details: { + jagClientId, + authClientId + } + }); + return { + error: 'invalid_grant', + errorDescription: + 'ID-JAG client_id does not match authenticating client' + }; + } + + // 6. Verify resource claim in ID-JAG matches the MCP server resource + const jagResource = decoded.resource as string | undefined; + if (mcpResourceUrl && jagResource !== mcpResourceUrl) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: `ID-JAG resource (${jagResource}) does not match MCP server resource (${mcpResourceUrl})`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.SEP_990_ENTERPRISE_OAUTH], + details: { + jagResource, + expectedResource: mcpResourceUrl + } + }); + return { + error: 'invalid_grant', + errorDescription: 'ID-JAG resource does not match MCP server resource' + }; + } + + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: + 'Successfully verified client auth, ID-JAG claims, and exchanged for access token', + status: 'SUCCESS', + timestamp, + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + + const scopes = body.scope ? body.scope.split(' ') : []; + return { + token: `test-token-${Date.now()}`, + scopes + }; + } catch (e) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: `JWT bearer grant failed: ${e}`, + status: 'FAILURE', + timestamp, + specReferences: [SpecReferences.RFC_7523_JWT_BEARER] + }); + return { + error: 'invalid_grant', + errorDescription: 'Invalid authorization grant' + }; + } + } + + async stop() { + await this.idpServer.stop(); + await this.authServer.stop(); + await this.mcpServer.stop(); + } + + getChecks(): ConformanceCheck[] { + const hasTokenExchangeCheck = this.checks.some( + (c) => c.id === 'complete-flow-token-exchange' + ); + const hasJwtBearerCheck = this.checks.some( + (c) => c.id === 'complete-flow-jwt-bearer' + ); + + if (!hasTokenExchangeCheck) { + this.checks.push({ + id: 'complete-flow-token-exchange', + name: 'CompleteFlowTokenExchange', + description: 'Client did not perform token exchange', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_8693_TOKEN_EXCHANGE, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + } + + if (!hasJwtBearerCheck) { + this.checks.push({ + id: 'complete-flow-jwt-bearer', + name: 'CompleteFlowJwtBearer', + description: 'Client did not perform JWT bearer grant exchange', + status: 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [ + SpecReferences.RFC_7523_JWT_BEARER, + SpecReferences.SEP_990_ENTERPRISE_OAUTH + ] + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7f75113..73c9ddb 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -23,6 +23,7 @@ import { } from './client-credentials'; import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; +import { CrossAppAccessCompleteFlowScenario } from './cross-app-access'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -49,5 +50,6 @@ export const backcompatScenariosList: Scenario[] = [ // Extension scenarios (optional for tier 1 - protocol extensions) export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), - new ClientCredentialsBasicScenario() + new ClientCredentialsBasicScenario(), + new CrossAppAccessCompleteFlowScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index a24e987..4020bfc 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -88,5 +88,17 @@ export const SpecReferences: { [key: string]: SpecReference } = { MCP_PKCE: { id: 'MCP-PKCE-requirement', url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection' + }, + RFC_8693_TOKEN_EXCHANGE: { + id: 'RFC-8693-Token-Exchange', + url: 'https://datatracker.ietf.org/doc/html/rfc8693' + }, + RFC_7523_JWT_BEARER: { + id: 'RFC-7523-JWT-Bearer-Grant', + url: 'https://datatracker.ietf.org/doc/html/rfc7523' + }, + SEP_990_ENTERPRISE_OAUTH: { + id: 'SEP-990-Enterprise-Managed-OAuth', + url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-oauth.mdx' } }; diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 2a8a907..9a5e249 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -22,6 +22,15 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ name: z.literal('auth/pre-registration'), client_id: z.string(), client_secret: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-complete-flow'), + 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() }) ]); From dd14862ee40da36f0b86af245d1e613d9b587631 Mon Sep 17 00:00:00 2001 From: jd Date: Fri, 13 Feb 2026 09:59:48 -0800 Subject: [PATCH 085/226] fix: use InvalidTokenError instead of generic Error in MockTokenVerifier (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK's `requireBearerAuth` middleware only converts `InvalidTokenError` instances to HTTP 401 responses. Generic `Error` instances fall through as HTTP 500, which prevents clients from detecting authentication failures and initiating the OAuth refresh/re-auth flow. This was discovered while building token refresh conformance scenarios — the mock server was returning 500 for expired/invalid tokens instead of the expected 401. Co-authored-by: JD Maturen <70791+jdmaturen@users.noreply.github.com> Co-authored-by: Cursor --- src/scenarios/client/auth/helpers/mockTokenVerifier.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts index 8cbfae1..022fa4d 100644 --- a/src/scenarios/client/auth/helpers/mockTokenVerifier.ts +++ b/src/scenarios/client/auth/helpers/mockTokenVerifier.ts @@ -1,5 +1,6 @@ import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import type { ConformanceCheck } from '../../../../types'; import { SpecReferences } from '../spec-references'; @@ -53,6 +54,6 @@ export class MockTokenVerifier implements OAuthTokenVerifier { token: token ? token.substring(0, 10) + '...' : 'missing' } }); - throw new Error('Invalid token'); + throw new InvalidTokenError('Invalid token'); } } From 3f9048a0194d4d8c0096f433e707ee949c7225df Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:08:15 +0000 Subject: [PATCH 086/226] feat: add specVersion classification to conformance scenarios (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add specVersions classification to conformance scenarios Each scenario declares which spec versions it applies to as a list. Scenarios that carry forward (e.g. initialize) list all applicable versions ['2025-06-18', '2025-11-25']. Scenarios removed from newer specs (e.g. backcompat auth) only list their original version ['2025-03-26']. - specVersions list on Scenario and ClientScenario interfaces - --spec-version CLI filter uses simple .includes() - Tier-check conformance matrix (Server / Client: Core / Client: Auth) with per-version columns and unique All* count - 7 unit tests for specVersions helpers - Updated tier-audit skill docs with matrix format * fix: fix console output template for tier audit skill The template had an orphan table header (Check | Value | T2 | T1) with no rows above the conformance matrix, causing an empty table to render. The scorecard rows below the matrix also lacked their own header. Fix: two self-contained tables with clear labels — 'Conformance:' for the per-version matrix, 'Scorecard:' for the check rows. * fix: align skill console template with tier-check script output Add asterisk footnote ('unique scenarios — a scenario may apply to multiple spec versions') and rename 'Scorecard' to 'Repository Health' to match the labels used by the tier-check CLI output. * fix: add specVersions to CrossAppAccessCompleteFlowScenario Added in 83c446d on main after this branch diverged. --- .claude/skills/mcp-sdk-tier-audit/SKILL.md | 45 ++++-- .../references/tier-requirements.md | 11 +- src/index.ts | 100 +++++++++++- src/scenarios/client/auth/basic-cimd.ts | 3 +- .../client/auth/client-credentials.ts | 9 +- src/scenarios/client/auth/cross-app-access.ts | 8 +- .../client/auth/discovery-metadata.ts | 1 + .../client/auth/march-spec-backcompat.ts | 4 +- src/scenarios/client/auth/pre-registration.ts | 8 +- .../client/auth/resource-mismatch.ts | 3 +- src/scenarios/client/auth/scope-handling.ts | 7 +- .../client/auth/token-endpoint-auth.ts | 3 +- src/scenarios/client/elicitation-defaults.ts | 3 +- src/scenarios/client/initialize.ts | 8 +- src/scenarios/client/sse-retry.ts | 8 +- src/scenarios/client/tools_call.ts | 3 +- src/scenarios/index.ts | 33 +++- src/scenarios/server/dns-rebinding.ts | 3 +- src/scenarios/server/elicitation-defaults.ts | 3 +- src/scenarios/server/elicitation-enums.ts | 3 +- src/scenarios/server/json-schema-2020-12.ts | 3 +- src/scenarios/server/lifecycle.ts | 3 +- src/scenarios/server/prompts.ts | 7 +- src/scenarios/server/resources.ts | 8 +- src/scenarios/server/sse-multiple-streams.ts | 3 +- src/scenarios/server/sse-polling.ts | 3 +- src/scenarios/server/tools.ts | 13 +- src/scenarios/server/utils.ts | 5 +- src/scenarios/spec-version.test.ts | 94 ++++++++++++ .../checks/test-conformance-results.ts | 18 ++- src/tier-check/output.ts | 145 +++++++++++++++++- src/tier-check/types.ts | 3 + src/types.ts | 9 ++ 33 files changed, 521 insertions(+), 59 deletions(-) create mode 100644 src/scenarios/spec-version.test.ts diff --git a/.claude/skills/mcp-sdk-tier-audit/SKILL.md b/.claude/skills/mcp-sdk-tier-audit/SKILL.md index 234a257..ca45df9 100644 --- a/.claude/skills/mcp-sdk-tier-audit/SKILL.md +++ b/.claude/skills/mcp-sdk-tier-audit/SKILL.md @@ -66,7 +66,9 @@ npm run --silent tier-check -- \ If no client-cmd was detected, omit the `--client-cmd` flag (client conformance will be skipped). -The CLI output includes server conformance pass rate, client conformance pass rate, 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. +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. + +The conformance results now include a `specVersions` field on each detail entry, enabling per-version pass rate analysis. The `list` command also shows spec version tags: `node dist/index.js list` shows `[2025-06-18]`, `[2025-11-25]`, `[draft]`, or `[extension]` next to each scenario. ### Conformance Baseline Check @@ -143,17 +145,21 @@ If any Tier 2 requirement is not met, the SDK is Tier 3. - If GitHub issue labels are not set up per SEP-1730, triage metrics cannot be computed. Note this as a gap. However, repos may use GitHub's native issue types instead of type labels — the CLI checks for both. - If client conformance was skipped (no client command found), note this as a gap but do not block tier advancement based on it alone. -**Client Conformance Splits:** +**Conformance Breakdown:** -When reporting client conformance, always break results into three categories: +The **full suite** pass rates (server total, client total) are used for tier threshold checks. To interpret them, present a single conformance matrix combining server and client results. Each detail entry in the tier-check JSON has a `specVersions` field; client category is derived from the scenario name (`auth/` prefix = Auth, everything else = Core). Server scenarios are all Core. -1. **Core suite** — Non-auth scenarios (e.g. initialize, tools_call, elicitation, sse-retry) -2. **Auth suite** — OAuth/authorization scenarios (any scenario starting with `auth/`) -3. **Full suite** — All scenarios combined +Example: -The **full suite** number is used for tier threshold checks. However, the core vs auth split provides essential context. Always present both numbers in the report. +| | 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%) | -If the SDK has a `baseline.yml` or expected-failures file, note which failures are known/tracked vs. unexpected regressions. A low full-suite score where all failures are auth scenarios documented in the baseline is a scope gap (OAuth not yet implemented), not a quality problem — flag it accordingly in the assessment. +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. + +If the SDK has a `baseline.yml` or expected-failures file, cross-reference with the matrix to identify whether baselined failures cluster in a specific cell (e.g. all in `2025-11-25` / Client: Auth = scope gap). **P0 Label Audit Guidance:** @@ -197,12 +203,24 @@ After the subagents finish, output a short executive summary directly to the use ``` ## — Tier +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%)** | **✓/✗** | **✓/✗** | + +\* unique scenarios — a scenario may apply to multiple spec versions + +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"}). + +Repository Health: + | Check | Value | T2 | T1 | |-------|-------|----|----| -| Server Conformance | / (%) | ✓/✗ | ✓/✗ | -| Client Conformance (full) | / (%) | ✓/✗ | ✓/✗ | -| — Core scenarios | / (%) | — | — | -| — Auth scenarios | / (%) | — | — | | Issue Triage | % (/) | ✓/✗ | ✓/✗ | | Labels | / | ✓/✗ | ✓/✗ | | P0 Resolution | open | ✓/✗ | ✓/✗ | @@ -213,9 +231,6 @@ After the subagents finish, output a short executive summary directly to the use | Versioning Policy | | N/A | ✓/✗ | | Stable Release | | ✓/✗ | ✓/✗ | -If a baseline file was found, add a note below the table: -> **Baseline**: {N} failures in `baseline.yml` ({list of categories, e.g. "18 auth scenarios"}). Core suite: {core_rate}%. - --- **High-Priority Fixes:** diff --git a/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md b/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md index b36a2d2..077917b 100644 --- a/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md +++ b/.claude/skills/mcp-sdk-tier-audit/references/tier-requirements.md @@ -32,12 +32,13 @@ Source: `modelcontextprotocol/docs/community/sdk-tiers.mdx` in the spec reposito ## Conformance Score Calculation -Conformance scores are calculated against **applicable required tests** only: +Every scenario in the conformance suite has a `specVersions` field indicating which spec version it targets. The valid values are defined as the `SpecVersion` type (as a list) in `src/types.ts` — run `node dist/index.js list` to see the current mapping of scenarios to spec versions. -- Tests for the specification version the SDK targets -- Excluding tests marked as pending or skipped -- Excluding tests for experimental features -- Excluding legacy backward-compatibility tests (unless the SDK claims legacy support) +Date-versioned scenarios (e.g. `2025-06-18`, `2025-11-25`) count toward tier scoring. `draft` and `extension` scenarios are listed separately as informational. + +The `--spec-version` CLI flag filters scenarios cumulatively for date versions (e.g. `--spec-version 2025-06-18` includes `2025-03-26` + `2025-06-18`). For `draft`/`extension`, it returns exact matches only. + +The tier-check output includes a per-version pass rate breakdown alongside the aggregate. ## Tier Relegation Rules diff --git a/src/index.ts b/src/index.ts index d51eb0d..537fb16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,13 @@ import { listMetadataScenarios, listCoreScenarios, listExtensionScenarios, - listBackcompatScenarios + listBackcompatScenarios, + listScenariosForSpec, + listClientScenariosForSpec, + getScenarioSpecVersions, + ALL_SPEC_VERSIONS } from './scenarios'; +import type { SpecVersion } from './scenarios'; import { ConformanceCheck } from './types'; import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; import { @@ -31,6 +36,32 @@ import { import { createTierCheckCommand } from './tier-check'; 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. +function filterScenariosBySpecVersion( + allScenarios: string[], + version: SpecVersion, + command: 'client' | 'server' +): string[] { + const versionScenarios = + command === 'client' + ? listScenariosForSpec(version) + : listClientScenariosForSpec(version); + const allowed = new Set(versionScenarios); + return allScenarios.filter((s) => allowed.has(s)); +} + const program = new Command(); program @@ -53,12 +84,19 @@ program 'Path to YAML file listing expected failures (baseline)' ) .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') .action(async (options) => { try { const timeout = parseInt(options.timeout, 10); const verbose = options.verbose ?? false; const outputDir = options.outputDir; + const specVersionFilter = options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; // Handle suite mode if (options.suite) { @@ -85,7 +123,14 @@ program process.exit(1); } - const scenarios = suites[suiteName](); + let scenarios = suites[suiteName](); + if (specVersionFilter) { + scenarios = filterScenariosBySpecVersion( + scenarios, + specVersionFilter, + 'client' + ); + } console.log( `Running ${suiteName} suite (${scenarios.length} scenarios) in parallel...\n` ); @@ -262,6 +307,10 @@ program 'Path to YAML file listing expected failures (baseline)' ) .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 { @@ -270,6 +319,9 @@ program 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) { @@ -317,6 +369,14 @@ program process.exit(1); } + if (specVersionFilter) { + scenarios = filterScenariosBySpecVersion( + scenarios, + specVersionFilter, + 'server' + ); + } + console.log( `Running ${suite} suite (${scenarios.length} scenarios) against ${validated.url}\n` ); @@ -393,11 +453,29 @@ program .description('List available test scenarios') .option('--client', 'List client scenarios') .option('--server', 'List server scenarios') + .option( + '--spec-version ', + 'Filter scenarios by spec version (cumulative for date versions)' + ) .action((options) => { + const specVersionFilter = options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; + if (options.server || (!options.client && !options.server)) { console.log('Server scenarios (test against a server):'); - const serverScenarios = listClientScenarios(); - serverScenarios.forEach((s) => console.log(` - ${s}`)); + let serverScenarios = listClientScenarios(); + if (specVersionFilter) { + serverScenarios = filterScenariosBySpecVersion( + serverScenarios, + specVersionFilter, + 'server' + ); + } + serverScenarios.forEach((s) => { + const v = getScenarioSpecVersions(s); + console.log(` - ${s}${v ? ` [${v}]` : ''}`); + }); } if (options.client || (!options.client && !options.server)) { @@ -405,8 +483,18 @@ program console.log(''); } console.log('Client scenarios (test against a client):'); - const clientScenarios = listScenarios(); - clientScenarios.forEach((s) => console.log(` - ${s}`)); + let clientScenarioNames = listScenarios(); + if (specVersionFilter) { + clientScenarioNames = filterScenariosBySpecVersion( + clientScenarioNames, + specVersionFilter, + 'client' + ); + } + clientScenarioNames.forEach((s) => { + const v = getScenarioSpecVersions(s); + console.log(` - ${s}${v ? ` [${v}]` : ''}`); + }); } }); diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts index 64c87d5..9e5fe67 100644 --- a/src/scenarios/client/auth/basic-cimd.ts +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -1,5 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls } from '../../../types'; +import { ScenarioUrls, SpecVersion } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -22,6 +22,7 @@ export const CIMD_CLIENT_METADATA_URL = */ export class AuthBasicCIMDScenario implements Scenario { name = 'auth/basic-cimd'; + specVersions: SpecVersion[] = ['2025-11-25']; 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(); diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index b82b5e2..79ab1b2 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -1,6 +1,11 @@ import * as jose from 'jose'; import type { CryptoKey } from 'jose'; -import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import type { + Scenario, + ConformanceCheck, + ScenarioUrls, + SpecVersion +} from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -32,6 +37,7 @@ async function generateTestKeypair(): Promise<{ */ export class ClientCredentialsJwtScenario implements Scenario { name = 'auth/client-credentials-jwt'; + specVersions: SpecVersion[] = ['extension']; description = 'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)'; @@ -250,6 +256,7 @@ export class ClientCredentialsJwtScenario implements Scenario { */ export class ClientCredentialsBasicScenario implements Scenario { name = 'auth/client-credentials-basic'; + specVersions: SpecVersion[] = ['extension']; description = 'Tests OAuth client_credentials flow with client_secret_basic authentication'; diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index 077889d..05a351b 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -1,7 +1,12 @@ import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; -import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import type { + Scenario, + ConformanceCheck, + ScenarioUrls, + SpecVersion +} from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; @@ -55,6 +60,7 @@ async function createIdpIdToken( */ export class CrossAppAccessCompleteFlowScenario implements Scenario { name = 'auth/cross-app-access-complete-flow'; + specVersions: SpecVersion[] = ['extension']; description = 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise Managed OAuth)'; diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 6fc09a8..3dd64b7 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -87,6 +87,7 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { return { name: `auth/${config.name}`, + specVersions: ['2025-11-25'], description: `Tests Basic OAuth metadata discovery flow. **PRM:** ${config.prmLocation}${config.inWwwAuth ? '' : ' (not in WWW-Authenticate)'} diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 4f0a5ae..3bc857e 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -1,5 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls } from '../../../types'; +import { ScenarioUrls, SpecVersion } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -8,6 +8,7 @@ import { SpecReferences } from './spec-references'; export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { name = 'auth/2025-03-26-oauth-metadata-backcompat'; + specVersions: SpecVersion[] = ['2025-03-26']; description = 'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location'; private server = new ServerLifecycle(); @@ -68,6 +69,7 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { export class Auth20250326OEndpointFallbackScenario implements Scenario { name = 'auth/2025-03-26-oauth-endpoint-fallback'; + specVersions: SpecVersion[] = ['2025-03-26']; 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(); diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index 0d95e33..00673df 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -1,4 +1,9 @@ -import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; +import type { + Scenario, + ConformanceCheck, + ScenarioUrls, + SpecVersion +} from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -19,6 +24,7 @@ const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret'; */ export class PreRegistrationScenario implements Scenario { name = 'auth/pre-registration'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests OAuth flow with pre-registered client credentials. Server does not support DCR.'; diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index b38968f..dd76c68 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -1,5 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls } from '../../../types.js'; +import { ScenarioUrls, SpecVersion } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; @@ -27,6 +27,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; */ export class ResourceMismatchScenario implements Scenario { name = 'auth/resource-mismatch'; + specVersions: SpecVersion[] = ['draft']; description = 'Tests that client rejects when PRM resource does not match server URL'; allowClientError = true; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index b9865bd..166a9c3 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -1,5 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls } from '../../../types'; +import { ScenarioUrls, SpecVersion } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -15,6 +15,7 @@ import type { Request, Response, NextFunction } from 'express'; */ export class ScopeFromWwwAuthenticateScenario implements Scenario { name = 'auth/scope-from-www-authenticate'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests that client uses scope parameter from WWW-Authenticate header when provided'; private authServer = new ServerLifecycle(); @@ -100,6 +101,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { */ export class ScopeFromScopesSupportedScenario implements Scenario { name = 'auth/scope-from-scopes-supported'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests that client uses all scopes from scopes_supported when scope not in WWW-Authenticate header'; private authServer = new ServerLifecycle(); @@ -195,6 +197,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario { */ export class ScopeOmittedWhenUndefinedScenario implements Scenario { name = 'auth/scope-omitted-when-undefined'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests that client omits scope parameter when scopes_supported is undefined'; private authServer = new ServerLifecycle(); @@ -281,6 +284,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { */ export class ScopeStepUpAuthScenario implements Scenario { name = 'auth/scope-step-up'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests that client handles step-up authentication with different scope requirements per operation'; private authServer = new ServerLifecycle(); @@ -477,6 +481,7 @@ export class ScopeStepUpAuthScenario implements Scenario { */ export class ScopeRetryLimitScenario implements Scenario { name = 'auth/scope-retry-limit'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests that client implements retry limits to prevent infinite authorization loops on repeated 403 responses'; allowClientError = true; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 80b80f7..2b32444 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -1,5 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls } from '../../../types.js'; +import { ScenarioUrls, SpecVersion } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; @@ -45,6 +45,7 @@ const AUTH_METHOD_NAMES: Record = { class TokenEndpointAuthScenario implements Scenario { name: string; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description: string; private expectedAuthMethod: AuthMethod; private authServer = new ServerLifecycle(); diff --git a/src/scenarios/client/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index 88e6bf0..73bc07c 100644 --- a/src/scenarios/client/elicitation-defaults.ts +++ b/src/scenarios/client/elicitation-defaults.ts @@ -11,7 +11,7 @@ import { ListToolsRequestSchema, ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck } from '../../types'; +import type { Scenario, ConformanceCheck, SpecVersion } from '../../types'; import express, { Request, Response } from 'express'; import { ScenarioUrls } from '../../types'; import { createRequestLogger } from '../request-logger'; @@ -474,6 +474,7 @@ function createServer(checks: ConformanceCheck[]): { export class ElicitationClientDefaultsScenario implements Scenario { name = 'elicitation-sep1034-client-defaults'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests client applies default values for omitted elicitation fields (SEP-1034)'; private app: express.Application | null = null; diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index a351700..70fb0d1 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -1,9 +1,15 @@ import http from 'http'; -import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + SpecVersion +} from '../../types'; import { clientChecks } from '../../checks/index'; export class InitializeScenario implements Scenario { name = 'initialize'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = 'Tests MCP client initialization handshake'; private server: http.Server | null = null; diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index 6e44a06..c8f0792 100644 --- a/src/scenarios/client/sse-retry.ts +++ b/src/scenarios/client/sse-retry.ts @@ -8,10 +8,16 @@ */ import http from 'http'; -import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + SpecVersion +} from '../../types.js'; export class SSERetryScenario implements Scenario { name = 'sse-retry'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)'; diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index b074773..807ad55 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -4,7 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck } from '../../types'; +import type { Scenario, ConformanceCheck, SpecVersion } from '../../types'; import express, { Request, Response } from 'express'; import { ScenarioUrls } from '../../types'; import { createRequestLogger } from '../request-logger'; @@ -115,6 +115,7 @@ function createServerApp(checks: ConformanceCheck[]): express.Application { export class ToolsCallScenario implements Scenario { name = 'tools_call'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = 'Tests calling tools with various parameter types'; private app: express.Application | null = null; private httpServer: any = null; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 6e17101..d67fae4 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -1,4 +1,4 @@ -import { Scenario, ClientScenario } from '../types'; +import { Scenario, ClientScenario, SpecVersion } from '../types'; import { InitializeScenario } from './client/initialize'; import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; @@ -211,3 +211,34 @@ export function listBackcompatScenarios(): string[] { } export { listMetadataScenarios }; + +// All valid spec versions, used by the CLI to validate --spec-version input. +export const ALL_SPEC_VERSIONS: SpecVersion[] = [ + '2025-03-26', + '2025-06-18', + '2025-11-25', + 'draft', + 'extension' +]; + +export function listScenariosForSpec(version: SpecVersion): string[] { + return scenariosList + .filter((s) => s.specVersions.includes(version)) + .map((s) => s.name); +} + +export function listClientScenariosForSpec(version: SpecVersion): string[] { + return allClientScenariosList + .filter((s) => s.specVersions.includes(version)) + .map((s) => s.name); +} + +export function getScenarioSpecVersions( + name: string +): SpecVersion[] | undefined { + return ( + scenarios.get(name)?.specVersions ?? clientScenarios.get(name)?.specVersions + ); +} + +export type { SpecVersion }; diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index 2f21f8f..cd6c5f4 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -5,7 +5,7 @@ * to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details. */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { request } from 'undici'; const SPEC_REFERENCES = [ @@ -85,6 +85,7 @@ async function sendRequestWithHostAndOrigin( export class DNSRebindingProtectionScenario implements ClientScenario { name = 'dns-rebinding-protection'; + specVersions: SpecVersion[] = ['2025-11-25']; description = `Test DNS rebinding protection for localhost servers. **Scope:** This test applies to localhost MCP servers running without HTTPS and without diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index 2be114c..a458fec 100644 --- a/src/scenarios/server/elicitation-defaults.ts +++ b/src/scenarios/server/elicitation-defaults.ts @@ -2,12 +2,13 @@ * SEP-1034: Elicitation default values test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationDefaultsScenario implements ClientScenario { name = 'elicitation-sep1034-defaults'; + specVersions: SpecVersion[] = ['2025-11-25']; description = `Test elicitation with default values for all primitive types (SEP-1034). **Server Implementation Requirements:** diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts index e5c1fa3..c9eb598 100644 --- a/src/scenarios/server/elicitation-enums.ts +++ b/src/scenarios/server/elicitation-enums.ts @@ -2,12 +2,13 @@ * SEP-1330: Elicitation enum schema improvements test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; export class ElicitationEnumsScenario implements ClientScenario { name = 'elicitation-sep1330-enums'; + specVersions: SpecVersion[] = ['2025-11-25']; description = `Test elicitation with enum schema improvements (SEP-1330). **Server Implementation Requirements:** diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts index 2cfd08b..be20f2a 100644 --- a/src/scenarios/server/json-schema-2020-12.ts +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -6,7 +6,7 @@ * or additionalProperties fields. */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; import { connectToServer } from './client-helper.js'; const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool'; @@ -14,6 +14,7 @@ const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; 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). **Server Implementation Requirements:** diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index d9b341e..392a932 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -2,11 +2,12 @@ * Lifecycle test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; export class ServerInitializeScenario implements ClientScenario { name = 'server-initialize'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test basic server initialization handshake. **Server Implementation Requirements:** diff --git a/src/scenarios/server/prompts.ts b/src/scenarios/server/prompts.ts index 436564b..f62faac 100644 --- a/src/scenarios/server/prompts.ts +++ b/src/scenarios/server/prompts.ts @@ -2,11 +2,12 @@ * Prompts test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; export class PromptsListScenario implements ClientScenario { name = 'prompts-list'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test listing available prompts. **Server Implementation Requirements:** @@ -87,6 +88,7 @@ export class PromptsListScenario implements ClientScenario { export class PromptsGetSimpleScenario implements ClientScenario { name = 'prompts-get-simple'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test getting a simple prompt without arguments. **Server Implementation Requirements:** @@ -171,6 +173,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']; description = `Test parameterized prompt. **Server Implementation Requirements:** @@ -266,6 +269,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']; description = `Test prompt with embedded resource content. **Server Implementation Requirements:** @@ -371,6 +375,7 @@ Returns: export class PromptsGetWithImageScenario implements ClientScenario { name = 'prompts-get-with-image'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test prompt with image content. **Server Implementation Requirements:** diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index a4ed241..ec3c5fc 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -2,7 +2,7 @@ * Resources test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; import { TextResourceContents, @@ -11,6 +11,7 @@ import { export class ResourcesListScenario implements ClientScenario { name = 'resources-list'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test listing available resources. **Server Implementation Requirements:** @@ -91,6 +92,7 @@ export class ResourcesListScenario implements ClientScenario { export class ResourcesReadTextScenario implements ClientScenario { name = 'resources-read-text'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test reading text resource. **Server Implementation Requirements:** @@ -177,6 +179,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']; description = `Test reading binary resource. **Server Implementation Requirements:** @@ -261,6 +264,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']; description = `Test reading resource from template. **Server Implementation Requirements:** @@ -362,6 +366,7 @@ Returns (for \`uri: "test://template/123/data"\`): export class ResourcesSubscribeScenario implements ClientScenario { name = 'resources-subscribe'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test subscribing to resource updates. **Server Implementation Requirements:** @@ -432,6 +437,7 @@ Example request: export class ResourcesUnsubscribeScenario implements ClientScenario { name = 'resources-unsubscribe'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test unsubscribing from resource. **Server Implementation Requirements:** diff --git a/src/scenarios/server/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index 7cda3f2..ea025de 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -9,13 +9,14 @@ * Multiple concurrent streams are achieved via POST requests, each getting their own stream. */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; export class ServerSSEMultipleStreamsScenario implements ClientScenario { name = 'server-sse-multiple-streams'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Test server supports multiple concurrent POST SSE streams (SEP-1699)'; diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 5a3a240..30deee7 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -8,7 +8,7 @@ * - Replaying events when client reconnects with Last-Event-ID */ -import { ClientScenario, ConformanceCheck } from '../../types.js'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -67,6 +67,7 @@ function createLoggingFetch(checks: ConformanceCheck[]) { export class ServerSSEPollingScenario implements ClientScenario { name = 'server-sse-polling'; + specVersions: SpecVersion[] = ['2025-11-25']; description = 'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)'; diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index e445a5c..7ecbfdd 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -2,7 +2,7 @@ * Tools test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer, NotificationCollector } from './client-helper'; import { CallToolResultSchema, @@ -13,6 +13,7 @@ import { export class ToolsListScenario implements ClientScenario { name = 'tools-list'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test listing available tools. **Server Implementation Requirements:** @@ -95,6 +96,7 @@ export class ToolsListScenario implements ClientScenario { export class ToolsCallSimpleTextScenario implements ClientScenario { name = 'tools-call-simple-text'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test calling a tool that returns simple text. **Server Implementation Requirements:** @@ -179,6 +181,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']; description = `Test calling a tool that returns image content. **Server Implementation Requirements:** @@ -266,6 +269,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']; description = `Test tool returning multiple content types. **Server Implementation Requirements:** @@ -366,6 +370,7 @@ 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']; description = `Test tool that sends log messages during execution. **Server Implementation Requirements:** @@ -454,6 +459,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']; description = `Test tool error reporting. **Server Implementation Requirements:** @@ -538,6 +544,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']; description = `Test tool that reports progress notifications. **Server Implementation Requirements:** @@ -657,6 +664,7 @@ 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']; description = `Test tool that requests LLM sampling from client. **Server Implementation Requirements:** @@ -784,6 +792,7 @@ Implement tool \`test_sampling\` with argument: export class ToolsCallElicitationScenario implements ClientScenario { name = 'tools-call-elicitation'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test tool that requests user input (elicitation) from client. **Server Implementation Requirements:** @@ -914,6 +923,7 @@ Implement tool \`test_elicitation\` with argument: export class ToolsCallAudioScenario implements ClientScenario { name = 'tools-call-audio'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test calling a tool that returns audio content. **Server Implementation Requirements:** @@ -1008,6 +1018,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']; description = `Test calling a tool that returns embedded resource content. **Server Implementation Requirements:** diff --git a/src/scenarios/server/utils.ts b/src/scenarios/server/utils.ts index 07b2ca6..0a4a391 100644 --- a/src/scenarios/server/utils.ts +++ b/src/scenarios/server/utils.ts @@ -2,11 +2,12 @@ * Utilities test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; +import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; export class LoggingSetLevelScenario implements ClientScenario { name = 'logging-set-level'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test setting logging level. **Server Implementation Requirements:** @@ -85,6 +86,7 @@ export class LoggingSetLevelScenario implements ClientScenario { export class PingScenario implements ClientScenario { name = 'ping'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test ping utility for connection health check. **Server Implementation Requirements:** @@ -174,6 +176,7 @@ export class PingScenario implements ClientScenario { export class CompletionCompleteScenario implements ClientScenario { name = 'completion-complete'; + specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; description = `Test completion endpoint. **Server Implementation Requirements:** diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts new file mode 100644 index 0000000..0b8e652 --- /dev/null +++ b/src/scenarios/spec-version.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + listScenarios, + listClientScenarios, + listScenariosForSpec, + getScenarioSpecVersions, + ALL_SPEC_VERSIONS +} from './index'; + +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); + } + } + }); + + 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('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('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('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); + } + }); +}); diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index 55e4f6d..6637136 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -3,7 +3,11 @@ import { mkdtempSync, readFileSync, existsSync, globSync } from 'fs'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { ConformanceResult } from '../types'; -import { listScenarios, listActiveClientScenarios } from '../../scenarios'; +import { + listScenarios, + listActiveClientScenarios, + getScenarioSpecVersions +} from '../../scenarios'; import { ConformanceCheck } from '../../types'; /** @@ -105,6 +109,15 @@ function reconcileWithExpected( }) ); + // Attach specVersion to existing detail entries + for (const detail of result.details) { + let name = stripTimestamp(detail.scenario); + if (resultPrefix) { + name = name.replace(new RegExp(`^${resultPrefix}-`), ''); + } + detail.specVersions = getScenarioSpecVersions(name); + } + for (const expected of expectedScenarios) { if (!reportedNames.has(expected)) { result.failed++; @@ -113,7 +126,8 @@ function reconcileWithExpected( scenario: expected, passed: false, checks_passed: 0, - checks_failed: 0 + checks_failed: 0, + specVersions: getScenarioSpecVersions(expected) }); } } diff --git a/src/tier-check/output.ts b/src/tier-check/output.ts index d7e9fc8..d4a857f 100644 --- a/src/tier-check/output.ts +++ b/src/tier-check/output.ts @@ -1,4 +1,4 @@ -import { TierScorecard, CheckStatus } from './types'; +import { TierScorecard, CheckStatus, ConformanceResult } from './types'; const COLORS = { RESET: '\x1b[0m', @@ -23,6 +23,79 @@ function statusIcon(status: CheckStatus): string { } } +const SPEC_VERSIONS = [ + '2025-03-26', + '2025-06-18', + '2025-11-25', + 'draft', + 'extension' +] as const; + +type Cell = { passed: number; total: number }; + +interface MatrixRow { + cells: Map; + unique: Cell; +} + +function newRow(): MatrixRow { + return { cells: new Map(), unique: { passed: 0, total: 0 } }; +} + +interface ConformanceMatrix { + server: MatrixRow; + clientCore: MatrixRow; + clientAuth: MatrixRow; +} + +function buildConformanceMatrix( + server: ConformanceResult, + client: ConformanceResult +): ConformanceMatrix { + const matrix: ConformanceMatrix = { + server: newRow(), + clientCore: newRow(), + 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 }; + cell.total++; + if (d.passed) cell.passed++; + matrix.server.cells.set(v, cell); + } + } + + 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); + } + } + + return matrix; +} + +function formatCell(cell: Cell | undefined): string { + if (!cell || cell.total === 0) return '\u2014'; + return `${cell.passed}/${cell.total}`; +} + +function formatRate(cell: Cell): string { + if (cell.total === 0) return '0/0'; + return `${cell.passed}/${cell.total} (${Math.round((cell.passed / cell.total) * 100)}%)`; +} + export function formatJson(scorecard: TierScorecard): string { return JSON.stringify(scorecard, null, 2); } @@ -42,12 +115,33 @@ export function formatMarkdown(scorecard: TierScorecard): string { lines.push(''); lines.push('| Check | Status | Detail |'); lines.push('|-------|--------|--------|'); - lines.push( - `| Server Conformance | ${c.conformance.status} | ${c.conformance.passed}/${c.conformance.total} scenarios pass (${Math.round(c.conformance.pass_rate * 100)}%) |` + // 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('')}---|`); + + const mdRows: [string, MatrixRow][] = [ + ['Server', matrix.server], + ['Client: Core', matrix.clientCore], + ['Client: Auth', matrix.clientAuth] + ]; + + for (const [label, row] of mdRows) { + lines.push( + `| ${label} | ${SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} | ${formatRate(row.unique)} |` + ); + } + + lines.push(''); lines.push( - `| Client Conformance | ${c.client_conformance.status} | ${c.client_conformance.passed}/${c.client_conformance.total} scenarios pass (${Math.round(c.client_conformance.pass_rate * 100)}%) |` + '_* unique scenarios — a scenario may apply to multiple spec versions_' ); + 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(', ')})` : ''} |` ); @@ -100,14 +194,51 @@ export function formatTerminal(scorecard: TierScorecard): void { if (scorecard.version) console.log(`Version: ${scorecard.version}`); console.log(`Timestamp: ${scorecard.timestamp}\n`); - console.log(`${COLORS.BOLD}Check Results:${COLORS.RESET}\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( - ` ${statusIcon(c.conformance.status)} Server Conformance ${c.conformance.passed}/${c.conformance.total} (${Math.round(c.conformance.pass_rate * 100)}%)` + ` ${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 : ''; + 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}` + ); + } + + // 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( - ` ${statusIcon(c.client_conformance.status)} Client Conformance ${c.client_conformance.passed}/${c.client_conformance.total} (${Math.round(c.client_conformance.pass_rate * 100)}%)` + `\n ${COLORS.DIM}* unique scenarios — a scenario may apply to multiple spec versions${COLORS.RESET}` ); + 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 ff6be5d..a9830f4 100644 --- a/src/tier-check/types.ts +++ b/src/tier-check/types.ts @@ -1,3 +1,5 @@ +import type { SpecVersion } from '../types'; + export type CheckStatus = 'pass' | 'fail' | 'partial' | 'skipped'; export interface CheckResult { @@ -15,6 +17,7 @@ export interface ConformanceResult extends CheckResult { passed: boolean; checks_passed: number; checks_failed: number; + specVersions?: SpecVersion[]; }>; } diff --git a/src/types.ts b/src/types.ts index 5dd6421..c086e65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,13 @@ export interface ConformanceCheck { logs?: string[]; } +export type SpecVersion = + | '2025-03-26' + | '2025-06-18' + | '2025-11-25' + | 'draft' + | 'extension'; + export interface ScenarioUrls { serverUrl: string; authUrl?: string; @@ -36,6 +43,7 @@ export interface ScenarioUrls { export interface Scenario { name: string; description: string; + specVersions: SpecVersion[]; /** * 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). @@ -49,5 +57,6 @@ export interface Scenario { export interface ClientScenario { name: string; description: string; + specVersions: SpecVersion[]; run(serverUrl: string): Promise; } From 8ab0831130320fb08292dd4e665b3f2e42287c90 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 13 Feb 2026 19:13:12 +0000 Subject: [PATCH 087/226] 0.1.14 (#149) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 250c002..4ea7956 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.13", + "version": "0.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.13", + "version": "0.1.14", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index a9f1e8f..1d6426f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.13", + "version": "0.1.14", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 066b2d70800a8bd6bca82e000007c9877b443e5b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:09:31 +0000 Subject: [PATCH 088/226] fix: tier-check branch support and draft/extension scoring (#153) * fix: pass --branch to tier-check CLI in tier-audit skill The skill was not forwarding the --branch argument to the tier-check CLI, causing policy signal checks to always run against the repo's default branch. Files on feature branches showed as 'Not found.' Now derives the branch from the local checkout if not explicitly provided, and always passes it to the CLI. * fix: exclude draft/extension scenarios from tier-scoring conformance rates SEP-1730 says date-versioned scenarios count toward tier scoring while draft and extension scenarios are informational. The CLI was including all scenarios in pass_rate, causing extension-only failures to block Tier 1. Changes: - pass_rate now only counts scenarios with at least one date-versioned spec version - Terminal and markdown output split into a tier-scoring matrix (date-versioned + All*) and an informational section (draft/extension) that only renders when there are draft/extension scenarios --- .claude/skills/mcp-sdk-tier-audit/SKILL.md | 50 ++++--- .../checks/test-conformance-results.ts | 21 ++- src/tier-check/output.ts | 129 +++++++++++++----- 3 files changed, 148 insertions(+), 52 deletions(-) diff --git a/.claude/skills/mcp-sdk-tier-audit/SKILL.md b/.claude/skills/mcp-sdk-tier-audit/SKILL.md index ca45df9..5da0fbd 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: ' [client-cmd] [--branch ]' --- # MCP SDK Tier Audit @@ -43,6 +43,7 @@ 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`) - **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. +- **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. @@ -59,12 +60,13 @@ The `tier-check` CLI handles all deterministic checks — server conformance, cl ```bash npm run --silent tier-check -- \ --repo \ + --branch \ --conformance-server-url \ --client-cmd '' \ --output json ``` -If no client-cmd was detected, omit the `--client-cmd` flag (client conformance will be skipped). +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 +117,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% (date-versioned scenarios only; `draft` and `extension` are informational and not scored) +- Client conformance test pass rate == 100% (date-versioned scenarios only; `draft` and `extension` are informational and not scored) - 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 +129,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 +153,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 +215,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/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index 6637136..5ba9849 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -8,7 +8,15 @@ import { listActiveClientScenarios, getScenarioSpecVersions } from '../../scenarios'; -import { ConformanceCheck } from '../../types'; +import { ConformanceCheck, SpecVersion } from '../../types'; + +const NON_SCORING_VERSIONS: SpecVersion[] = ['draft', 'extension']; + +/** Whether a scenario counts toward tier scoring (has at least one date-versioned spec). */ +function isTierScoring(specVersions?: SpecVersion[]): boolean { + if (!specVersions || specVersions.length === 0) return true; // unknown = count it + return specVersions.some((v) => !NON_SCORING_VERSIONS.includes(v)); +} /** * Parse conformance results from an output directory. @@ -132,7 +140,16 @@ function reconcileWithExpected( } } - result.pass_rate = result.total > 0 ? result.passed / result.total : 0; + // pass_rate only counts tier-scoring scenarios (date-versioned, not draft/extension). + // passed/failed/total reflect ALL scenarios for full reporting; pass_rate and status + // reflect only tier-scoring scenarios for tier logic. + const tierDetails = result.details.filter((d) => + isTierScoring(d.specVersions) + ); + const tierPassed = tierDetails.filter((d) => d.passed).length; + const tierTotal = tierDetails.length; + + result.pass_rate = tierTotal > 0 ? tierPassed / tierTotal : 0; result.status = result.pass_rate >= 1.0 ? 'pass' diff --git a/src/tier-check/output.ts b/src/tier-check/output.ts index d4a857f..9e7aa57 100644 --- a/src/tier-check/output.ts +++ b/src/tier-check/output.ts @@ -23,23 +23,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 = ['2025-03-26', '2025-06-18', '2025-11-25'] as const; + +const INFO_SPEC_VERSIONS = ['draft', '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 +63,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; @@ -121,9 +129,10 @@ export function formatMarkdown(scorecard: TierScorecard): string { c.client_conformance as ConformanceResult ); + // Tier-scoring matrix lines.push(''); - lines.push(`| | ${SPEC_VERSIONS.join(' | ')} | All* |`); - lines.push(`|---|${SPEC_VERSIONS.map(() => '---|').join('')}---|`); + lines.push(`| | ${TIER_SPEC_VERSIONS.join(' | ')} | All* |`); + lines.push(`|---|${TIER_SPEC_VERSIONS.map(() => '---|').join('')}---|`); const mdRows: [string, MatrixRow][] = [ ['Server', matrix.server], @@ -133,7 +142,7 @@ export function formatMarkdown(scorecard: TierScorecard): string { for (const [label, row] of mdRows) { lines.push( - `| ${label} | ${SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} | ${formatRate(row.unique)} |` + `| ${label} | ${TIER_SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} | ${formatRate(row.tierUnique)} |` ); } @@ -141,6 +150,31 @@ export function formatMarkdown(scorecard: TierScorecard): string { lines.push( '_* 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( `| Labels | ${c.labels.status} | ${c.labels.present}/${c.labels.required} required labels${c.labels.missing.length > 0 ? ` (missing: ${c.labels.missing.join(', ')})` : ''} |` @@ -208,8 +242,9 @@ export function formatTerminal(scorecard: TierScorecard): void { 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)} ${SPEC_VERSIONS.map((v) => rp(v, vw)).join(' ')} ${rp('All*', tw)}${COLORS.RESET}` + ` ${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][] = [ @@ -223,21 +258,49 @@ export function formatTerminal(scorecard: TierScorecard): void { const b = bold ? COLORS.BOLD : ''; const r = bold ? COLORS.RESET : ''; 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}` + ` ${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 - const clientTotal: Cell = { - passed: matrix.clientCore.unique.passed + matrix.clientAuth.unique.passed, - total: matrix.clientCore.unique.total + matrix.clientAuth.unique.total + // 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(SPEC_VERSIONS.length * (vw + 1) - 1)} ${COLORS.BOLD}${rp(formatRate(clientTotal), tw)}${COLORS.RESET}` + ` ${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` From 50c3cf1a430274ebdaaddeb95ef2c786a6bca290 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 20 Feb 2026 15:32:19 +0100 Subject: [PATCH 089/226] fix: include tenant path in auth server `issuer` claim (#152) - Fixes gh-140 Signed-off-by: Daniel Garnier-Moiroux --- src/scenarios/client/auth/helpers/createAuthServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 8621490..c8c4ecd 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -134,7 +134,7 @@ export function createAuthServer( }); const metadata: any = { - issuer: getAuthBaseUrl(), + issuer: `${getAuthBaseUrl()}${routePrefix}`, authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, ...(!disableDynamicRegistration && { From a2855b03582a6c0b31065ad4d9af248316ce61a3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 20 Feb 2026 14:41:15 +0000 Subject: [PATCH 090/226] 0.1.15 (#156) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ea7956..9d8a9cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.14", + "version": "0.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.14", + "version": "0.1.15", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 1d6426f..0d2a446 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.14", + "version": "0.1.15", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 22f5c8bb1c47fd1b445db99190d12c84d989f582 Mon Sep 17 00:00:00 2001 From: Maciej Kisiel Date: Mon, 2 Mar 2026 11:03:15 +0100 Subject: [PATCH 091/226] chore: update Node.js version in the conformance action to 22 (#162) With Node.js 20 the action fails with: ``` SyntaxError: The requested module 'fs' does not provide an export named 'globSync' at ModuleJob._instantiate (node:internal/modules/esm/module_job:213:21) at async ModuleJob.run (node:internal/modules/esm/module_job:320:5) at async ModuleLoader.import (node:internal/modules/esm/loader:606:24) at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5) ``` `fs.globSync` was added in Node.js 22. --- action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index e5fe549..756a127 100644 --- a/action.yml +++ b/action.yml @@ -28,9 +28,9 @@ 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: From 02a5f1c41df7baa29ccd9e2e4df18ff1ed9afa44 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Wed, 18 Mar 2026 01:34:17 -0700 Subject: [PATCH 092/226] feat (auth): add sep-2207 client checks (#166) * feat: add sep-2207 client checks * Update description for the offline access test Co-authored-by: Paul Carleton --------- Co-authored-by: Paul Carleton --- .../clients/typescript/everything-client.ts | 5 +- src/index.ts | 4 +- src/scenarios/client/auth/index.test.ts | 21 +- src/scenarios/client/auth/index.ts | 12 +- src/scenarios/client/auth/offline-access.ts | 308 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 4 + src/scenarios/index.ts | 6 + 7 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/client/auth/offline-access.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 21804a8..3f86339 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -146,7 +146,10 @@ 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' ], runAuthClient ); diff --git a/src/index.ts b/src/index.ts index 537fb16..e2d6e02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { listCoreScenarios, listExtensionScenarios, listBackcompatScenarios, + listDraftScenarios, listScenariosForSpec, listClientScenariosForSpec, getScenarioSpecVersions, @@ -112,6 +113,7 @@ program backcompat: listBackcompatScenarios, auth: listAuthScenarios, metadata: listMetadataScenarios, + draft: listDraftScenarios, 'sep-835': () => listAuthScenarios().filter((name) => name.startsWith('auth/scope-')) }; @@ -230,7 +232,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); } diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index e43f0d3..e18a467 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,4 +1,8 @@ -import { authScenariosList, backcompatScenariosList } from './index'; +import { + authScenariosList, + backcompatScenariosList, + draftScenariosList +} from './index'; import { runClientAgainstScenario, InlineClientRunner @@ -61,6 +65,21 @@ 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('Negative tests', () => { test('bad client requests root PRM location', async () => { const runner = new InlineClientRunner(badPrmClient); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 73c9ddb..7f65aa0 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -24,6 +24,10 @@ import { import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; import { CrossAppAccessCompleteFlowScenario } from './cross-app-access'; +import { + OfflineAccessScopeScenario, + OfflineAccessNotSupportedScenario +} from './offline-access'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -37,7 +41,6 @@ export const authScenariosList: Scenario[] = [ new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), new PublicClientAuthScenario(), - new ResourceMismatchScenario(), new PreRegistrationScenario() ]; @@ -53,3 +56,10 @@ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsBasicScenario(), new CrossAppAccessCompleteFlowScenario() ]; + +// Draft scenarios (informational - not scored for tier assessment) +export const draftScenariosList: Scenario[] = [ + new ResourceMismatchScenario(), + new OfflineAccessScopeScenario(), + new OfflineAccessNotSupportedScenario() +]; diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts new file mode 100644 index 0000000..e4ed777 --- /dev/null +++ b/src/scenarios/client/auth/offline-access.ts @@ -0,0 +1,308 @@ +import type { Scenario, ConformanceCheck } from '../../../types'; +import { ScenarioUrls, SpecVersion } 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'; + specVersions: SpecVersion[] = ['draft']; + 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(): Promise { + this.checks = []; + this.grantTypesChecked = false; + this.capturedCimdUrl = undefined; + + const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']); + + const authApp = createAuthServer(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( + 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'; + specVersions: SpecVersion[] = ['draft']; + 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(): Promise { + this.checks = []; + + const tokenVerifier = new MockTokenVerifier(this.checks, [ + 'mcp:basic', + 'mcp:read' + ]); + + const authApp = createAuthServer(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( + 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/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 4020bfc..768dd65 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -100,5 +100,9 @@ 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' + }, + SEP_2207_REFRESH_TOKEN_GUIDANCE: { + id: 'SEP-2207-Refresh-Token-Guidance', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207' } }; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index d67fae4..36c8f32 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -56,6 +56,7 @@ import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; import { authScenariosList, backcompatScenariosList, + draftScenariosList, extensionScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; @@ -149,6 +150,7 @@ const scenariosList: Scenario[] = [ new SSERetryScenario(), ...authScenariosList, ...backcompatScenariosList, + ...draftScenariosList, ...extensionScenariosList ]; @@ -210,6 +212,10 @@ export function listBackcompatScenarios(): string[] { return backcompatScenariosList.map((scenario) => scenario.name); } +export function listDraftScenarios(): string[] { + return draftScenariosList.map((scenario) => scenario.name); +} + export { listMetadataScenarios }; // All valid spec versions, used by the CLI to validate --spec-version input. From 8f3994c75ff1aed1e39f91cff9358e2bc2c81dcd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 24 Mar 2026 16:18:54 +0000 Subject: [PATCH 093/226] chore(deps): consolidate dependabot updates (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): consolidate dependabot updates Bumps all pending dependency updates into one commit: Root package: - zod 3.25.76 → 4.3.6 (major) - @types/node 22 → 25 (major) - @modelcontextprotocol/sdk 1.26.0 → 1.27.1 - tsdown 0.15.12 → 0.21.4 - prettier 3.6.2 → 3.8.1 - typescript-eslint 8.48.0 → 8.57.1 - lefthook 2.0.2 → 2.1.4 - eslint/@eslint/js → 9.39.4 (kept at v9, typescript-eslint lacks v10 support) - Regenerated lockfile to pick up transitive security fixes (hono, @hono/node-server, express-rate-limit, flatted, rollup, minimatch, ajv, qs, undici) examples/servers/typescript: - @modelcontextprotocol/sdk → 1.27.1 - Removed zod-to-json-schema (broken with zod v4) - Added zod ^4 as direct dep - Regenerated lockfile GitHub Actions: - actions/setup-node v4 → v6 in action.yml Code changes for zod v4: - ZodError.errors → ZodError.issues (src/index.ts) - .refine() callback signature updated (src/schemas.ts) - everything-server: replaced zodToJsonSchema with SDK's toJsonSchemaCompat helper (handles zod v3/v4/v4-mini) Build fix for tsdown 0.21: - Added --no-fixed-extension to preserve dist/index.js output (tsdown 0.21 defaults to .mjs for type:module packages) * fix: include all platform binaries in lockfile The lockfile was generated on macOS which caused npm to only resolve darwin-arm64 entries for platform-specific optional dependencies (esbuild, rolldown bindings). This broke npm ci on Linux CI runners. Regenerated from a clean state to include all 26 esbuild platforms and all 15 rolldown platform bindings. --- action.yml | 2 +- .../servers/typescript/everything-server.ts | 35 +- examples/servers/typescript/package-lock.json | 404 +-- examples/servers/typescript/package.json | 4 +- package-lock.json | 2403 +++++++++-------- package.json | 24 +- src/index.ts | 4 +- src/schemas.ts | 27 +- 8 files changed, 1581 insertions(+), 1322 deletions(-) diff --git a/action.yml b/action.yml index 756a127..3c6fb21 100644 --- a/action.yml +++ b/action.yml @@ -35,7 +35,7 @@ runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 374e48c..91559f8 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -25,8 +25,8 @@ import { 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 cors from 'cors'; import { randomUUID } from 'crypto'; @@ -1006,31 +1006,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, diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index 80a2f37..71f4cba 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.27.1", "@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.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "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.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", + "dependencies": { + "ip-address": "10.1.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.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "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.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "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", @@ -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..9b39cf9 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.27.1", "@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/package-lock.json b/package-lock.json index 9d8a9cd..b2ef554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.15", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "@octokit/rest": "^22.0.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", @@ -17,111 +17,112 @@ "jose": "^6.1.2", "undici": "^7.19.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.5.0", + "@typescript/native-preview": "^7.0.0-dev.20260323.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.4", + "prettier": "^3.8.1", + "tsdown": "^0.21.4", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "8.48.0", + "typescript-eslint": "^8.57.1", "vitest": "^4.0.16" } }, "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.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.2.tgz", + "integrity": "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^8.0.0-rc.2", + "@babel/types": "^8.0.0-rc.2", "@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.2", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.2.tgz", + "integrity": "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==", "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.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.2.tgz", + "integrity": "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^8.0.0-rc.2" }, "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.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.2.tgz", + "integrity": "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==", "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.2", + "@babel/helper-validator-identifier": "^8.0.0-rc.2" }, "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.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "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.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -130,9 +131,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.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -141,9 +142,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 +159,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 +176,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 +193,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 +210,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 +227,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 +244,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 +261,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 +278,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 +295,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 +312,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 +329,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 +346,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 +363,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 +380,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 +397,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 +414,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 +431,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 +448,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 +465,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 +482,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 +499,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 +516,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 +533,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 +550,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 +567,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 +626,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 +667,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 +691,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 +715,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 +752,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.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -854,9 +855,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.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -938,9 +939,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 +1014,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 +1067,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.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", "dev": true, "license": "MIT", "funding": { @@ -1088,9 +1090,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "cpu": [ "arm64" ], @@ -1105,9 +1107,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], @@ -1122,9 +1124,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], @@ -1139,9 +1141,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], @@ -1156,9 +1158,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], @@ -1173,9 +1175,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], @@ -1190,9 +1192,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], @@ -1206,10 +1208,44 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ "x64" ], @@ -1224,9 +1260,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ "x64" ], @@ -1241,9 +1277,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "cpu": [ "arm64" ], @@ -1258,9 +1294,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "cpu": [ "wasm32" ], @@ -1268,16 +1304,16 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.7" + "@napi-rs/wasm-runtime": "^1.1.1" }, "engines": { "node": ">=14.0.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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ "arm64" ], @@ -1291,27 +1327,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ "x64" ], @@ -1326,362 +1345,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.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", "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 +1447,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 +1462,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.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.18.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 +1507,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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "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.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1855,8 +1530,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -1871,17 +1546,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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "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.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1891,20 +1566,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "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.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1918,14 +1593,14 @@ } }, "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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1936,9 +1611,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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, "license": "MIT", "engines": { @@ -1953,17 +1628,17 @@ } }, "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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "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.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1973,14 +1648,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, "license": "MIT", "engines": { @@ -1992,21 +1667,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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "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.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "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.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2019,43 +1694,56 @@ "typescript": ">=4.8.4 <6.0.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.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "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.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "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.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2065,19 +1753,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2087,29 +1775,42 @@ "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-e8rnqL5I4DUSjiy6jiWqYFKcgKq8tC4S+uEmJwWiyTgSIGDhaRUujJ2pb6EXmi+NPeXoES6vIG7e9BsEV0WSow==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "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.20260323.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260323.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260323.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260323.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260323.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260323.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260323.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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-C3tQdgMaYn57xzRUWek+zNKMiP0z9j7fqbCjr0wlyiLNIh5jMwDArjBDKHlqSu58FKvZg7baqbqB5Mcepb3s6w==", "cpu": [ "arm64" ], @@ -2121,9 +1822,9 @@ ] }, "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-YE6pD4wdMqNgaBkXicQBLFwABOEmLxDclSM7grl0fw4cQSbfVwFCbSlwFDkmISKdmsgtWdYeMohrsTU710ufpg==", "cpu": [ "x64" ], @@ -2135,9 +1836,9 @@ ] }, "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-6zFO/SF9Gu8rtRyEt1b10TNapQGq5b/wUjCLG14675n155r4EO3JFMgnltBViV2Eatnq7G+bXD65BUBk7Ixyhw==", "cpu": [ "arm" ], @@ -2149,9 +1850,9 @@ ] }, "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-Jzr2gBY0ifA3XejAl7kPeHLT72JFBfzLSafOAQbANh5Iag02uPl99k8ORMfKREbYgEMMOzqPpe+r6eaKy+VEfw==", "cpu": [ "arm64" ], @@ -2163,9 +1864,9 @@ ] }, "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-UvsQdVI/LayXTowltMgtg2GHU/a/lcuOYbaAYm9+/nvgIs01VqVo0s1/lTSNBzepEk0y1EOYQ6SIsRM9lLGHxA==", "cpu": [ "x64" ], @@ -2177,9 +1878,9 @@ ] }, "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-3208Xoe+3Xblf6WLVTkIdyh6401zB2eXAhu1UaDpZY0rf8SMHCxKW3TSJDytpri5UivCotZ0CNC2wgJ1TlymUA==", "cpu": [ "arm64" ], @@ -2191,9 +1892,9 @@ ] }, "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.20260323.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260323.1.tgz", + "integrity": "sha512-DAiRqrcs58eXwjFOtbklbIHq70IpW7uYz1Bx3kNAzqoWlA7R4mC29N6G0kGEZDalGmj7f0HVuckq9AzaC1r6oQ==", "cpu": [ "x64" ], @@ -2205,17 +1906,17 @@ ] }, "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.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", "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", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2223,13 +1924,13 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.1", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2238,7 +1939,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,9 +1951,9 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2263,13 +1964,13 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.1", "pathe": "^2.0.3" }, "funding": { @@ -2277,13 +1978,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.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2292,9 +1994,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.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", "dev": true, "license": "MIT", "funding": { @@ -2302,13 +2004,14 @@ } }, "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.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -2329,9 +2032,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 +2055,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", @@ -2428,13 +2131,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 +2162,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": { @@ -2512,13 +2216,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 +2291,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 +2349,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", @@ -2750,14 +2445,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 +2534,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 +2553,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 +2566,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 +2614,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 +2651,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 +2720,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": { @@ -3206,12 +2901,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.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -3359,9 +3054,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 +3140,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 +3190,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 +3225,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.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "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.0", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz", + "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", "dev": true, "license": "MIT" }, @@ -3615,6 +3303,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-without-cache": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz", + "integrity": "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==", + "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 +3333,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.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -3684,20 +3385,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 +3446,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 +3463,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.4", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.4.tgz", + "integrity": "sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3776,22 +3473,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.4", + "lefthook-darwin-x64": "2.1.4", + "lefthook-freebsd-arm64": "2.1.4", + "lefthook-freebsd-x64": "2.1.4", + "lefthook-linux-arm64": "2.1.4", + "lefthook-linux-x64": "2.1.4", + "lefthook-openbsd-arm64": "2.1.4", + "lefthook-openbsd-x64": "2.1.4", + "lefthook-windows-arm64": "2.1.4", + "lefthook-windows-x64": "2.1.4" } }, "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.4", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.4.tgz", + "integrity": "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw==", "cpu": [ "arm64" ], @@ -3803,9 +3500,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.4", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.4.tgz", + "integrity": "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q==", "cpu": [ "x64" ], @@ -3817,9 +3514,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.4", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.4.tgz", + "integrity": "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw==", "cpu": [ "arm64" ], @@ -3831,9 +3528,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.4", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.4.tgz", + "integrity": "sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ==", "cpu": [ "x64" ], @@ -3845,9 +3542,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.4", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.4.tgz", + "integrity": "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA==", "cpu": [ "arm64" ], @@ -3859,9 +3556,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.4", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.4.tgz", + "integrity": "sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA==", "cpu": [ "x64" ], @@ -3873,9 +3570,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.4", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.4.tgz", + "integrity": "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw==", "cpu": [ "arm64" ], @@ -3887,9 +3584,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.4", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.4.tgz", + "integrity": "sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg==", "cpu": [ "x64" ], @@ -3901,9 +3598,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.4", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.4.tgz", + "integrity": "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA==", "cpu": [ "arm64" ], @@ -3915,9 +3612,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.4", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.4.tgz", + "integrity": "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw==", "cpu": [ "x64" ], @@ -3942,6 +3639,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 +3989,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": { @@ -4275,9 +4233,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.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -4314,9 +4272,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.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -4353,9 +4311,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 +4366,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 +4396,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.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.95.0", - "@rolldown/pluginutils": "1.0.0-beta.45" + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4468,38 +4412,39 @@ "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.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, "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.22.5", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.22.5.tgz", + "integrity": "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==", "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.2", + "@babel/helper-validator-identifier": "8.0.0-rc.2", + "@babel/parser": "8.0.0-rc.2", + "@babel/types": "8.0.0-rc.2", + "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.6", + "obug": "^2.1.1" }, "engines": { "node": ">=20.19.0" @@ -4510,9 +4455,9 @@ "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" + "rolldown": "^1.0.0-rc.3", + "typescript": "^5.0.0 || ^6.0.0-beta", + "vue-tsc": "~3.2.0" }, "peerDependenciesMeta": { "@ts-macro/tsc": { @@ -4529,51 +4474,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 +4687,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 +4727,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.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -4854,9 +4754,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 +4783,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 +4796,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.4", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.4.tgz", + "integrity": "sha512-Q/kBi8SXkr4X6JI/NAZKZY1UuiEcbuXtIskL4tZCsgpDiEPM/2W6lC+OonNA31S+V3KsWedFvbFDBs23hvt+Aw==", "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.4", "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", + "hookable": "^6.1.0", + "import-without-cache": "^0.2.5", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "rolldown": "1.0.0-rc.9", + "rolldown-plugin-dts": "^0.22.5", + "semver": "^7.7.4", + "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", - "unconfig": "^7.3.3" + "unconfig-core": "^7.5.0", + "unrun": "^0.2.32" }, "bin": { "tsdown": "dist/run.mjs" @@ -4928,30 +4830,34 @@ }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.21.4", + "@tsdown/exe": "0.21.4", + "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0", - "unplugin-lightningcss": "^0.4.0", - "unplugin-unused": "^0.5.0", - "unrun": "^0.2.1" + "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 +4931,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.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", "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.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5044,31 +4950,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.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" - } - }, "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 +4969,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.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", "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.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -5110,6 +4999,33 @@ "node": ">= 0.8" } }, + "node_modules/unrun": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.32.tgz", + "integrity": "sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rolldown": "1.0.0-rc.9" + }, + "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,17 +5046,16 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.11", "tinyglobby": "^0.2.15" }, "bin": { @@ -5157,9 +5072,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.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 +5088,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -5204,32 +5123,338 @@ } } }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "node_modules/vite/node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/rolldown": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "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", + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + } + }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "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", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -5245,12 +5470,13 @@ "@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.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -5279,6 +5505,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -5331,9 +5560,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 +5588,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 0d2a446..71f41d7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "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", @@ -30,30 +30,30 @@ "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.5.0", + "@typescript/native-preview": "^7.0.0-dev.20260323.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.4", + "prettier": "^3.8.1", + "tsdown": "^0.21.4", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "8.48.0", + "typescript-eslint": "^8.57.1", "vitest": "^4.0.16" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "@octokit/rest": "^22.0.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", "jose": "^6.1.2", "undici": "^7.19.0", "yaml": "^2.8.2", - "zod": "^3.25.76", - "@octokit/rest": "^22.0.0" + "zod": "^4.3.6" } } diff --git a/src/index.ts b/src/index.ts index e2d6e02..9aeb0f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -278,7 +278,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:'); @@ -434,7 +434,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 server scenarios:'); diff --git a/src/schemas.ts b/src/schemas.ts index 5e81c8c..aa51abe 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -7,12 +7,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,12 +30,9 @@ 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() }); @@ -49,12 +43,9 @@ 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; From 4859a81547d49308dc2466425711f4cfbee4937f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 25 Mar 2026 11:42:06 +0000 Subject: [PATCH 094/226] docs: add AGENTS.md and CONTRIBUTING.md (#194) * docs: add AGENTS.md and CONTRIBUTING.md Codifies scenario design guidelines from past PR reviews: - fewer scenarios with more checks (CI cost multiplies per-SDK) - same check id for SUCCESS and FAIL, optimize for Ctrl+F - extend the everything-client/server rather than adding new example files - prove it passes and fails - reuse the CLI runner, don't add parallel entry points Also asks contributors to open an issue for discussion before a PR, and to target specific spec MUSTs or open issues rather than running generic AI bug-hunts against the harness. * docs: broaden issue-first guidance to cover bugs, not just scenarios --- AGENTS.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 46 ++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35e854f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# 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. +- Include `specReferences` pointing to the relevant spec section. + +## 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. + +## 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. **Evidence it fails when it should** — ideally a negative example (a deliberately broken client), or at minimum a manual run showing the failure mode. + +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` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7f018da --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# 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 + +```sh +# Against the bundled TypeScript example +npm run build +node dist/index.js client --command "tsx examples/clients/typescript/everything-client.ts" --scenario + +# Against a server +node dist/index.js server --url http://localhost:3000/mcp --scenario +``` + +See the [README](./README.md) for full CLI options and the [SDK Integration Guide](./SDK_INTEGRATION.md) for 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 before opening the PR — we'll ask what the output looked like +- Keep PRs focused; one feature or scenario group at a time From 687d5b4035404e3d9961c05729aa41ba056900aa Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 25 Mar 2026 11:42:31 +0000 Subject: [PATCH 095/226] chore(deps): ignore major version bumps in dependabot (#195) Major bumps of devDeps (eslint, typescript, prettier) need manual config migration and provide no value for conformance testing. Security updates still come through regardless; scheduled updates are now minor/patch only. --- .github/dependabot.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9d1dc11..77cff36 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,9 @@ version: 2 +# Security updates always come through regardless of these settings. +# Scheduled (non-security) updates are limited to minor/patch only — +# major bumps of devDeps like eslint/typescript are manual decisions. + updates: - package-ecosystem: npm directory: / @@ -9,6 +13,9 @@ updates: all-dependencies: patterns: - '*' + ignore: + - dependency-name: '*' + update-types: ['version-update:semver-major'] - package-ecosystem: npm directory: /examples/servers/typescript @@ -18,6 +25,9 @@ updates: all-dependencies: patterns: - '*' + ignore: + - dependency-name: '*' + update-types: ['version-update:semver-major'] - package-ecosystem: github-actions directory: / @@ -27,3 +37,6 @@ updates: all-actions: patterns: - '*' + ignore: + - dependency-name: '*' + update-types: ['version-update:semver-major'] From 7b5865a8744d26a5b9e19b3289b12861e135fb81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:49:15 +0000 Subject: [PATCH 096/226] chore(deps-dev): bump the all-dependencies group with 2 updates (#196) Bumps the all-dependencies group with 2 updates: [@typescript/native-preview](https://github.com/microsoft/typescript-go) and [tsdown](https://github.com/rolldown/tsdown). Updates `@typescript/native-preview` from 7.0.0-dev.20260323.1 to 7.0.0-dev.20260325.1 - [Changelog](https://github.com/microsoft/typescript-go/blob/main/CHANGES.md) - [Commits](https://github.com/microsoft/typescript-go/commits) Updates `tsdown` from 0.21.4 to 0.21.5 - [Release notes](https://github.com/rolldown/tsdown/releases) - [Commits](https://github.com/rolldown/tsdown/compare/v0.21.4...v0.21.5) --- updated-dependencies: - dependency-name: "@typescript/native-preview" dependency-version: 7.0.0-dev.20260325.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies - dependency-name: tsdown dependency-version: 0.21.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 546 ++++++++++------------------------------------ package.json | 4 +- 2 files changed, 122 insertions(+), 428 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2ef554..e74136b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,13 +26,13 @@ "@eslint/js": "^9.39.4", "@types/express": "^5.0.3", "@types/node": "^25.5.0", - "@typescript/native-preview": "^7.0.0-dev.20260323.1", + "@typescript/native-preview": "^7.0.0-dev.20260325.1", "cors": "^2.8.5", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "lefthook": "^2.1.4", "prettier": "^3.8.1", - "tsdown": "^0.21.4", + "tsdown": "^0.21.5", "tsx": "^4.7.0", "typescript": "^5.5.4", "typescript-eslint": "^8.57.1", @@ -1067,9 +1067,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, "license": "MIT", "funding": { @@ -1090,9 +1090,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "cpu": [ "arm64" ], @@ -1107,9 +1107,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "cpu": [ "arm64" ], @@ -1124,9 +1124,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "cpu": [ "x64" ], @@ -1141,9 +1141,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "cpu": [ "x64" ], @@ -1158,9 +1158,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "cpu": [ "arm" ], @@ -1175,9 +1175,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "cpu": [ "arm64" ], @@ -1192,9 +1192,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "cpu": [ "arm64" ], @@ -1209,9 +1209,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "cpu": [ "ppc64" ], @@ -1226,9 +1226,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "cpu": [ "s390x" ], @@ -1243,9 +1243,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "cpu": [ "x64" ], @@ -1260,9 +1260,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "cpu": [ "x64" ], @@ -1277,9 +1277,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "cpu": [ "arm64" ], @@ -1294,9 +1294,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "cpu": [ "wasm32" ], @@ -1311,9 +1311,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "cpu": [ "arm64" ], @@ -1328,9 +1328,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "cpu": [ "x64" ], @@ -1345,9 +1345,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true, "license": "MIT" }, @@ -1789,28 +1789,28 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-e8rnqL5I4DUSjiy6jiWqYFKcgKq8tC4S+uEmJwWiyTgSIGDhaRUujJ2pb6EXmi+NPeXoES6vIG7e9BsEV0WSow==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-42I1oVqz2EOkE1vCrzazV3r+zVREq+le4m7Vr4OEz9taH2rhR02yxq+tNygKV3IOUOPLOXkX/soKcgrF3drDHA==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260323.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260323.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260323.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260323.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260323.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260323.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260323.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260325.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260325.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260325.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260325.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260325.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260325.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260325.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-C3tQdgMaYn57xzRUWek+zNKMiP0z9j7fqbCjr0wlyiLNIh5jMwDArjBDKHlqSu58FKvZg7baqbqB5Mcepb3s6w==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-TN51zclpW+D9Qe55Do1ATeZaZ77E6H5JX5cG86xFTKhXaFaW35ANagS86t6d5xnf0quemXM6EP06so2WLSYCqw==", "cpu": [ "arm64" ], @@ -1822,9 +1822,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-YE6pD4wdMqNgaBkXicQBLFwABOEmLxDclSM7grl0fw4cQSbfVwFCbSlwFDkmISKdmsgtWdYeMohrsTU710ufpg==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-iRzGkGdJmTGJHk8jI7PSjHjbDGrrw5oImTUfACevJFpB+dA5Hn/bsYlJQ5MR9KmDAJYoRHY1HQp6Dm30zXZw3A==", "cpu": [ "x64" ], @@ -1836,9 +1836,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-6zFO/SF9Gu8rtRyEt1b10TNapQGq5b/wUjCLG14675n155r4EO3JFMgnltBViV2Eatnq7G+bXD65BUBk7Ixyhw==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-MSumEH3jrfCXAtrkgm8DF4IeNiKAoJBpnyGS4WdjIQkqeI6c2wEGRXWJixOJRj3Lp7/CDx5Wo+ySFyjNdC4Uyg==", "cpu": [ "arm" ], @@ -1850,9 +1850,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-Jzr2gBY0ifA3XejAl7kPeHLT72JFBfzLSafOAQbANh5Iag02uPl99k8ORMfKREbYgEMMOzqPpe+r6eaKy+VEfw==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-qY10cp4PurJBD0TT7e4JwMUh2cGySLI+F7r5wZkkARSU/5aXAsWOImnVtshuzyv+MBfhcq8KHB1XMb62Kjrruw==", "cpu": [ "arm64" ], @@ -1864,9 +1864,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-UvsQdVI/LayXTowltMgtg2GHU/a/lcuOYbaAYm9+/nvgIs01VqVo0s1/lTSNBzepEk0y1EOYQ6SIsRM9lLGHxA==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-p93R+o9pV3IuypB3ydWXJSbzUgdHG3KD+5uFQZyo2A/QR9xnRPgTOhFnHXj9ml/RQvGHbmmAdFe/Xe2GiwnsSQ==", "cpu": [ "x64" ], @@ -1878,9 +1878,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-3208Xoe+3Xblf6WLVTkIdyh6401zB2eXAhu1UaDpZY0rf8SMHCxKW3TSJDytpri5UivCotZ0CNC2wgJ1TlymUA==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-OgoAfFryES4XS08PNXEJL54z4VbxY7VDwLb5z+TnMl5TMqYprk7cZZ+hQtq7XzwgailQyI162CQ81e+vtPuXqQ==", "cpu": [ "arm64" ], @@ -1892,9 +1892,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260323.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260323.1.tgz", - "integrity": "sha512-DAiRqrcs58eXwjFOtbklbIHq70IpW7uYz1Bx3kNAzqoWlA7R4mC29N6G0kGEZDalGmj7f0HVuckq9AzaC1r6oQ==", + "version": "7.0.0-dev.20260325.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260325.1.tgz", + "integrity": "sha512-BuzbtCqAYR/CmWDzaEw3/s80HLHXCIu+eSepRygjiLdd8CiNbIIAwCo2teQ1C5fjsWQ+Iu8iAJItOLpxWWTCzg==", "cpu": [ "x64" ], @@ -4211,9 +4211,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": { @@ -4396,14 +4396,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4412,21 +4412,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" } }, "node_modules/rolldown-plugin-dts": { @@ -4796,9 +4796,9 @@ } }, "node_modules/tsdown": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.4.tgz", - "integrity": "sha512-Q/kBi8SXkr4X6JI/NAZKZY1UuiEcbuXtIskL4tZCsgpDiEPM/2W6lC+OonNA31S+V3KsWedFvbFDBs23hvt+Aw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.5.tgz", + "integrity": "sha512-TlgNhfPioAD6ECCUnZsxcUsXXuPPR4Rrxz3az741kL/M3oGIET4a9GajSNRNRx+jIva73TYUKQybrEPkDYN+fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4809,15 +4809,15 @@ "hookable": "^6.1.0", "import-without-cache": "^0.2.5", "obug": "^2.1.1", - "picomatch": "^4.0.3", - "rolldown": "1.0.0-rc.9", + "picomatch": "^4.0.4", + "rolldown": "1.0.0-rc.11", "rolldown-plugin-dts": "^0.22.5", "semver": "^7.7.4", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", - "unrun": "^0.2.32" + "unrun": "^0.2.33" }, "bin": { "tsdown": "dist/run.mjs" @@ -4830,11 +4830,11 @@ }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", - "@tsdown/css": "0.21.4", - "@tsdown/exe": "0.21.4", + "@tsdown/css": "0.21.5", + "@tsdown/exe": "0.21.5", "@vitejs/devtools": "*", "publint": "^0.3.0", - "typescript": "^5.0.0", + "typescript": "^5.0.0 || ^6.0.0", "unplugin-unused": "^0.5.0" }, "peerDependenciesMeta": { @@ -5000,13 +5000,13 @@ } }, "node_modules/unrun": { - "version": "0.2.32", - "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.32.tgz", - "integrity": "sha512-opd3z6791rf281JdByf0RdRQrpcc7WyzqittqIXodM/5meNWdTwrVxeyzbaCp4/Rgls/um14oUaif1gomO8YGg==", + "version": "0.2.33", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.33.tgz", + "integrity": "sha512-urXTjZHOHS6lMnatQerLcBpcTsaKZYGuu9aSZ+HlNfCApkiINRbj7YecC9h9hdZroMT4WQ4KVyzHfBqHKuFX9Q==", "dev": true, "license": "MIT", "dependencies": { - "rolldown": "1.0.0-rc.9" + "rolldown": "1.0.0-rc.11" }, "bin": { "unrun": "dist/cli.mjs" @@ -5123,312 +5123,6 @@ } } }, - "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" - } - }, "node_modules/vitest": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", diff --git a/package.json b/package.json index 71f41d7..a1bade9 100644 --- a/package.json +++ b/package.json @@ -33,13 +33,13 @@ "@eslint/js": "^9.39.4", "@types/express": "^5.0.3", "@types/node": "^25.5.0", - "@typescript/native-preview": "^7.0.0-dev.20260323.1", + "@typescript/native-preview": "^7.0.0-dev.20260325.1", "cors": "^2.8.5", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "lefthook": "^2.1.4", "prettier": "^3.8.1", - "tsdown": "^0.21.4", + "tsdown": "^0.21.5", "tsx": "^4.7.0", "typescript": "^5.5.4", "typescript-eslint": "^8.57.1", From e702a253ba3afa8e7c5aad5418066b9bdaddccd2 Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 25 Mar 2026 13:01:08 +0100 Subject: [PATCH 097/226] fix: resolve CLI path for tier-check conformance subprocess (#175) Use process.argv[1] instead of relative dist/index.js so tier-check works when invoked via npx or from outside the package directory. --- src/tier-check/checks/test-conformance-results.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index 5ba9849..054ce44 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -182,9 +182,8 @@ export async function checkConformance(options: { try { execSync( - `node dist/index.js server --url ${options.serverUrl} -o ${outputDir}`, + `node "${process.argv[1]}" server --url ${options.serverUrl} -o ${outputDir}`, { - cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000 } @@ -222,9 +221,8 @@ export async function checkClientConformance(options: { try { execSync( - `node dist/index.js client --command '${options.clientCmd}' --suite all -o ${outputDir}`, + `node "${process.argv[1]}" client --command '${options.clientCmd}' --suite all -o ${outputDir}`, { - cwd: process.cwd(), stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000 } From a2ec2d44adfc8b5b02b32dc3b25f79e0c9fce1d6 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Wed, 25 Mar 2026 05:03:34 -0700 Subject: [PATCH 098/226] Fix Windows compatibility in tier-check client conformance (#168) * Fix Windows compatibility in tier-check client conformance Two issues prevented client conformance tests from running on Windows: 1. Single-quote quoting in execSync: The --command argument was wrapped in single quotes, which cmd.exe treats as literal characters rather than string delimiters. This caused the CLI to receive only the first word of the command. Switched to execFileSync with an args array to bypass shell quoting entirely. 2. Backslash path separators in parseOutputDir: dirname() on Windows returns auth\scenario-name but reconcileWithExpected compares against auth/scenario-name, causing auth scenarios to be double-counted. Normalize backslashes to forward slashes in parsed scenario names. * fix: use process.argv[1] for CLI path in subprocess calls Combines the npx-compatibility fix from #175 with this PR's execFileSync approach. process.argv[1] resolves to the absolute path of the running CLI regardless of working directory, so tier-check works when invoked via npx or from any cwd. --------- Co-authored-by: Paul Carleton --- .../checks/test-conformance-results.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index 054ce44..f68d879 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { mkdtempSync, readFileSync, existsSync, globSync } from 'fs'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; @@ -43,7 +43,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 { @@ -181,8 +181,9 @@ export async function checkConformance(options: { const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-server-')); try { - execSync( - `node "${process.argv[1]}" server --url ${options.serverUrl} -o ${outputDir}`, + execFileSync( + process.execPath, + [process.argv[1], 'server', '--url', options.serverUrl, '-o', outputDir], { stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000 @@ -220,8 +221,18 @@ export async function checkClientConformance(options: { const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-client-')); try { - execSync( - `node "${process.argv[1]}" client --command '${options.clientCmd}' --suite all -o ${outputDir}`, + execFileSync( + process.execPath, + [ + process.argv[1], + 'client', + '--command', + options.clientCmd, + '--suite', + 'all', + '-o', + outputDir + ], { stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000 From fe16e381c7fdfcfc8d06f89c0466d4363e928b58 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 25 Mar 2026 12:19:11 +0000 Subject: [PATCH 099/226] fix: don't leak escalated scopes in PRM scopes_supported for step-up test (#154) --- src/scenarios/client/auth/scope-handling.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 166a9c3..32db087 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -418,7 +418,7 @@ export class ScopeStepUpAuthScenario implements Scenario { { prmPath: '/.well-known/oauth-protected-resource/mcp', requiredScopes: escalatedScopes, - scopesSupported: escalatedScopes, + scopesSupported: [initialScope], includeScopeInWwwAuth: true, authMiddleware: stepUpMiddleware, tokenVerifier From 1c48a30ff62abcd5194236a5f4fcb667a712be08 Mon Sep 17 00:00:00 2001 From: Anthony Date: Wed, 25 Mar 2026 15:53:26 +0100 Subject: [PATCH 100/226] feat: add --spec-version option to tier-check (#176) * feat: add --spec-version option to tier-check * refactor: share resolveSpecVersion between CLI and tier-check Move resolveSpecVersion to src/scenarios/index.ts so tier-check can reuse it instead of duplicating the validation logic. --------- Co-authored-by: Felix Weinberger --- src/index.ts | 11 +-- src/scenarios/index.ts | 9 +++ .../checks/test-conformance-results.ts | 76 ++++++++++++------- src/tier-check/index.ts | 15 +++- 4 files changed, 72 insertions(+), 39 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9aeb0f0..29edd4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ import { listScenariosForSpec, listClientScenariosForSpec, getScenarioSpecVersions, - ALL_SPEC_VERSIONS + resolveSpecVersion } from './scenarios'; import type { SpecVersion } from './scenarios'; import { ConformanceCheck } from './types'; @@ -37,15 +37,6 @@ import { import { createTierCheckCommand } from './tier-check'; 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). diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 36c8f32..75ca61d 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -227,6 +227,15 @@ export const ALL_SPEC_VERSIONS: SpecVersion[] = [ 'extension' ]; +export 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); +} + export function listScenariosForSpec(version: SpecVersion): string[] { return scenariosList .filter((s) => s.specVersions.includes(version)) diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index f68d879..2a9ce43 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -6,6 +6,8 @@ import { ConformanceResult } from '../types'; import { listScenarios, listActiveClientScenarios, + listScenariosForSpec, + listClientScenariosForSpec, getScenarioSpecVersions } from '../../scenarios'; import { ConformanceCheck, SpecVersion } from '../../types'; @@ -166,6 +168,7 @@ function reconcileWithExpected( export async function checkConformance(options: { serverUrl?: string; skip?: boolean; + specVersion?: SpecVersion; }): Promise { if (options.skip || !options.serverUrl) { return { @@ -179,23 +182,37 @@ export async function checkConformance(options: { } const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-server-')); + const args = [ + process.argv[1], + 'server', + '--url', + options.serverUrl, + '-o', + outputDir + ]; + if (options.specVersion) { + args.push('--spec-version', options.specVersion); + } try { - execFileSync( - process.execPath, - [process.argv[1], 'server', '--url', options.serverUrl, '-o', outputDir], - { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000 - } - ); + execFileSync(process.execPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 120_000 + }); } catch { // Non-zero exit is expected when tests fail — results are still in outputDir } + const activeScenarios = new Set(listActiveClientScenarios()); + const expectedScenarios = options.specVersion + ? listClientScenariosForSpec(options.specVersion).filter((s) => + activeScenarios.has(s) + ) + : [...activeScenarios]; + return reconcileWithExpected( parseOutputDir(outputDir), - listActiveClientScenarios(), + expectedScenarios, 'server' ); } @@ -206,6 +223,7 @@ export async function checkConformance(options: { export async function checkClientConformance(options: { clientCmd?: string; skip?: boolean; + specVersion?: SpecVersion; }): Promise { if (options.skip || !options.clientCmd) { return { @@ -219,28 +237,32 @@ export async function checkClientConformance(options: { } const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-client-')); + const args = [ + process.argv[1], + 'client', + '--command', + options.clientCmd, + '--suite', + 'all', + '-o', + outputDir + ]; + if (options.specVersion) { + args.push('--spec-version', options.specVersion); + } try { - execFileSync( - process.execPath, - [ - process.argv[1], - 'client', - '--command', - options.clientCmd, - '--suite', - 'all', - '-o', - outputDir - ], - { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000 - } - ); + execFileSync(process.execPath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 120_000 + }); } catch { // Non-zero exit is expected when tests fail — results are still in outputDir } - return reconcileWithExpected(parseOutputDir(outputDir), listScenarios()); + const expectedScenarios = options.specVersion + ? listScenariosForSpec(options.specVersion) + : listScenarios(); + + return reconcileWithExpected(parseOutputDir(outputDir), expectedScenarios); } diff --git a/src/tier-check/index.ts b/src/tier-check/index.ts index 5416748..1cd32c1 100644 --- a/src/tier-check/index.ts +++ b/src/tier-check/index.ts @@ -13,6 +13,7 @@ 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'; function parseRepo(repo: string): { owner: string; repo: string } { const parts = repo.split('/'); @@ -48,10 +49,18 @@ export function createTierCheckCommand(): Command { '--token ', 'GitHub token (defaults to GITHUB_TOKEN env var)' ) + .option( + '--spec-version ', + 'Only run conformance scenarios for this spec version' + ) .action(async (options) => { const { owner, repo } = parseRepo(options.repo); let token = options.token || process.env.GITHUB_TOKEN; + const specVersion = options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; + if (!token) { // Try to get token from GitHub CLI try { @@ -90,14 +99,16 @@ export function createTierCheckCommand(): Command { ] = await Promise.all([ checkConformance({ serverUrl: options.conformanceServerUrl, - skip: options.skipConformance + skip: options.skipConformance, + specVersion }).then((r) => { console.error(' ✓ Server Conformance'); return r; }), checkClientConformance({ clientCmd: options.clientCmd, - skip: options.skipConformance || !options.clientCmd + skip: options.skipConformance || !options.clientCmd, + specVersion }).then((r) => { console.error(' ✓ Client Conformance'); return r; From 21a9a2febd7100d7c17ac1021ee7f2ed9f66a1e0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 27 Mar 2026 11:29:13 +0000 Subject: [PATCH 101/226] chore: bump version to 0.1.16 (#198) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e74136b..82032f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.15", + "version": "0.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.15", + "version": "0.1.16", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", diff --git a/package.json b/package.json index a1bade9..8f9a768 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.15", + "version": "0.1.16", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From cb0f4c3601c8a87d9b3d43c2154d9f56fdcfd53c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 27 Mar 2026 19:25:07 +0000 Subject: [PATCH 102/226] docs: add MAINTAINERS.md and remove experimental warning (#199) * docs: add MAINTAINERS.md and remove experimental warning from README * docs: flatten MAINTAINERS.md to single list --- MAINTAINERS.md | 10 ++++++++++ README.md | 3 --- 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 MAINTAINERS.md 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..dc84f10 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 From 050d9cf93a5b9760a822dad748a6d46d3e3da4e3 Mon Sep 17 00:00:00 2001 From: Michito-Okai <69030207+Michito-Okai@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:42:23 +0900 Subject: [PATCH 103/226] feat: add conformance test for authorization server metadata (#170) --- src/index.ts | 146 ++++++++++++++++- src/runner/authorization-server.ts | 74 +++++++++ .../authorization-server-metadata.test.ts | 51 ++++++ .../authorization-server-metadata.ts | 155 ++++++++++++++++++ src/scenarios/index.ts | 47 +++++- src/schemas.ts | 9 + src/types.ts | 7 + 7 files changed, 479 insertions(+), 10 deletions(-) create mode 100644 src/runner/authorization-server.ts create mode 100644 src/scenarios/authorization-server/authorization-server-metadata.test.ts create mode 100644 src/scenarios/authorization-server/authorization-server-metadata.ts diff --git a/src/index.ts b/src/index.ts index 29edd4a..38f7701 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ import { printServerSummary, runInteractiveMode } from './runner'; +import { + printAuthorizationServerSummary, + runAuthorizationServerConformanceTest +} from './runner/authorization-server'; import { listScenarios, listClientScenarios, @@ -24,11 +28,17 @@ import { listScenariosForSpec, listClientScenariosForSpec, getScenarioSpecVersions, + 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 { loadExpectedFailures, evaluateBaseline, @@ -44,12 +54,19 @@ import packageJson from '../package.json'; 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)); } @@ -437,6 +454,87 @@ program } }); +// Authorization command - tests an authorization server implementation +program + .command('authorization') + .description( + 'Run conformance tests against an authorization server implementation' + ) + .requiredOption('--url ', 'URL of the authorization server issuer') + .option('-o, --output-dir ', 'Save results to this directory') + .option( + '--spec-version ', + 'Filter scenarios by spec version (cumulative for date versions)' + ) + .action(async (options) => { + try { + // Validate options with Zod + const validated = AuthorizationServerOptionsSchema.parse(options); + const outputDir = options.outputDir; + const specVersionFilter = options.specVersion + ? resolveSpecVersion(options.specVersion) + : undefined; + + 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[] }[] = []; + for (const scenarioName of scenarios) { + console.log(`\n=== Running scenario: ${scenarioName} ===`); + try { + const result = await runAuthorizationServerConformanceTest( + validated.url, + scenarioName, + outputDir + ); + 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()); @@ -446,6 +544,7 @@ program .description('List available test scenarios') .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)' @@ -455,7 +554,10 @@ program ? resolveSpecVersion(options.specVersion) : undefined; - if (options.server || (!options.client && !options.server)) { + if ( + options.server || + (!options.client && !options.server && !options.authorization) + ) { console.log('Server scenarios (test against a server):'); let serverScenarios = listClientScenarios(); if (specVersionFilter) { @@ -471,7 +573,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(''); } @@ -489,6 +594,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/runner/authorization-server.ts b/src/runner/authorization-server.ts new file mode 100644 index 0000000..5b4094b --- /dev/null +++ b/src/runner/authorization-server.ts @@ -0,0 +1,74 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { ConformanceCheck } from '../types'; +import { getClientScenarioForAuthorizationServer } from '../scenarios'; +import { createResultDir } from './utils'; + +export async function runAuthorizationServerConformanceTest( + serverUrl: string, + scenarioName: string, + outputDir?: string +): 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: ${serverUrl}` + ); + + const checks = await scenario.run(serverUrl); + + if (resultDir) { + await fs.writeFile( + path.join(resultDir, 'checks.json'), + JSON.stringify(checks, null, 2) + ); + + console.log(`Results saved to ${resultDir}`); + } + + return { + checks, + resultDir, + scenarioDescription: scenario.description + }; +} + +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/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts new file mode 100644 index 0000000..b20b47e --- /dev/null +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -0,0 +1,51 @@ +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); + +describe('AuthorizationServerMetadataEndpointScenario (SUCCESS only)', () => { + it('returns SUCCESS for valid authorization server metadata', async () => { + const scenario = new AuthorizationServerMetadataEndpointScenario(); + const serverUrl = 'https://example.com'; + + mockedRequest.mockResolvedValue({ + statusCode: 200, + headers: { + 'content-type': 'application/json' + }, + body: { + json: async () => ({ + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/auth', + token_endpoint: 'https://example.com/token', + response_types_supported: ['code'] + }) + } + } as any); + + const checks = await scenario.run(serverUrl); + + expect(checks).toHaveLength(1); + + 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('https://example.com'); + expect((check.details!.body as any).authorization_endpoint).toBe( + 'https://example.com/auth' + ); + expect((check.details!.body as any).token_endpoint).toBe( + 'https://example.com/token' + ); + expect((check.details!.body as any).response_types_supported).toEqual([ + 'code' + ]); + }); +}); 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..b386090 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -0,0 +1,155 @@ +/** + * Authorization server metadata endpoint test scenarios for MCP authorization servers + */ +import { + ClientScenarioForAuthorizationServer, + ConformanceCheck, + SpecVersion +} from '../../types'; +import { request } from 'undici'; + +type Status = 'SUCCESS' | 'FAILURE'; + +export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer { + name = 'authorization-server-metadata-endpoint'; + specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25']; + 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.`; + + async run(serverUrl: string): Promise { + let status: Status = 'SUCCESS'; + let errorMessage: string | undefined; + let details: any; + let response: any | null = null; + try { + const wellKnownUrls = this.createWellKnownUrl(serverUrl); + + 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']); + + const body = await this.parseJson(response); + const errors: string[] = []; + this.validateMetadataBody(body, serverUrl, 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); + } + + return [ + { + id: 'authorization-server-metadata', + name: 'AuthorizationServerMetadata', + description: 'Valid authorization server metadata response', + status, + timestamp: new Date().toISOString(), + errorMessage, + specReferences: [ + { + id: 'Authorization-Server-Metadata', + url: 'https://datatracker.ietf.org/doc/html/rfc8414' + } + ], + ...(details ? { details } : {}) + } + ]; + } + + 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 (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/index.ts b/src/scenarios/index.ts index 75ca61d..665a7a9 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -1,4 +1,9 @@ -import { Scenario, ClientScenario, SpecVersion } from '../types'; +import { + Scenario, + ClientScenario, + ClientScenarioForAuthorizationServer, + SpecVersion +} from '../types'; import { InitializeScenario } from './client/initialize'; import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; @@ -60,6 +65,7 @@ import { extensionScenariosList } from './client/auth/index'; import { listMetadataScenarios } from './client/auth/discovery-metadata'; +import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -142,6 +148,23 @@ export const clientScenarios = new Map( allClientScenariosList.map((scenario) => [scenario.name, scenario]) ); +// All client scenarios for authorization server +const allClientScenariosListForAuthorizationServer: ClientScenario[] = [ + // Authorization server scenarios + new AuthorizationServerMetadataEndpointScenario() +]; + +// Client scenarios map for authorization server - built from list +export const clientScenariosForAuthorizationServer = new Map< + string, + ClientScenario +>( + allClientScenariosListForAuthorizationServer.map((scenario) => [ + scenario.name, + scenario + ]) +); + // All client test scenarios (core + backcompat + extensions) const scenariosList: Scenario[] = [ new InitializeScenario(), @@ -180,6 +203,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()); } @@ -212,6 +241,10 @@ export function listBackcompatScenarios(): string[] { return backcompatScenariosList.map((scenario) => scenario.name); } +export function listClientScenariosForAuthorizationServer(): string[] { + return Array.from(clientScenariosForAuthorizationServer.keys()); +} + export function listDraftScenarios(): string[] { return draftScenariosList.map((scenario) => scenario.name); } @@ -248,11 +281,21 @@ export function listClientScenariosForSpec(version: SpecVersion): string[] { .map((s) => s.name); } +export function listClientScenariosForAuthorizationServerForSpec( + version: SpecVersion +): string[] { + return allClientScenariosListForAuthorizationServer + .filter((s) => s.specVersions.includes(version)) + .map((s) => s.name); +} + export function getScenarioSpecVersions( name: string ): SpecVersion[] | undefined { return ( - scenarios.get(name)?.specVersions ?? clientScenarios.get(name)?.specVersions + scenarios.get(name)?.specVersions ?? + clientScenarios.get(name)?.specVersions ?? + clientScenariosForAuthorizationServer.get(name)?.specVersions ); } diff --git a/src/schemas.ts b/src/schemas.ts index aa51abe..e0df8ce 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -38,6 +38,15 @@ export const ServerOptionsSchema = z.object({ export type ServerOptions = z.infer; +// Authorization server command options schema +export const AuthorizationServerOptionsSchema = z.object({ + url: z.string().url('Invalid authorization server URL') +}); + +export type AuthorizationServerOptions = z.infer< + typeof AuthorizationServerOptionsSchema +>; + // Interactive command options schema export const InteractiveOptionsSchema = z.object({ scenario: z diff --git a/src/types.ts b/src/types.ts index c086e65..193686f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,3 +60,10 @@ export interface ClientScenario { specVersions: SpecVersion[]; run(serverUrl: string): Promise; } + +export interface ClientScenarioForAuthorizationServer { + name: string; + description: string; + specVersions: SpecVersion[]; + run(serverUrl: string): Promise; +} From 8d9f80a258ce03db70c9081be657c8de03f29059 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Mon, 13 Apr 2026 09:39:39 -0700 Subject: [PATCH 104/226] feat: Session ID ASCII validation check (#206) * Checks for session ASCII correctness * tests: Session ID ASCII checks --- src/scenarios/server/lifecycle.test.ts | 94 ++++++++++++++++++++++++ src/scenarios/server/lifecycle.ts | 98 +++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/scenarios/server/lifecycle.test.ts diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts new file mode 100644 index 0000000..15ee6d8 --- /dev/null +++ b/src/scenarios/server/lifecycle.test.ts @@ -0,0 +1,94 @@ +import { ServerInitializeScenario } from './lifecycle'; +import { connectToServer } from './client-helper'; + +vi.mock('./client-helper', () => ({ + 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(serverUrl); + + expect(connectToServer).toHaveBeenCalledWith(serverUrl); + 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(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(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-é' + } + }); + }); +}); diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 392a932..3758615 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -5,6 +5,15 @@ import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; +const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/; + +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']; @@ -18,8 +27,10 @@ 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 { const checks: ConformanceCheck[] = []; @@ -65,6 +76,91 @@ 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. + try { + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { + name: 'conformance-session-id-test', + version: '1.0.0' + } + } + }) + }); + + const sessionId = response.headers.get('mcp-session-id'); + + 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 + }); } return checks; From 70cc5efbad6d1be913e0875adffc4b74a9e9ce1b Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Tue, 14 Apr 2026 01:56:48 +0900 Subject: [PATCH 105/226] feat: check for PKCE of authorization server metadata (#212) --- .../authorization-server-metadata.test.ts | 86 ++++++++++++++----- .../authorization-server-metadata.ts | 9 ++ 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index b20b47e..1cf862b 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -8,27 +8,32 @@ vi.mock('undici', () => ({ const mockedRequest = vi.mocked(request); -describe('AuthorizationServerMetadataEndpointScenario (SUCCESS only)', () => { +const SERVER_URL = 'https://example.com'; +const AUTHORIZATION_ENDPOINT = `${SERVER_URL}/auth`; +const TOKEN_ENDPOINT = `${SERVER_URL}/token`; + +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(); - const serverUrl = 'https://example.com'; + mockMetadataResponse(validMetadata); - mockedRequest.mockResolvedValue({ - statusCode: 200, - headers: { - 'content-type': 'application/json' - }, - body: { - json: async () => ({ - issuer: 'https://example.com', - authorization_endpoint: 'https://example.com/auth', - token_endpoint: 'https://example.com/token', - response_types_supported: ['code'] - }) - } - } as any); - - const checks = await scenario.run(serverUrl); + const checks = await scenario.run(SERVER_URL); expect(checks).toHaveLength(1); @@ -37,15 +42,50 @@ describe('AuthorizationServerMetadataEndpointScenario (SUCCESS only)', () => { expect(check.errorMessage).toBeUndefined(); expect(check.details).toBeDefined(); expect(check.details!.contentType).toContain('application/json'); - expect((check.details!.body as any).issuer).toBe('https://example.com'); + expect((check.details!.body as any).issuer).toBe(SERVER_URL); expect((check.details!.body as any).authorization_endpoint).toBe( - 'https://example.com/auth' - ); - expect((check.details!.body as any).token_endpoint).toBe( - 'https://example.com/token' + 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(SERVER_URL); + + expect(checks).toHaveLength(1); + + 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(SERVER_URL); + + expect(checks).toHaveLength(1); + + const check = checks[0]; + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain('code_challenge_methods_supported'); }); }); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index b386090..96b3669 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -142,6 +142,15 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar ); } + 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)'}`); } From 64e6c7c0ddc824ca495072817dd77299c11f4ec6 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Thu, 23 Apr 2026 22:06:41 +0530 Subject: [PATCH 106/226] fix(tier-check): add missing markdown table headers for repository health (#239) Fixes a bug where the Markdown output format for the Repository Health section lacked a table header, causing it to render as raw text instead of a table on GitHub. --- src/tier-check/output.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tier-check/output.ts b/src/tier-check/output.ts index 9e7aa57..f08a47d 100644 --- a/src/tier-check/output.ts +++ b/src/tier-check/output.ts @@ -121,8 +121,7 @@ export function formatMarkdown(scorecard: TierScorecard): string { lines.push(''); lines.push('## Check Results'); lines.push(''); - lines.push('| Check | Status | Detail |'); - lines.push('|-------|--------|--------|'); + // Conformance matrix const matrix = buildConformanceMatrix( c.conformance as ConformanceResult, @@ -176,6 +175,8 @@ export function formatMarkdown(scorecard: TierScorecard): string { } } 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(', ')})` : ''} |` ); From 7ee58ef780f99a83e9d31e21699fe09c266e2183 Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Fri, 24 Apr 2026 11:42:51 +0100 Subject: [PATCH 107/226] feat: add conformance scenario for SEP-2164 resource-not-found error code (#200) * feat: add conformance scenario for SEP-2164 resource-not-found error code Adds a draft-scoped scenario verifying servers return -32602 (Invalid Params) for non-existent resources instead of the legacy -32002 or an empty contents array. Includes a sep-2164.yaml traceability file mapping each MUST/MUST NOT/SHOULD to its check per the proposed SEP-2484 process. * fix: align SEP-2164 check severities with spec text (#246) Splits sep-2164-error-code into: - sep-2164-no-empty-contents (MUST NOT -> FAILURE) - sep-2164-error-code (SHOULD -> WARNING) Updates sep-2164.yaml to quote the spec text verbatim and adds an excluded row for the client-side -32002 backcompat SHOULD. See #245 for the SHOULD/WARNING tier-scoring policy discussion. --------- Co-authored-by: Paul Carleton --- src/scenarios/index.ts | 6 +- src/scenarios/server/resources.ts | 162 ++++++++++++++++++++++++++++- src/scenarios/server/sep-2164.yaml | 9 ++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/scenarios/server/sep-2164.yaml diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 665a7a9..7080849 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -45,7 +45,8 @@ import { ResourcesReadBinaryScenario, ResourcesTemplateReadScenario, ResourcesSubscribeScenario, - ResourcesUnsubscribeScenario + ResourcesUnsubscribeScenario, + ResourcesNotFoundErrorScenario } from './server/resources'; import { @@ -123,6 +124,9 @@ const allClientScenariosList: ClientScenario[] = [ new ResourcesSubscribeScenario(), new ResourcesUnsubscribeScenario(), + // Resources error handling (SEP-2164) + new ResourcesNotFoundErrorScenario(), + // Prompts scenarios new PromptsListScenario(), new PromptsGetSimpleScenario(), diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index ec3c5fc..bea2706 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -6,7 +6,8 @@ import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { connectToServer } from './client-helper'; import { TextResourceContents, - BlobResourceContents + BlobResourceContents, + McpError } from '@modelcontextprotocol/sdk/types.js'; export class ResourcesListScenario implements ClientScenario { @@ -435,6 +436,165 @@ Example request: } } +export class ResourcesNotFoundErrorScenario implements ClientScenario { + name = 'sep-2164-resource-not-found'; + specVersions: SpecVersion[] = ['draft']; + 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(serverUrl: string): 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' + } + ]; + + let connection; + try { + connection = await connectToServer(serverUrl); + } 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: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`, + specReferences + }); + return checks; + } + + let caughtError: unknown; + let result: { contents: unknown[] } | undefined; + try { + result = await connection.client.readResource({ 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 errorCode = + caughtError instanceof McpError ? caughtError.code : undefined; + 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 (!(caughtError instanceof McpError)) { + 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 = + caughtError instanceof McpError + ? (caughtError.data as { uri?: string } | undefined) + : 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: + caughtError instanceof McpError + ? dataUriMatches + ? 'SUCCESS' + : 'WARNING' + : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + caughtError instanceof McpError + ? 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 connection.close(); + return checks; + } +} + export class ResourcesUnsubscribeScenario implements ClientScenario { name = 'resources-unsubscribe'; specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; diff --git a/src/scenarios/server/sep-2164.yaml b/src/scenarios/server/sep-2164.yaml new file mode 100644 index 0000000..051253d --- /dev/null +++ b/src/scenarios/server/sep-2164.yaml @@ -0,0 +1,9 @@ +sep: 2164 +spec_url: 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' From 22b4dce6080c5819bf33d2865809459c44b9f2e8 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Fri, 24 Apr 2026 03:45:09 -0700 Subject: [PATCH 108/226] doc: Add example of testing against TS SDK (#237) --- CONTRIBUTING.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f018da..920b175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,19 +28,44 @@ 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 -# Against the bundled TypeScript example 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 ``` -See the [README](./README.md) for full CLI options and the [SDK Integration Guide](./SDK_INTEGRATION.md) for testing against a real SDK. +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 before opening the PR — we'll ask what the output looked like +- 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 From 88c087e9c43303c59e551084098f9ad8a8608719 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 11:51:24 +0100 Subject: [PATCH 109/226] docs: add SEP scenario review guidance to AGENTS.md (#247) --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 35e854f..7a230da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Keep scenarios separate when they're genuinely independent features or when they - **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. - 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.) ## Descriptions and wording @@ -60,6 +61,16 @@ Be precise about what's **required** vs **optional**. A scenario description tha 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 +``` + ## Examples: prove it passes and fails A new scenario should come with: From 90b2334dcc170beca3c843fb4d0fbda04bc45d93 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Fri, 24 Apr 2026 03:57:36 -0700 Subject: [PATCH 110/226] feat: Tool name format checks (SEP-986) (#240) --- src/scenarios/server/tools.test.ts | 107 +++++++++++++++++++++++++++++ src/scenarios/server/tools.ts | 83 +++++++++++++++++++++- 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/scenarios/server/tools.test.ts diff --git a/src/scenarios/server/tools.test.ts b/src/scenarios/server/tools.test.ts new file mode 100644 index 0000000..d54daa4 --- /dev/null +++ b/src/scenarios/server/tools.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { buildToolsNameFormatCheck, validateToolNameFormat } from './tools.js'; + +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 64-character name (upper length boundary)', () => { + expect(validateToolNameFormat('a'.repeat(64))).toBeNull(); + }); + + it('rejects an empty name', () => { + expect(validateToolNameFormat('')).toMatch(/length 0 is outside/); + }); + + it('rejects a 65-character name', () => { + expect(validateToolNameFormat('a'.repeat(65))).toMatch( + /length 65 is 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: 'namespace/tool-v1.2' } + ]); + expect(check.status).toBe('SUCCESS'); + expect(check.errorMessage).toBeUndefined(); + expect(check.details).toMatchObject({ + toolCount: 2, + results: { + test_simple_text: 'valid', + 'namespace/tool-v1.2': 'valid' + } + }); + }); + + it('returns FAILURE with per-tool details when some names are invalid', () => { + const check = buildToolsNameFormatCheck([ + { name: 'good_tool' }, + { name: 'bad name with spaces' }, + { name: 'a'.repeat(65) } + ]); + + expect(check.status).toBe('FAILURE'); + expect(check.errorMessage).toContain( + '2 tool name(s) violate SEP-986 format' + ); + + 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(65)]).toMatch(/^invalid: length 65/); + }); + + it('flags a tool whose name is not a string', () => { + const check = buildToolsNameFormatCheck([{ name: 123 as unknown }]); + expect(check.status).toBe('FAILURE'); + const results = (check.details as { results: Record }) + .results; + expect(results['']).toBe( + 'invalid: name is not a string' + ); + }); + + it('includes both MCP-Tools-List and SEP-986 spec references', () => { + const check = buildToolsNameFormatCheck([{ name: 'ok' }]); + const ids = check.specReferences?.map((r) => r.id); + expect(ids).toEqual(['MCP-Tools-List', 'SEP-986']); + }); +}); diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index 7ecbfdd..de49d78 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -11,6 +11,83 @@ import { Progress } from '@modelcontextprotocol/sdk/types.js'; +const TOOL_NAME_PATTERN = /^[A-Za-z0-9_./-]+$/; +const TOOL_NAME_MAX_LENGTH = 64; + +const TOOLS_NAME_FORMAT_SPEC_REFS = [ + { + id: 'MCP-Tools-List', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools' + }, + { + id: 'SEP-986', + url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/SEP/SEP-986.md' + } +]; + +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 are 1-64 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' : 'FAILURE', + errorMessage: + violations.length > 0 + ? `${violations.length} tool name(s) violate SEP-986 format: ${violations.join('; ')}` + : undefined, + details: { + toolCount: tools.length, + results: toolResults + } + }; +} + export class ToolsListScenario implements ClientScenario { name = 'tools-list'; specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; @@ -23,7 +100,7 @@ export class ToolsListScenario implements ClientScenario { **Requirements**: - Return array of all available tools - Each tool MUST have: - - \`name\` (string) + - \`name\` (string, 1-64 chars, matching \`^[A-Za-z0-9_./-]+$\`) - \`description\` (string) - \`inputSchema\` (valid JSON Schema object)`; @@ -72,6 +149,10 @@ export class ToolsListScenario implements ClientScenario { } }); + // Validate tool name format per SEP-986: + // names MUST be 1-64 chars matching ^[A-Za-z0-9_./-]+$ + checks.push(buildToolsNameFormatCheck(result.tools)); + await connection.close(); } catch (error) { checks.push({ From 5d8595b663c722250b6b5cbdc9f6a717d36f360c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 12:09:12 +0100 Subject: [PATCH 111/226] test: add server-side negative test harness (#249) Adds src/scenarios/server/negative.test.ts with spawn helpers. Wires two cases: the previously-orphaned no-dns-rebinding-protection.ts and a new sep-2164-empty-contents.ts. Strengthens the AGENTS.md guidance to point at the harness. --- AGENTS.md | 2 +- .../typescript/sep-2164-empty-contents.ts | 66 +++++++++++ src/scenarios/server/negative.test.ts | 109 ++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 examples/servers/typescript/sep-2164-empty-contents.ts create mode 100644 src/scenarios/server/negative.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7a230da..1d8aef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ gh api "repos/modelcontextprotocol/modelcontextprotocol/contents/docs/specificat 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. **Evidence it fails when it should** — ideally a negative example (a deliberately broken client), or at minimum a manual run showing the failure mode. +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. 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..6ec1bdf --- /dev/null +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +/** + * SEP-2164 Negative Test Server + * + * 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. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + ListResourcesRequestSchema, + ReadResourceRequestSchema +} from '@modelcontextprotocol/sdk/types.js'; +import express from 'express'; + +function createServer() { + const server = new Server( + { name: 'sep-2164-empty-contents', version: '1.0.0' }, + { capabilities: { resources: {} } } + ); + + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [] + })); + + server.setRequestHandler(ReadResourceRequestSchema, async () => ({ + contents: [] + })); + + 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 || '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/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts new file mode 100644 index 0000000..769374a --- /dev/null +++ b/src/scenarios/server/negative.test.ts @@ -0,0 +1,109 @@ +import { spawn, ChildProcess } from 'child_process'; +import path from 'path'; +import { DNSRebindingProtectionScenario } from './dns-rebinding'; +import { ResourcesNotFoundErrorScenario } from './resources'; + +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('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(`http://localhost:${PORT}/mcp`); + + const rebindingCheck = checks.find( + (c) => c.id === 'localhost-host-rebinding-rejected' + ); + expect(rebindingCheck?.status).toBe('FAILURE'); + }, 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(`http://localhost:${PORT}/mcp`); + + 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); + }); +}); From 825f2031c89fd3c95f12ffdfa0b39f34b536500e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 12:22:07 +0100 Subject: [PATCH 112/226] =?UTF-8?q?chore(deps):=20security=20rollup=20?= =?UTF-8?q?=E2=80=94=20path-to-regexp,=20defu,=20vite,=20hono,=20@hono/nod?= =?UTF-8?q?e-server=20(#251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #204, #209, #210, #218, #229, #230, #231, #232, #233. Excludes #205 (failing CI; triaged separately). --- examples/servers/typescript/package-lock.json | 26 +- examples/servers/typescript/package.json | 2 +- package-lock.json | 406 +++++++++++++++--- 3 files changed, 372 insertions(+), 62 deletions(-) diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index 71f4cba..d892ee6 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -8,7 +8,7 @@ "name": "mcp-conformance-test-server", "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", @@ -462,9 +462,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "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" @@ -474,9 +474,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "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", @@ -1106,9 +1106,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "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" @@ -1338,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", diff --git a/examples/servers/typescript/package.json b/examples/servers/typescript/package.json index 9b39cf9..4356d42 100644 --- a/examples/servers/typescript/package.json +++ b/examples/servers/typescript/package.json @@ -15,7 +15,7 @@ "testing" ], "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/cors": "^2.8.19", "cors": "^2.8.5", "express": "^5.2.1", diff --git a/package-lock.json b/package-lock.json index 82032f1..844cb83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,21 +108,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "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.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "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, @@ -131,9 +131,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "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, @@ -752,9 +752,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "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" @@ -895,20 +895,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": { @@ -1705,9 +1707,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "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": { @@ -2196,9 +2198,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": { @@ -2430,9 +2432,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" }, @@ -3225,9 +3227,9 @@ } }, "node_modules/hono": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", - "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "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" @@ -4187,9 +4189,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", @@ -4233,9 +4235,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -4737,14 +4739,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" @@ -5046,17 +5048,17 @@ } }, "node_modules/vite": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", - "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", + "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": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.11", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -5073,7 +5075,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -5123,6 +5125,314 @@ } } }, + "node_modules/vite/node_modules/@oxc-project/types": { + "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": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/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/vite/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/vite/node_modules/@rolldown/binding-linux-x64-gnu": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite/node_modules/@rolldown/pluginutils": { + "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/vite/node_modules/rolldown": { + "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.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@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/vitest": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", From 7f6eb148354f42f2d0d354024883f053666806dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:51:39 +0100 Subject: [PATCH 113/226] chore(deps): bump the all-dependencies group across 1 directory with 10 updates (#252) * chore(deps): bump the all-dependencies group across 1 directory with 10 updates Bumps the all-dependencies group with 10 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1.27.1` | `1.29.0` | | [eventsource-parser](https://github.com/rexxars/eventsource-parser) | `3.0.6` | `3.0.8` | | [undici](https://github.com/nodejs/undici) | `7.24.5` | `7.25.0` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.0` | `25.6.0` | | [@typescript/native-preview](https://github.com/microsoft/typescript-go) | `7.0.0-dev.20260325.1` | `7.0.0-dev.20260424.1` | | [lefthook](https://github.com/evilmartians/lefthook) | `2.1.4` | `2.1.6` | | [prettier](https://github.com/prettier/prettier) | `3.8.1` | `3.8.3` | | [tsdown](https://github.com/rolldown/tsdown) | `0.21.5` | `0.21.10` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.57.2` | `8.59.0` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.1` | `4.1.5` | Updates `@modelcontextprotocol/sdk` from 1.27.1 to 1.29.0 - [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.27.1...v1.29.0) Updates `eventsource-parser` from 3.0.6 to 3.0.8 - [Release notes](https://github.com/rexxars/eventsource-parser/releases) - [Changelog](https://github.com/rexxars/eventsource-parser/blob/main/CHANGELOG.md) - [Commits](https://github.com/rexxars/eventsource-parser/compare/v3.0.6...v3.0.8) Updates `undici` from 7.24.5 to 7.25.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.24.5...v7.25.0) Updates `@types/node` from 25.5.0 to 25.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `@typescript/native-preview` from 7.0.0-dev.20260325.1 to 7.0.0-dev.20260424.1 - [Changelog](https://github.com/microsoft/typescript-go/blob/main/CHANGES.md) - [Commits](https://github.com/microsoft/typescript-go/commits) Updates `lefthook` from 2.1.4 to 2.1.6 - [Release notes](https://github.com/evilmartians/lefthook/releases) - [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md) - [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.4...v2.1.6) Updates `prettier` from 3.8.1 to 3.8.3 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3) Updates `tsdown` from 0.21.5 to 0.21.10 - [Release notes](https://github.com/rolldown/tsdown/releases) - [Commits](https://github.com/rolldown/tsdown/compare/v0.21.5...v0.21.10) Updates `typescript-eslint` from 8.57.2 to 8.59.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint) Updates `vitest` from 4.1.1 to 4.1.5 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest) --- updated-dependencies: - dependency-name: "@modelcontextprotocol/sdk" dependency-version: 1.29.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-dependencies - dependency-name: eventsource-parser dependency-version: 3.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-dependencies - dependency-name: undici dependency-version: 7.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-dependencies - dependency-name: "@types/node" dependency-version: 25.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-dependencies - dependency-name: "@typescript/native-preview" dependency-version: 7.0.0-dev.20260424.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies - dependency-name: lefthook dependency-version: 2.1.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies - dependency-name: prettier dependency-version: 3.8.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies - dependency-name: tsdown dependency-version: 0.21.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies - dependency-name: typescript-eslint dependency-version: 8.59.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-dependencies - dependency-name: vitest dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-dependencies ... Signed-off-by: dependabot[bot] * fix: switch moduleResolution to bundler for newer tsgo --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Paul Carleton --- package-lock.json | 1063 +++++++++++++++++---------------------------- package.json | 20 +- tsconfig.json | 2 +- 3 files changed, 406 insertions(+), 679 deletions(-) diff --git a/package-lock.json b/package-lock.json index 844cb83..5f7034d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,13 @@ "version": "0.1.16", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", "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": "^4.3.6" }, @@ -25,29 +25,29 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/express": "^5.0.3", - "@types/node": "^25.5.0", - "@typescript/native-preview": "^7.0.0-dev.20260325.1", + "@types/node": "^25.6.0", + "@typescript/native-preview": "^7.0.0-dev.20260424.1", "cors": "^2.8.5", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", - "lefthook": "^2.1.4", - "prettier": "^3.8.1", - "tsdown": "^0.21.5", + "lefthook": "^2.1.6", + "prettier": "^3.8.3", + "tsdown": "^0.21.10", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.57.1", - "vitest": "^4.0.16" + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.5" } }, "node_modules/@babel/generator": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.2.tgz", - "integrity": "sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==", + "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": "^8.0.0-rc.2", - "@babel/types": "^8.0.0-rc.2", + "@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", @@ -68,9 +68,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.2.tgz", - "integrity": "sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==", + "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": { @@ -78,13 +78,13 @@ } }, "node_modules/@babel/parser": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.2.tgz", - "integrity": "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==", + "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": "^8.0.0-rc.2" + "@babel/types": "^8.0.0-rc.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -94,14 +94,14 @@ } }, "node_modules/@babel/types": { - "version": "8.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.2.tgz", - "integrity": "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==", + "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": "^8.0.0-rc.2", - "@babel/helper-validator-identifier": "^8.0.0-rc.2" + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -855,9 +855,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", - "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "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", @@ -1069,9 +1069,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "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": { @@ -1092,9 +1092,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "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" ], @@ -1109,9 +1109,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "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" ], @@ -1126,9 +1126,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "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" ], @@ -1143,9 +1143,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "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" ], @@ -1160,9 +1160,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "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" ], @@ -1177,9 +1177,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "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" ], @@ -1194,9 +1194,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "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" ], @@ -1211,9 +1211,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "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" ], @@ -1228,9 +1228,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "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" ], @@ -1245,9 +1245,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "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" ], @@ -1262,9 +1262,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "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" ], @@ -1279,9 +1279,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "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" ], @@ -1296,9 +1296,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "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" ], @@ -1306,16 +1306,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@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-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "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" ], @@ -1330,9 +1332,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "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" ], @@ -1347,9 +1349,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "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" }, @@ -1464,13 +1466,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "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": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { @@ -1509,20 +1511,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "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.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.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.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1532,9 +1534,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -1548,16 +1550,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "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.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@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": { @@ -1569,18 +1571,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "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.57.2", - "@typescript-eslint/types": "^8.57.2", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -1591,18 +1593,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.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "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.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1613,9 +1615,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "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": { @@ -1626,21 +1628,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.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "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.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@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.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1651,13 +1653,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "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": { @@ -1669,21 +1671,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "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.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@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.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1693,7 +1695,7 @@ "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": { @@ -1720,13 +1722,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -1736,16 +1738,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "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.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@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" @@ -1756,17 +1758,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "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.57.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1791,28 +1793,31 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-42I1oVqz2EOkE1vCrzazV3r+zVREq+le4m7Vr4OEz9taH2rhR02yxq+tNygKV3IOUOPLOXkX/soKcgrF3drDHA==", + "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.20260325.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260325.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260325.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260325.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260325.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260325.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260325.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.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-TN51zclpW+D9Qe55Do1ATeZaZ77E6H5JX5cG86xFTKhXaFaW35ANagS86t6d5xnf0quemXM6EP06so2WLSYCqw==", + "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" ], @@ -1821,12 +1826,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-iRzGkGdJmTGJHk8jI7PSjHjbDGrrw5oImTUfACevJFpB+dA5Hn/bsYlJQ5MR9KmDAJYoRHY1HQp6Dm30zXZw3A==", + "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" ], @@ -1835,12 +1843,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-MSumEH3jrfCXAtrkgm8DF4IeNiKAoJBpnyGS4WdjIQkqeI6c2wEGRXWJixOJRj3Lp7/CDx5Wo+ySFyjNdC4Uyg==", + "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" ], @@ -1849,12 +1860,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-qY10cp4PurJBD0TT7e4JwMUh2cGySLI+F7r5wZkkARSU/5aXAsWOImnVtshuzyv+MBfhcq8KHB1XMb62Kjrruw==", + "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" ], @@ -1863,12 +1877,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-p93R+o9pV3IuypB3ydWXJSbzUgdHG3KD+5uFQZyo2A/QR9xnRPgTOhFnHXj9ml/RQvGHbmmAdFe/Xe2GiwnsSQ==", + "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" ], @@ -1877,12 +1894,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-OgoAfFryES4XS08PNXEJL54z4VbxY7VDwLb5z+TnMl5TMqYprk7cZZ+hQtq7XzwgailQyI162CQ81e+vtPuXqQ==", + "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" ], @@ -1891,12 +1911,15 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260325.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260325.1.tgz", - "integrity": "sha512-BuzbtCqAYR/CmWDzaEw3/s80HLHXCIu+eSepRygjiLdd8CiNbIIAwCo2teQ1C5fjsWQ+Iu8iAJItOLpxWWTCzg==", + "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" ], @@ -1905,34 +1928,37 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@vitest/expect": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", - "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "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.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", - "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "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.1.1", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1953,26 +1979,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", - "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "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.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", - "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "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.1.1", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -1980,14 +2006,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", - "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "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.1.1", - "@vitest/utils": "4.1.1", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1996,9 +2022,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", - "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "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": { @@ -2006,15 +2032,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", - "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "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.1.1", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2841,9 +2867,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" @@ -3236,9 +3262,9 @@ } }, "node_modules/hookable": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz", - "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", + "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", "dev": true, "license": "MIT" }, @@ -3306,9 +3332,9 @@ } }, "node_modules/import-without-cache": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz", - "integrity": "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==", + "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": { @@ -3465,9 +3491,9 @@ } }, "node_modules/lefthook": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.4.tgz", - "integrity": "sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w==", + "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", @@ -3475,22 +3501,22 @@ "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "2.1.4", - "lefthook-darwin-x64": "2.1.4", - "lefthook-freebsd-arm64": "2.1.4", - "lefthook-freebsd-x64": "2.1.4", - "lefthook-linux-arm64": "2.1.4", - "lefthook-linux-x64": "2.1.4", - "lefthook-openbsd-arm64": "2.1.4", - "lefthook-openbsd-x64": "2.1.4", - "lefthook-windows-arm64": "2.1.4", - "lefthook-windows-x64": "2.1.4" + "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.4", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.4.tgz", - "integrity": "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw==", + "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" ], @@ -3502,9 +3528,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.4.tgz", - "integrity": "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q==", + "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" ], @@ -3516,9 +3542,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.4.tgz", - "integrity": "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw==", + "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" ], @@ -3530,9 +3556,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.4.tgz", - "integrity": "sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ==", + "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" ], @@ -3544,9 +3570,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.4.tgz", - "integrity": "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA==", + "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" ], @@ -3558,9 +3584,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.4.tgz", - "integrity": "sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA==", + "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" ], @@ -3572,9 +3598,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.4.tgz", - "integrity": "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw==", + "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" ], @@ -3586,9 +3612,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.4.tgz", - "integrity": "sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg==", + "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" ], @@ -3600,9 +3626,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.4.tgz", - "integrity": "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA==", + "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" ], @@ -3614,9 +3640,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.4.tgz", - "integrity": "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw==", + "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" ], @@ -4274,9 +4300,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "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": { @@ -4398,14 +4424,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "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.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4414,39 +4440,40 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + "@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.22.5", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.22.5.tgz", - "integrity": "sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==", + "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": "8.0.0-rc.2", - "@babel/helper-validator-identifier": "8.0.0-rc.2", - "@babel/parser": "8.0.0-rc.2", - "@babel/types": "8.0.0-rc.2", + "@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.6", - "obug": "^2.1.1" + "get-tsconfig": "^4.13.7", + "obug": "^2.1.1", + "picomatch": "^4.0.4" }, "engines": { "node": ">=20.19.0" @@ -4456,9 +4483,9 @@ }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", - "@typescript/native-preview": ">=7.0.0-dev.20250601.1", - "rolldown": "^1.0.0-rc.3", - "typescript": "^5.0.0 || ^6.0.0-beta", + "@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": { @@ -4729,9 +4756,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -4798,28 +4825,28 @@ } }, "node_modules/tsdown": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.5.tgz", - "integrity": "sha512-TlgNhfPioAD6ECCUnZsxcUsXXuPPR4Rrxz3az741kL/M3oGIET4a9GajSNRNRx+jIva73TYUKQybrEPkDYN+fQ==", + "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": "^7.0.0", - "defu": "^6.1.4", + "defu": "^6.1.7", "empathic": "^2.0.0", - "hookable": "^6.1.0", - "import-without-cache": "^0.2.5", + "hookable": "^6.1.1", + "import-without-cache": "^0.3.3", "obug": "^2.1.1", "picomatch": "^4.0.4", - "rolldown": "1.0.0-rc.11", - "rolldown-plugin-dts": "^0.22.5", + "rolldown": "1.0.0-rc.17", + "rolldown-plugin-dts": "^0.23.2", "semver": "^7.7.4", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", + "tinyexec": "^1.1.1", + "tinyglobby": "^0.2.16", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", - "unrun": "^0.2.33" + "unrun": "^0.2.37" }, "bin": { "tsdown": "dist/run.mjs" @@ -4832,8 +4859,8 @@ }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", - "@tsdown/css": "0.21.5", - "@tsdown/exe": "0.21.5", + "@tsdown/css": "0.21.10", + "@tsdown/exe": "0.21.10", "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0 || ^6.0.0", @@ -4933,16 +4960,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", - "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "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.57.2", - "@typescript-eslint/parser": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2" + "@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" @@ -4953,7 +4980,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unconfig-core": { @@ -4971,18 +4998,18 @@ } }, "node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "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": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "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" }, @@ -5002,13 +5029,13 @@ } }, "node_modules/unrun": { - "version": "0.2.33", - "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.33.tgz", - "integrity": "sha512-urXTjZHOHS6lMnatQerLcBpcTsaKZYGuu9aSZ+HlNfCApkiINRbj7YecC9h9hdZroMT4WQ4KVyzHfBqHKuFX9Q==", + "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.11" + "rolldown": "1.0.0-rc.17" }, "bin": { "unrun": "dist/cli.mjs" @@ -5125,328 +5152,20 @@ } } }, - "node_modules/vite/node_modules/@oxc-project/types": { - "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": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/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/vite/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/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/pluginutils": { - "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/vite/node_modules/rolldown": { - "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.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@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/vitest": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", - "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "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.1.1", - "@vitest/mocker": "4.1.1", - "@vitest/pretty-format": "4.1.1", - "@vitest/runner": "4.1.1", - "@vitest/snapshot": "4.1.1", - "@vitest/spy": "4.1.1", - "@vitest/utils": "4.1.1", + "@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", @@ -5457,7 +5176,7 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", + "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, @@ -5474,10 +5193,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.1", - "@vitest/browser-preview": "4.1.1", - "@vitest/browser-webdriverio": "4.1.1", - "@vitest/ui": "4.1.1", + "@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": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5501,6 +5222,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/package.json b/package.json index 8f9a768..b28437f 100644 --- a/package.json +++ b/package.json @@ -32,27 +32,27 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/express": "^5.0.3", - "@types/node": "^25.5.0", - "@typescript/native-preview": "^7.0.0-dev.20260325.1", + "@types/node": "^25.6.0", + "@typescript/native-preview": "^7.0.0-dev.20260424.1", "cors": "^2.8.5", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", - "lefthook": "^2.1.4", - "prettier": "^3.8.1", - "tsdown": "^0.21.5", + "lefthook": "^2.1.6", + "prettier": "^3.8.3", + "tsdown": "^0.21.10", "tsx": "^4.7.0", "typescript": "^5.5.4", - "typescript-eslint": "^8.57.1", - "vitest": "^4.0.16" + "typescript-eslint": "^8.59.0", + "vitest": "^4.1.5" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", "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": "^4.3.6" } 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, From d94412200363296d8ab8b0c9dde4015c1ac32dd6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 13:01:05 +0100 Subject: [PATCH 114/226] =?UTF-8?q?chore:=20remove=20dependabot.yml=20?= =?UTF-8?q?=E2=80=94=20security-only=20via=20repo=20defaults=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conformance is a leaf CLI; scheduled version bumps are churn. Deleting the config disables version-update PRs entirely. Security advisories remain enabled via repo settings (Code security > Dependabot). --- .github/dependabot.yml | 42 ------------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 77cff36..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,42 +0,0 @@ -version: 2 - -# Security updates always come through regardless of these settings. -# Scheduled (non-security) updates are limited to minor/patch only — -# major bumps of devDeps like eslint/typescript are manual decisions. - -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: monthly - groups: - all-dependencies: - patterns: - - '*' - ignore: - - dependency-name: '*' - update-types: ['version-update:semver-major'] - - - package-ecosystem: npm - directory: /examples/servers/typescript - schedule: - interval: monthly - groups: - all-dependencies: - patterns: - - '*' - ignore: - - dependency-name: '*' - update-types: ['version-update:semver-major'] - - - package-ecosystem: github-actions - directory: / - schedule: - interval: monthly - groups: - all-actions: - patterns: - - '*' - ignore: - - dependency-name: '*' - update-types: ['version-update:semver-major'] From 17f1f93cc070754cdd290ac13476dcfa13f39855 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 30 Apr 2026 11:37:32 +0100 Subject: [PATCH 115/226] feat: support "draft" as a first-class spec version target (#255) * feat: support "draft" as a first-class spec version target - Add LATEST_SPEC_VERSION and DATED_SPEC_VERSIONS constants so the next spec release is a one-line change in types.ts. - Accept "draft" as a valid protocolVersion in the initialize check and mock-server response. - --spec-version draft now selects latest-dated scenarios plus draft-tagged ones, so SEP authors can run the full suite against an SDK tracking the in-progress spec without retagging core scenarios. - Forward --spec-version to the client process via MCP_CONFORMANCE_SPEC_VERSION so SDK examples can pick the matching protocolVersion. Closes #253 * review: collapse SpecVersion to wire strings; dedup NEGOTIABLE_PROTOCOL_VERSIONS; split out 'extension' Addresses review feedback from @felixweinberger and @mikekistler: - SpecVersion is now always a wire protocolVersion string (DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION). The separate 'draft' tag literal is gone from the type system; 'draft' survives only as a CLI input alias in resolveSpecVersion. Removes the tag-vs-wire confusion and the specVersionToProtocolVersion mapping. - NEGOTIABLE_PROTOCOL_VERSIONS in types.ts is the single source for what the mock server accepts on initialize (was duplicated in checks/client.ts and scenarios/client/initialize.ts). - 'extension' moved out of SpecVersion into a separate ScenarioSpecTag type. --spec-version extension is no longer valid; extension scenarios remain reachable via --suite extensions. - Draft-tagged scenarios now use the DRAFT_PROTOCOL_VERSION constant so a draft revision bump is a one-line change in types.ts. --- README.md | 3 +- src/checks/checks.test.ts | 26 +++++++ src/checks/client.ts | 20 +++--- src/index.ts | 6 +- src/runner/client.ts | 14 ++-- .../client/auth/client-credentials.ts | 6 +- src/scenarios/client/auth/cross-app-access.ts | 4 +- src/scenarios/client/auth/offline-access.ts | 10 ++- .../client/auth/resource-mismatch.ts | 8 ++- src/scenarios/client/initialize.ts | 9 +-- src/scenarios/index.ts | 46 +++++++++---- src/scenarios/server/resources.ts | 9 ++- src/scenarios/spec-version.test.ts | 67 ++++++++++++++----- .../checks/test-conformance-results.ts | 16 +++-- src/tier-check/output.ts | 5 +- src/tier-check/types.ts | 4 +- src/types.ts | 46 ++++++++++--- 17 files changed, 221 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index dc84f10..b2b5f0e 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,12 @@ 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") +- `--spec-version ` - Filter scenarios by spec version (e.g., `2025-11-25`, `DRAFT-2026-v1`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios - `--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. ### Server Testing 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/index.ts b/src/index.ts index 38f7701..64d1eaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -152,7 +152,8 @@ program options.command, scenarioName, timeout, - outputDir + outputDir, + specVersionFilter ); return { scenario: scenarioName, @@ -259,7 +260,8 @@ program validated.command, validated.scenario, timeout, - outputDir + outputDir, + specVersionFilter ); const { overallFailure } = printClientResults( diff --git a/src/runner/client.ts b/src/runner/client.ts index 4525416..1bf8c9f 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -1,7 +1,7 @@ import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; -import { ConformanceCheck } from '../types'; +import { ConformanceCheck, SpecVersion } from '../types'; import { getScenario } from '../scenarios'; import { createResultDir, formatPrettyChecks } from './utils'; @@ -17,7 +17,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 +35,9 @@ async function executeClient( // 3. Semantic separation: scenario identifies "which test", context provides "test data" const env = { ...process.env }; env.MCP_CONFORMANCE_SCENARIO = scenarioName; + if (specVersion) { + env.MCP_CONFORMANCE_PROTOCOL_VERSION = specVersion; + } if (context) { // Include scenario name in context for discriminated union parsing env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({ @@ -92,7 +96,8 @@ export async function runConformanceTest( clientCommand: string, scenarioName: string, timeout: number = 30000, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion ): Promise<{ checks: ConformanceCheck[]; clientOutput: ClientExecutionResult; @@ -123,7 +128,8 @@ export async function runConformanceTest( scenarioName, urls.serverUrl, timeout, - urls.context + urls.context, + specVersion ); // Print stdout/stderr if client exited with nonzero code diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 79ab1b2..5923da2 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -4,7 +4,7 @@ import type { Scenario, ConformanceCheck, ScenarioUrls, - SpecVersion + ScenarioSpecTag } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; @@ -37,7 +37,7 @@ async function generateTestKeypair(): Promise<{ */ export class ClientCredentialsJwtScenario implements Scenario { name = 'auth/client-credentials-jwt'; - specVersions: SpecVersion[] = ['extension']; + specVersions: ScenarioSpecTag[] = ['extension']; description = 'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)'; @@ -256,7 +256,7 @@ export class ClientCredentialsJwtScenario implements Scenario { */ export class ClientCredentialsBasicScenario implements Scenario { name = 'auth/client-credentials-basic'; - specVersions: SpecVersion[] = ['extension']; + specVersions: ScenarioSpecTag[] = ['extension']; description = 'Tests OAuth client_credentials flow with client_secret_basic authentication'; diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/cross-app-access.ts index 05a351b..3eaff5a 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/cross-app-access.ts @@ -5,7 +5,7 @@ import type { Scenario, ConformanceCheck, ScenarioUrls, - SpecVersion + ScenarioSpecTag } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; @@ -60,7 +60,7 @@ async function createIdpIdToken( */ export class CrossAppAccessCompleteFlowScenario implements Scenario { name = 'auth/cross-app-access-complete-flow'; - specVersions: SpecVersion[] = ['extension']; + specVersions: ScenarioSpecTag[] = ['extension']; description = 'Tests complete SEP-990 flow: token exchange + JWT bearer grant (Enterprise Managed OAuth)'; diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts index e4ed777..a9029a4 100644 --- a/src/scenarios/client/auth/offline-access.ts +++ b/src/scenarios/client/auth/offline-access.ts @@ -1,5 +1,9 @@ import type { Scenario, ConformanceCheck } from '../../../types'; -import { ScenarioUrls, SpecVersion } from '../../../types'; +import { + ScenarioUrls, + SpecVersion, + DRAFT_PROTOCOL_VERSION +} from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -23,7 +27,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier'; */ export class OfflineAccessScopeScenario implements Scenario { name = 'auth/offline-access-scope'; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; 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)'; @@ -227,7 +231,7 @@ export class OfflineAccessScopeScenario implements Scenario { */ export class OfflineAccessNotSupportedScenario implements Scenario { name = 'auth/offline-access-not-supported'; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = 'Tests that client does not request offline_access when AS does not list it in scopes_supported (SEP-2207)'; diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index dd76c68..32b9525 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -1,5 +1,9 @@ import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { ScenarioUrls, SpecVersion } from '../../../types.js'; +import { + ScenarioUrls, + SpecVersion, + DRAFT_PROTOCOL_VERSION +} from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; import { createServer } from './helpers/createServer.js'; import { ServerLifecycle } from './helpers/serverLifecycle.js'; @@ -27,7 +31,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; */ export class ResourceMismatchScenario implements Scenario { name = 'auth/resource-mismatch'; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = 'Tests that client rejects when PRM resource does not match server URL'; allowClientError = true; diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index 70fb0d1..191ed30 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -3,7 +3,9 @@ import { Scenario, ScenarioUrls, ConformanceCheck, - SpecVersion + SpecVersion, + LATEST_SPEC_VERSION, + NEGOTIABLE_PROTOCOL_VERSIONS } from '../../types'; import { clientChecks } from '../../checks/index'; @@ -117,11 +119,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/index.ts b/src/scenarios/index.ts index 7080849..0e2191a 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -2,7 +2,11 @@ import { Scenario, ClientScenario, ClientScenarioForAuthorizationServer, - SpecVersion + SpecVersion, + ScenarioSpecTag, + DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION } from '../types'; import { InitializeScenario } from './client/initialize'; import { ToolsCallScenario } from './client/tools_call'; @@ -256,32 +260,50 @@ export function listDraftScenarios(): string[] { 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(', ')}`); + console.error( + `Valid versions: ${ALL_SPEC_VERSIONS.join(', ')} (or 'draft' as an alias for ${DRAFT_PROTOCOL_VERSION})` + ); process.exit(1); } +// The draft version selects everything in the latest dated release plus +// scenarios tagged draft-only, so SEP authors can run the full suite against an +// SDK tracking the in-progress spec without retagging core scenarios. +function matchesSpecVersion( + scenario: { specVersions: ScenarioSpecTag[] }, + version: SpecVersion +): boolean { + if (version === DRAFT_PROTOCOL_VERSION) { + return ( + scenario.specVersions.includes(DRAFT_PROTOCOL_VERSION) || + scenario.specVersions.includes(LATEST_SPEC_VERSION) + ); + } + return scenario.specVersions.includes(version); +} + export function listScenariosForSpec(version: SpecVersion): string[] { return scenariosList - .filter((s) => s.specVersions.includes(version)) + .filter((s) => matchesSpecVersion(s, version)) .map((s) => s.name); } export function listClientScenariosForSpec(version: SpecVersion): string[] { return allClientScenariosList - .filter((s) => s.specVersions.includes(version)) + .filter((s) => matchesSpecVersion(s, version)) .map((s) => s.name); } @@ -289,13 +311,13 @@ export function listClientScenariosForAuthorizationServerForSpec( version: SpecVersion ): string[] { return allClientScenariosListForAuthorizationServer - .filter((s) => s.specVersions.includes(version)) + .filter((s) => matchesSpecVersion(s, version)) .map((s) => s.name); } export function getScenarioSpecVersions( name: string -): SpecVersion[] | undefined { +): ScenarioSpecTag[] | undefined { return ( scenarios.get(name)?.specVersions ?? clientScenarios.get(name)?.specVersions ?? @@ -303,4 +325,4 @@ export function getScenarioSpecVersions( ); } -export type { SpecVersion }; +export type { SpecVersion, ScenarioSpecTag }; diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index bea2706..9f9d636 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -2,7 +2,12 @@ * Resources test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { + ClientScenario, + ConformanceCheck, + SpecVersion, + DRAFT_PROTOCOL_VERSION +} from '../../types'; import { connectToServer } from './client-helper'; import { TextResourceContents, @@ -438,7 +443,7 @@ Example request: export class ResourcesNotFoundErrorScenario implements ClientScenario { name = 'sep-2164-resource-not-found'; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test error handling for non-existent resources (SEP-2164). **Server Implementation Requirements:** diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index 0b8e652..87b7970 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -3,9 +3,23 @@ import { listScenarios, listClientScenarios, listScenariosForSpec, + listDraftScenarios, + listExtensionScenarios, getScenarioSpecVersions, + resolveSpecVersion, ALL_SPEC_VERSIONS } from './index'; +import { + DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION, + ScenarioSpecTag +} from '../types'; + +const ALL_SCENARIO_SPEC_TAGS: ScenarioSpecTag[] = [ + ...ALL_SPEC_VERSIONS, + 'extension' +]; describe('specVersions helpers', () => { it('every Scenario has specVersions', () => { @@ -17,7 +31,7 @@ describe('specVersions helpers', () => { ).toBeDefined(); expect(versions!.length).toBeGreaterThan(0); for (const v of versions!) { - expect(ALL_SPEC_VERSIONS).toContain(v); + expect(ALL_SCENARIO_SPEC_TAGS).toContain(v); } } }); @@ -31,7 +45,7 @@ describe('specVersions helpers', () => { ).toBeDefined(); expect(versions!.length).toBeGreaterThan(0); for (const v of versions!) { - expect(ALL_SPEC_VERSIONS).toContain(v); + expect(ALL_SCENARIO_SPEC_TAGS).toContain(v); } } }); @@ -69,26 +83,43 @@ describe('specVersions helpers', () => { } }); - it('draft and extension scenarios are isolated', () => { - const draft = listScenariosForSpec('draft'); - for (const name of draft) { - expect(getScenarioSpecVersions(name)).toContain('draft'); + it('the draft spec version is a superset of the latest dated release', () => { + const latest = new Set(listScenariosForSpec(LATEST_SPEC_VERSION)); + const draft = new Set(listScenariosForSpec(DRAFT_PROTOCOL_VERSION)); + for (const name of latest) { + expect(draft.has(name)).toBe(true); } - const ext = listScenariosForSpec('extension'); - for (const name of ext) { - expect(getScenarioSpecVersions(name)).toContain('extension'); + for (const name of listDraftScenarios()) { + expect(draft.has(name)).toBe(true); } }); - 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); + it('draft-tagged scenarios are not also tagged with a dated version', () => { + for (const name of listDraftScenarios()) { + const versions = getScenarioSpecVersions(name)!; + for (const dated of DATED_SPEC_VERSIONS) { + expect( + versions, + `scenario "${name}" is tagged with both DRAFT_PROTOCOL_VERSION and '${dated}'` + ).not.toContain(dated); + } + } + }); + + it("resolveSpecVersion accepts 'draft' as an alias", () => { + expect(resolveSpecVersion('draft')).toBe(DRAFT_PROTOCOL_VERSION); + expect(resolveSpecVersion(LATEST_SPEC_VERSION)).toBe(LATEST_SPEC_VERSION); + }); + + 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); + } } }); }); diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index 2a9ce43..ba16be2 100644 --- a/src/tier-check/checks/test-conformance-results.ts +++ b/src/tier-check/checks/test-conformance-results.ts @@ -10,14 +10,22 @@ import { listClientScenariosForSpec, getScenarioSpecVersions } from '../../scenarios'; -import { ConformanceCheck, SpecVersion } from '../../types'; +import { + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + ScenarioSpecTag, + SpecVersion +} from '../../types'; -const NON_SCORING_VERSIONS: SpecVersion[] = ['draft', 'extension']; +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?: SpecVersion[]): boolean { +function isTierScoring(specVersions?: ScenarioSpecTag[]): boolean { if (!specVersions || specVersions.length === 0) return true; // unknown = count it - return specVersions.some((v) => !NON_SCORING_VERSIONS.includes(v)); + return specVersions.some((v) => !NON_SCORING_TAGS.includes(v)); } /** diff --git a/src/tier-check/output.ts b/src/tier-check/output.ts index f08a47d..ab0d2fb 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,9 +24,9 @@ function statusIcon(status: CheckStatus): string { } } -const TIER_SPEC_VERSIONS = ['2025-03-26', '2025-06-18', '2025-11-25'] as const; +const TIER_SPEC_VERSIONS = DATED_SPEC_VERSIONS; -const INFO_SPEC_VERSIONS = ['draft', 'extension'] as const; +const INFO_SPEC_VERSIONS = [DRAFT_PROTOCOL_VERSION, 'extension'] as const; type Cell = { passed: number; total: number }; diff --git a/src/tier-check/types.ts b/src/tier-check/types.ts index a9830f4..eacbcbe 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'; @@ -17,7 +17,7 @@ export interface ConformanceResult extends CheckResult { passed: boolean; checks_passed: number; checks_failed: number; - specVersions?: SpecVersion[]; + specVersions?: ScenarioSpecTag[]; }>; } diff --git a/src/types.ts b/src/types.ts index 193686f..51cee73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,12 +23,40 @@ export interface ConformanceCheck { 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 = 'DRAFT-2026-v1'; + +// 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; + +// 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'; export interface ScenarioUrls { serverUrl: string; @@ -43,7 +71,7 @@ export interface ScenarioUrls { export interface Scenario { name: string; description: string; - specVersions: SpecVersion[]; + specVersions: ScenarioSpecTag[]; /** * 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). @@ -57,13 +85,13 @@ export interface Scenario { export interface ClientScenario { name: string; description: string; - specVersions: SpecVersion[]; + specVersions: ScenarioSpecTag[]; run(serverUrl: string): Promise; } export interface ClientScenarioForAuthorizationServer { name: string; description: string; - specVersions: SpecVersion[]; + specVersions: ScenarioSpecTag[]; run(serverUrl: string): Promise; } From 04dd6db7ae341517005d218d1bd207d577b8af3f Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Wed, 13 May 2026 23:53:24 +0900 Subject: [PATCH 116/226] feat: add new-sep scaffolding command (#272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add new-sep scaffolding command Related #243. This commit adds the new-sep subcommand, which writes the file src/scenarios//sep-.yaml. This file contains the "sep", "spec_url", and "requirements" rows in the same style as sep-2164.yaml, with single quotes and two spaces. The spec source resolves in order: --spec-url → --spec-path → GitHub API against modelcontextprotocol/modelcontextprotocol (explicit --pr or title search for "SEP-NNNN"). The target directory is inferred from the spec path (`server/`, `client/`, or `basic/authorization*`); the --target flag overrides this. It refuses to overwrite without the --force flag. Token resolution reuses the "gh auth token" fallback pattern from tier-check. It also ships the .claude/skills/new-sep directory, which drives the CLI. It fetches the spec diff via "gh api" and extracts RFC 2119 sentences. There is a warning against regex-only matching. Bullets inherit keywords from their lead-in sentence. It rewrites the placeholder rows per the severity rules in AGENTS.md. MUST/SHALL/REQUIRED are checked for FAILURE. SHOULD is checked for WARNING. MAY/OPTIONAL are excluded. Signed-off-by: Satoshi Ito * dropped --pr arg, list changed files, support multi-file seps w url override * Consolidate SEP yamls into src/seps/ and tighten yaml ordering - All sep-NNNN.yaml files now live in src/seps/ regardless of which scenario suite their checks run in - Drop --target / inferTarget; output dir is fixed - renderYaml: check rows emit `check:` first, excluded rows grouped at bottom with a blank-line separator - SKILL.md: document key/row ordering, expand exclusion-confirmation step - Move src/scenarios/server/sep-2164.yaml -> src/seps/ --------- Signed-off-by: Satoshi Ito Co-authored-by: Paul Carleton --- .claude/skills/new-sep/SKILL.md | 201 +++++++++++++++ AGENTS.md | 10 + src/index.ts | 4 + src/new-sep/index.test.ts | 115 +++++++++ src/new-sep/index.ts | 256 +++++++++++++++++++ src/{scenarios/server => seps}/sep-2164.yaml | 9 +- 6 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/new-sep/SKILL.md create mode 100644 src/new-sep/index.test.ts create mode 100644 src/new-sep/index.ts rename src/{scenarios/server => seps}/sep-2164.yaml (65%) 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/AGENTS.md b/AGENTS.md index 1d8aef1..fc864ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,16 @@ Verify requirement levels against the SEP's **spec diff** — the change to `doc 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. + ## Examples: prove it passes and fails A new scenario should come with: diff --git a/src/index.ts b/src/index.ts index 64d1eaa..013f44c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ import { printBaselineResults } from './expected-failures'; import { createTierCheckCommand } from './tier-check'; +import { createNewSepCommand } from './new-sep'; import packageJson from '../package.json'; // Note on naming: `command` refers to which CLI command is calling this. @@ -540,6 +541,9 @@ program // Tier check command program.addCommand(createTierCheckCommand()); +// New SEP scaffolding command +program.addCommand(createNewSepCommand()); + // List scenarios command program .command('list') 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/scenarios/server/sep-2164.yaml b/src/seps/sep-2164.yaml similarity index 65% rename from src/scenarios/server/sep-2164.yaml rename to src/seps/sep-2164.yaml index 051253d..83c11ce 100644 --- a/src/scenarios/server/sep-2164.yaml +++ b/src/seps/sep-2164.yaml @@ -1,9 +1,10 @@ sep: 2164 spec_url: 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 + - 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' From acc70de0f6a500f5c7335ecdb3418388cedc8088 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 14 May 2026 16:12:32 +0100 Subject: [PATCH 117/226] refactor: range-based spec version tagging (introducedIn/removedIn) (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: replace specVersions list with introducedIn/removedIn range tagging Replaces per-scenario `specVersions: SpecVersion[]` arrays with `introducedIn` and optional `removedIn` range fields on all three scenario interfaces. Extension scenarios get an orthogonal `extension?: boolean` flag instead of the former `'extension'` tag in the versions list. matchesSpecVersion() is rewritten as a range comparison (introducedIn <= target && (!removedIn || target < removedIn)), with draft sorting after all dated versions. getScenarioSpecVersions() reconstructs the effective version list from the range for backward-compat with tier-check. Closes #256. * refactor: nest spec-version range under `source: ScenarioSource` union The flat `introducedIn`/`removedIn`/`extension?` shape forced extension scenarios to set a placeholder `introducedIn` that was never read. Nesting the version-range fields inside a `source` property typed as a union lets the compiler enforce introducedIn XOR extensionId — no runtime invariant test needed. type ScenarioSource = | { introducedIn: SpecVersion; removedIn?: SpecVersion } | { extensionId: ExtensionId }; A class can't `implements` a union type directly, so the union lives inside a property and `Scenario` stays a plain interface — `class implements Scenario` keeps working everywhere. `EXTENSION_IDS` is a const array mirroring the `DATED_SPEC_VERSIONS` pattern. Also renames the cross-app-access scenario to enterprise-managed-auth to match the extension's canonical name: file, class, slug, context schema literal, and the everything-client runner. * refactor: use canonical SEP-2133 extension identifiers EXTENSION_IDS now holds the wire identifiers from the spec's extension docs (client-matrix.mdx, docs/extensions/auth/*.mdx) so extensionId values can be cross-referenced against capabilities.extensions keys directly: io.modelcontextprotocol/oauth-client-credentials io.modelcontextprotocol/enterprise-managed-authorization The previous commit used a shortened 'enterprise-managed-auth' which doesn't match the canonical '-authorization'. Re-renames the scenario file/class/slug and downstream references to align. --------- Co-authored-by: Claude --- .../clients/typescript/everything-client.ts | 18 ++-- .../authorization-server-metadata.ts | 5 +- src/scenarios/client/auth/basic-cimd.ts | 4 +- .../client/auth/client-credentials.ts | 15 ++-- .../client/auth/discovery-metadata.ts | 2 +- ...ts => enterprise-managed-authorization.ts} | 19 ++-- src/scenarios/client/auth/index.ts | 4 +- .../client/auth/march-spec-backcompat.ts | 12 ++- src/scenarios/client/auth/offline-access.ts | 10 +-- src/scenarios/client/auth/pre-registration.ts | 9 +- .../client/auth/resource-mismatch.ts | 8 +- src/scenarios/client/auth/scope-handling.ts | 12 +-- .../client/auth/token-endpoint-auth.ts | 4 +- src/scenarios/client/elicitation-defaults.ts | 4 +- src/scenarios/client/initialize.ts | 3 +- src/scenarios/client/sse-retry.ts | 9 +- src/scenarios/client/tools_call.ts | 4 +- src/scenarios/index.ts | 52 ++++++----- src/scenarios/server/dns-rebinding.ts | 4 +- src/scenarios/server/elicitation-defaults.ts | 4 +- src/scenarios/server/elicitation-enums.ts | 4 +- src/scenarios/server/json-schema-2020-12.ts | 4 +- src/scenarios/server/lifecycle.ts | 4 +- src/scenarios/server/prompts.ts | 12 +-- src/scenarios/server/resources.ts | 15 ++-- src/scenarios/server/sse-multiple-streams.ts | 4 +- src/scenarios/server/sse-polling.ts | 4 +- src/scenarios/server/tools.ts | 24 ++--- src/scenarios/server/utils.ts | 8 +- src/scenarios/spec-version.test.ts | 89 +++++++------------ src/schemas/context.ts | 2 +- src/types.ts | 29 +++++- 32 files changed, 193 insertions(+), 208 deletions(-) rename src/scenarios/client/auth/{cross-app-access.ts => enterprise-managed-authorization.ts} (97%) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 3f86339..48c107e 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -375,20 +375,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); @@ -494,7 +494,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: {} } ); @@ -516,12 +516,12 @@ 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 ); // ============================================================================ diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index 96b3669..00c930f 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -3,8 +3,7 @@ */ import { ClientScenarioForAuthorizationServer, - ConformanceCheck, - SpecVersion + ConformanceCheck } from '../../types'; import { request } from 'undici'; @@ -12,7 +11,7 @@ type Status = 'SUCCESS' | 'FAILURE'; export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer { name = 'authorization-server-metadata-endpoint'; - specVersions: SpecVersion[] = ['2025-03-26', '2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-03-26' } as const; description = `Test authorization server metadata endpoint. **Authorization Server Implementation Requirements:** diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts index 9e5fe67..a99b4e3 100644 --- a/src/scenarios/client/auth/basic-cimd.ts +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -1,5 +1,5 @@ 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,7 +22,7 @@ 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(); diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 5923da2..eaebcb0 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -1,11 +1,6 @@ import * as jose from 'jose'; import type { CryptoKey } from 'jose'; -import type { - Scenario, - ConformanceCheck, - ScenarioUrls, - ScenarioSpecTag -} 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 +32,9 @@ async function generateTestKeypair(): Promise<{ */ export class ClientCredentialsJwtScenario implements Scenario { name = 'auth/client-credentials-jwt'; - specVersions: ScenarioSpecTag[] = ['extension']; + readonly source = { + extensionId: 'io.modelcontextprotocol/oauth-client-credentials' + } as const; description = 'Tests OAuth client_credentials flow with private_key_jwt authentication (SEP-1046)'; @@ -256,7 +253,9 @@ export class ClientCredentialsJwtScenario implements Scenario { */ export class ClientCredentialsBasicScenario implements Scenario { name = 'auth/client-credentials-basic'; - specVersions: ScenarioSpecTag[] = ['extension']; + readonly source = { + extensionId: 'io.modelcontextprotocol/oauth-client-credentials' + } as const; description = 'Tests OAuth client_credentials flow with client_secret_basic authentication'; diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 3dd64b7..7387ead 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -87,7 +87,7 @@ 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)'} diff --git a/src/scenarios/client/auth/cross-app-access.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts similarity index 97% rename from src/scenarios/client/auth/cross-app-access.ts rename to src/scenarios/client/auth/enterprise-managed-authorization.ts index 3eaff5a..ddadfbf 100644 --- a/src/scenarios/client/auth/cross-app-access.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -1,12 +1,7 @@ import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; -import type { - Scenario, - ConformanceCheck, - ScenarioUrls, - ScenarioSpecTag -} from '../../../types'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; @@ -53,16 +48,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: ScenarioSpecTag[] = ['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(); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 7f65aa0..62d60b5 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -23,7 +23,7 @@ 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 { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario @@ -54,7 +54,7 @@ export const backcompatScenariosList: Scenario[] = [ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario(), - new CrossAppAccessCompleteFlowScenario() + new EnterpriseManagedAuthorizationScenario() ]; // Draft scenarios (informational - not scored for tier assessment) diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 3bc857e..b250209 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -1,5 +1,5 @@ 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,7 +8,10 @@ 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(); @@ -69,7 +72,10 @@ 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(); diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts index a9029a4..afcd593 100644 --- a/src/scenarios/client/auth/offline-access.ts +++ b/src/scenarios/client/auth/offline-access.ts @@ -1,9 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types'; -import { - ScenarioUrls, - SpecVersion, - DRAFT_PROTOCOL_VERSION -} from '../../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { ServerLifecycle } from './helpers/serverLifecycle'; @@ -27,7 +23,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier'; */ export class OfflineAccessScopeScenario implements Scenario { name = 'auth/offline-access-scope'; - specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + 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)'; @@ -231,7 +227,7 @@ export class OfflineAccessScopeScenario implements Scenario { */ export class OfflineAccessNotSupportedScenario implements Scenario { name = 'auth/offline-access-not-supported'; - specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + 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)'; diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index 00673df..b482e4f 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -1,9 +1,4 @@ -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'; @@ -24,7 +19,7 @@ const PRE_REGISTERED_CLIENT_SECRET = 'pre-registered-secret'; */ 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.'; diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index 32b9525..9c15d4d 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -1,9 +1,5 @@ import type { Scenario, ConformanceCheck } from '../../../types.js'; -import { - ScenarioUrls, - SpecVersion, - DRAFT_PROTOCOL_VERSION -} 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'; @@ -31,7 +27,7 @@ import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; */ export class ResourceMismatchScenario implements Scenario { name = 'auth/resource-mismatch'; - specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = 'Tests that client rejects when PRM resource does not match server URL'; allowClientError = true; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 32db087..e1d0ff1 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -1,5 +1,5 @@ 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'; @@ -15,7 +15,7 @@ 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(); @@ -101,7 +101,7 @@ 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(); @@ -197,7 +197,7 @@ 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(); @@ -284,7 +284,7 @@ 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(); @@ -481,7 +481,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; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 2b32444..79d6296 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -1,5 +1,5 @@ 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'; @@ -45,7 +45,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(); diff --git a/src/scenarios/client/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index 73bc07c..c78f349 100644 --- a/src/scenarios/client/elicitation-defaults.ts +++ b/src/scenarios/client/elicitation-defaults.ts @@ -11,7 +11,7 @@ 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 { createRequestLogger } from '../request-logger'; @@ -474,7 +474,7 @@ 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' } as const; description = 'Tests client applies default values for omitted elicitation fields (SEP-1034)'; private app: express.Application | null = null; diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index 191ed30..b5e2aef 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -3,7 +3,6 @@ import { Scenario, ScenarioUrls, ConformanceCheck, - SpecVersion, LATEST_SPEC_VERSION, NEGOTIABLE_PROTOCOL_VERSIONS } from '../../types'; @@ -11,7 +10,7 @@ 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' } as const; description = 'Tests MCP client initialization handshake'; private server: http.Server | null = null; diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index c8f0792..b90bf40 100644 --- a/src/scenarios/client/sse-retry.ts +++ b/src/scenarios/client/sse-retry.ts @@ -8,16 +8,11 @@ */ import http from 'http'; -import { - Scenario, - ScenarioUrls, - ConformanceCheck, - SpecVersion -} from '../../types.js'; +import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; export class SSERetryScenario implements Scenario { name = 'sse-retry'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)'; diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index 807ad55..59470f3 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -4,7 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } 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 { createRequestLogger } from '../request-logger'; @@ -115,7 +115,7 @@ function createServerApp(checks: ConformanceCheck[]): express.Application { 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; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 0e2191a..396d3bc 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -2,11 +2,12 @@ import { Scenario, ClientScenario, ClientScenarioForAuthorizationServer, + ScenarioSource, SpecVersion, + DatedSpecVersion, ScenarioSpecTag, DATED_SPEC_VERSIONS, - DRAFT_PROTOCOL_VERSION, - LATEST_SPEC_VERSION + DRAFT_PROTOCOL_VERSION } from '../types'; import { InitializeScenario } from './client/initialize'; import { ToolsCallScenario } from './client/tools_call'; @@ -279,31 +280,34 @@ export function resolveSpecVersion(value: string): SpecVersion { process.exit(1); } -// The draft version selects everything in the latest dated release plus -// scenarios tagged draft-only, so SEP authors can run the full suite against an -// SDK tracking the in-progress spec without retagging core scenarios. +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. function matchesSpecVersion( - scenario: { specVersions: ScenarioSpecTag[] }, + source: ScenarioSource, version: SpecVersion ): boolean { - if (version === DRAFT_PROTOCOL_VERSION) { - return ( - scenario.specVersions.includes(DRAFT_PROTOCOL_VERSION) || - scenario.specVersions.includes(LATEST_SPEC_VERSION) - ); - } - return scenario.specVersions.includes(version); + if ('extensionId' in source) return false; + return ( + versionIndex(source.introducedIn) <= versionIndex(version) && + (source.removedIn === undefined || + versionIndex(version) < versionIndex(source.removedIn)) + ); } export function listScenariosForSpec(version: SpecVersion): string[] { return scenariosList - .filter((s) => matchesSpecVersion(s, version)) + .filter((s) => matchesSpecVersion(s.source, version)) .map((s) => s.name); } export function listClientScenariosForSpec(version: SpecVersion): string[] { return allClientScenariosList - .filter((s) => matchesSpecVersion(s, version)) + .filter((s) => matchesSpecVersion(s.source, version)) .map((s) => s.name); } @@ -311,18 +315,24 @@ export function listClientScenariosForAuthorizationServerForSpec( version: SpecVersion ): string[] { return allClientScenariosListForAuthorizationServer - .filter((s) => matchesSpecVersion(s, version)) + .filter((s) => matchesSpecVersion(s.source, version)) .map((s) => s.name); } export function getScenarioSpecVersions( name: string ): ScenarioSpecTag[] | undefined { - return ( - scenarios.get(name)?.specVersions ?? - clientScenarios.get(name)?.specVersions ?? - clientScenariosForAuthorizationServer.get(name)?.specVersions - ); + 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, ScenarioSpecTag }; diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index cd6c5f4..67c98d9 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -5,7 +5,7 @@ * to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { request } from 'undici'; const SPEC_REFERENCES = [ @@ -85,7 +85,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 diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index a458fec..61381c0 100644 --- a/src/scenarios/server/elicitation-defaults.ts +++ b/src/scenarios/server/elicitation-defaults.ts @@ -2,13 +2,13 @@ * SEP-1034: Elicitation default values test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { connectToServer } from './client-helper'; 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' } as const; description = `Test elicitation with default values for all primitive types (SEP-1034). **Server Implementation Requirements:** diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts index c9eb598..3a96451 100644 --- a/src/scenarios/server/elicitation-enums.ts +++ b/src/scenarios/server/elicitation-enums.ts @@ -2,13 +2,13 @@ * SEP-1330: Elicitation enum schema improvements test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { connectToServer } from './client-helper'; 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' } as const; description = `Test elicitation with enum schema improvements (SEP-1330). **Server Implementation Requirements:** diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts index be20f2a..ae1d9c0 100644 --- a/src/scenarios/server/json-schema-2020-12.ts +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -6,7 +6,7 @@ * or additionalProperties fields. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; +import { ClientScenario, ConformanceCheck } from '../../types.js'; import { connectToServer } from './client-helper.js'; const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool'; @@ -14,7 +14,7 @@ const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; export class JsonSchema2020_12Scenario implements ClientScenario { name = 'json-schema-2020-12'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613). **Server Implementation Requirements:** diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 3758615..bb55869 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -2,7 +2,7 @@ * Lifecycle test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { connectToServer } from './client-helper'; const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/; @@ -16,7 +16,7 @@ const SESSION_SPEC_REFERENCES = [ export class ServerInitializeScenario implements ClientScenario { name = 'server-initialize'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test basic server initialization handshake. **Server Implementation Requirements:** diff --git a/src/scenarios/server/prompts.ts b/src/scenarios/server/prompts.ts index f62faac..2f65723 100644 --- a/src/scenarios/server/prompts.ts +++ b/src/scenarios/server/prompts.ts @@ -2,12 +2,12 @@ * Prompts test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { connectToServer } from './client-helper'; 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:** @@ -88,7 +88,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:** @@ -173,7 +173,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:** @@ -269,7 +269,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:** @@ -375,7 +375,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:** diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index 9f9d636..be332d4 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -5,7 +5,6 @@ import { ClientScenario, ConformanceCheck, - SpecVersion, DRAFT_PROTOCOL_VERSION } from '../../types'; import { connectToServer } from './client-helper'; @@ -17,7 +16,7 @@ import { 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:** @@ -98,7 +97,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:** @@ -185,7 +184,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:** @@ -270,7 +269,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:** @@ -372,7 +371,7 @@ 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' } as const; description = `Test subscribing to resource updates. **Server Implementation Requirements:** @@ -443,7 +442,7 @@ Example request: export class ResourcesNotFoundErrorScenario implements ClientScenario { name = 'sep-2164-resource-not-found'; - specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; description = `Test error handling for non-existent resources (SEP-2164). **Server Implementation Requirements:** @@ -602,7 +601,7 @@ This scenario does not require the server to register any specific resource — export class ResourcesUnsubscribeScenario implements ClientScenario { name = 'resources-unsubscribe'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test unsubscribing from resource. **Server Implementation Requirements:** diff --git a/src/scenarios/server/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index ea025de..7090f81 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -9,14 +9,14 @@ * Multiple concurrent streams are achieved via POST requests, each getting their own stream. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; +import { ClientScenario, ConformanceCheck } from '../../types.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 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)'; diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 30deee7..79e5986 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -8,7 +8,7 @@ * - Replaying events when client reconnects with Last-Event-ID */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types.js'; +import { ClientScenario, ConformanceCheck } from '../../types.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -67,7 +67,7 @@ function createLoggingFetch(checks: ConformanceCheck[]) { export class ServerSSEPollingScenario implements ClientScenario { name = 'server-sse-polling'; - specVersions: SpecVersion[] = ['2025-11-25']; + readonly source = { introducedIn: '2025-11-25' } as const; description = 'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)'; diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index de49d78..2fb4717 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -2,7 +2,7 @@ * Tools test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { connectToServer, NotificationCollector } from './client-helper'; import { CallToolResultSchema, @@ -90,7 +90,7 @@ export function buildToolsNameFormatCheck( 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:** @@ -177,7 +177,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:** @@ -262,7 +262,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:** @@ -350,7 +350,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:** @@ -451,7 +451,7 @@ 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' } as const; description = `Test tool that sends log messages during execution. **Server Implementation Requirements:** @@ -540,7 +540,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:** @@ -625,7 +625,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:** @@ -745,7 +745,7 @@ 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' } as const; description = `Test tool that requests LLM sampling from client. **Server Implementation Requirements:** @@ -873,7 +873,7 @@ 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' } as const; description = `Test tool that requests user input (elicitation) from client. **Server Implementation Requirements:** @@ -1004,7 +1004,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:** @@ -1099,7 +1099,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:** diff --git a/src/scenarios/server/utils.ts b/src/scenarios/server/utils.ts index 0a4a391..54b6d94 100644 --- a/src/scenarios/server/utils.ts +++ b/src/scenarios/server/utils.ts @@ -2,12 +2,12 @@ * Utilities test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck } from '../../types'; import { connectToServer } from './client-helper'; export class LoggingSetLevelScenario implements ClientScenario { name = 'logging-set-level'; - specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25']; + readonly source = { introducedIn: '2025-06-18' } as const; description = `Test setting logging level. **Server Implementation Requirements:** @@ -86,7 +86,7 @@ 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' } as const; description = `Test ping utility for connection health check. **Server Implementation Requirements:** @@ -176,7 +176,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:** diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index 87b7970..c77e43e 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -1,88 +1,59 @@ import { describe, it, expect } from 'vitest'; import { listScenarios, - listClientScenarios, listScenariosForSpec, listDraftScenarios, listExtensionScenarios, getScenarioSpecVersions, resolveSpecVersion, - ALL_SPEC_VERSIONS + ALL_SPEC_VERSIONS, + scenarios } from './index'; import { DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION, - LATEST_SPEC_VERSION, - ScenarioSpecTag + LATEST_SPEC_VERSION } from '../types'; -const ALL_SCENARIO_SPEC_TAGS: ScenarioSpecTag[] = [ - ...ALL_SPEC_VERSIONS, - 'extension' -]; - 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_SCENARIO_SPEC_TAGS).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_SCENARIO_SPEC_TAGS).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('the draft spec version is a superset of the latest dated release', () => { const latest = new Set(listScenariosForSpec(LATEST_SPEC_VERSION)); const draft = new Set(listScenariosForSpec(DRAFT_PROTOCOL_VERSION)); @@ -94,14 +65,14 @@ describe('specVersions helpers', () => { } }); - it('draft-tagged scenarios are not also tagged with a dated version', () => { + it('draft-introduced scenarios are not matched by any dated spec version', () => { for (const name of listDraftScenarios()) { - const versions = getScenarioSpecVersions(name)!; for (const dated of DATED_SPEC_VERSIONS) { + const selected = new Set(listScenariosForSpec(dated)); expect( - versions, - `scenario "${name}" is tagged with both DRAFT_PROTOCOL_VERSION and '${dated}'` - ).not.toContain(dated); + selected.has(name), + `draft scenario "${name}" should not appear in --spec-version ${dated}` + ).toBe(false); } } }); diff --git a/src/schemas/context.ts b/src/schemas/context.ts index 9a5e249..12c72af 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -24,7 +24,7 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ client_secret: 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(), diff --git a/src/types.ts b/src/types.ts index 51cee73..8052eb1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,29 @@ export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; // (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' +] 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; authUrl?: string; @@ -71,7 +94,7 @@ export interface ScenarioUrls { export interface Scenario { name: string; description: string; - specVersions: ScenarioSpecTag[]; + 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). @@ -85,13 +108,13 @@ export interface Scenario { export interface ClientScenario { name: string; description: string; - specVersions: ScenarioSpecTag[]; + source: ScenarioSource; run(serverUrl: string): Promise; } export interface ClientScenarioForAuthorizationServer { name: string; description: string; - specVersions: ScenarioSpecTag[]; + source: ScenarioSource; run(serverUrl: string): Promise; } From 39f34b2c5899fd384a39dc12b39aab5b51c06b19 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Fri, 15 May 2026 07:20:43 -0500 Subject: [PATCH 118/226] Add conformance tests for SEP-2243 HTTP Standardization (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add prepare script for git dependency installs * Add conformance tests for SEP-2243 HTTP Standardization * Add traceability file for SEP-2243 * Fix prettier formatting in http-standard-headers.ts * Improve SEP-2243 conformance test coverage Close gaps in HTTP header conformance scenarios: Client standard headers (http-standard-headers.ts): - Enforce all expected methods in checks, failing any that were not observed - Track Mcp-Name header per-method separately from Mcp-Method - Advertise resources and prompts capabilities so clients exercise those endpoints - Add a prompt entry for prompts/get testing Client custom headers (http-custom-headers.ts): - Add Base64 encoding checks for non-ASCII, whitespace, and control-char values - Add null/omitted parameter test (second tool call with null value) - Add client-keeps-valid-tool check verifying clients still call valid tools after filtering out invalid ones Server header validation (server/http-standard-headers.ts): - Replace fetch-based sendRawRequest with http.request to preserve exact header casing on the wire (fetch/Headers lowercases names) - Compute defaultArgs and defaultHeaders from tool schema so test requests satisfy all required parameters while varying only the param under test Traceability (sep-2243.yaml): - Add 7 new spec-to-check mappings (nonascii, whitespace, controlchar, keeps-valid-tool, literal-missing-base64-prefix/suffix, no-mirror-unannotated) - Fix 2 incorrect existing mappings * Fill SEP-2243 conformance test coverage gaps Add missing test cases identified by comparing the branch against the SEP-2243 spec's Conformance Test Cases section: - Mcp-Method on notifications: add notifications/initialized to expectedMethods so clients are checked for header on notifications - Boolean true: add debug=true param to complement verbose=false - Leading-space-only and trailing-space-only: separate checks for Base64 encoding when only one edge has whitespace - Internal spaces only: verify plain ASCII (no Base64) for values like 'us west 1' that have spaces only in the middle - CRLF string: add line1\r\nline2 test (complements existing \n test) - Leading tab: add \tindented test for tab-triggered Base64 encoding - Server rejects invalid Mcp-Param chars: mark as excluded in YAML since HTTP itself prevents these characters in headers Register all new checks in sep-2243.yaml. * Remove case-insensitive =?base64? prefix test The spec states header values are case-sensitive (RFC 9110). The =?base64? prefix is part of the header value, not the header name, so it should be matched case-sensitively. This was identified as a bug in the conformance tests per feedback on SEP-2243. Changes: - Remove server-accepts-case-insensitive-base64 check from server validation scenario - Change base64 prefix regex from case-insensitive (/i) to case-sensitive in client header validation - Remove corresponding entry from sep-2243.yaml * Remove nested x-mcp-header rejection test The spec constrains x-mcp-header to primitive types but does not restrict it to top-level properties only. A nested string property with x-mcp-header is valid per the spec. This was confirmed by mikekistler in SEP-2243 PR discussion. Removes invalid_nested_header from the invalid tool definitions list and its corresponding sep-2243.yaml entry. * Use numeric comparison for number header values Compare number header values numerically instead of as strings to accommodate cross-SDK floating point representation differences (e.g., '42' vs '42.0'). For integers, exact numeric match is required. For decimals, a tolerance of 1e-9 is allowed. See SEP-2243 discussion on number precision. * client/http-standard-headers: SKIPPED (not FAILURE) for unexercised methods; make getChecks() idempotent A client that never calls prompts/list isn't violating SEP-2243 — the spec sentence is 'The client MUST include the standard MCP request headers on each POST request', and a request that was never sent can't fail it. The Mcp-Method requirement is already proven by initialize/tools/list etc., so there's nothing unique to prompts/list. SKIPPED keeps the gap visible without a false red. Separately: getChecks() was pushing into this.checks on every call. The runner may call it more than once (e.g. progress + final report); that produced duplicate rows. Build a fresh array per call instead. * client/http-standard-headers: de-dup guard in checkMcpNameHeader checkMcpMethodHeader already has 'if (this.methodHeaderChecks.has(method)) return'. checkMcpNameHeader was missing the equivalent. The test server advertises two tools and two resources; a client that calls both produces two 'client-mcp-name-header-tools-call' / '...-resources-read' rows. * client/http-standard-headers: replace -> /\//g for slug generation String.replace with a string arg only replaces the first occurrence. 'notifications/initialized' is fine (one slash) but a method like 'notifications/resources/updated' would yield 'notifications-resources/updated' in the check id. (replaceAll isn't in this project's TS lib target.) * client/http-custom-headers: base64 regex (.+) -> (.*) so empty string round-trips The spec doesn't forbid base64-encoding values that don't strictly need it. A client that always wraps would send '=?base64??=' for empty_val: ''. The '.+' wouldn't match, so the harness would compare '=?base64??=' === '' literally and FAIL a compliant client. '(.*)' lets the empty payload decode to '' and match. * client/http-custom-headers: emit SUCCESS for no-mirror-unannotated This check only pushed on FAILURE; a correct client produced no row at all, so the pass was invisible in reports and couldn't be counted toward coverage. Same-slug SUCCESS/FAILURE pair is the repo convention. * client/http-custom-headers: drop redundant optional-present check checkParamHeader('Verbose', ...) already pushes a FAILURE when the header is missing. The explicit 'optional-present' block re-tests the same condition under a second check id, doubling the row. * server/http-standard-headers: defaultArgs must use real number/boolean, not strings defaultArgs feeds the JSON-RPC body. With Record + '0'/'false', a server that validates inputSchema rejects on type mismatch — which is also a 400. Every header-rejection check then false-PASSes (server rejected for the wrong reason), and server-accepts-valid-base64 false-FAILs a compliant server because it never gets past schema validation. Also handle 'integer' (JSON Schema distinguishes it from 'number'). * server/http-standard-headers: createAcceptanceCheck must also assert no JSON-RPC error in body A server can return HTTP 200 with {error: {code: -32001, ...}} in the body. Status-only acceptance lets that through as a pass. * use DRAFT_PROTOCOL_VERSION constant instead of 'DRAFT-2026-v1' literal The literal will rot when the draft cycle rolls over. types.ts already exports DRAFT_PROTOCOL_VERSION for this. * sep-2243.yaml: spec-first rewrite + move to src/seps/ Regenerated from the SEP-2243 spec diff (transports.mdx + tools.mdx) rather than from the scenario implementations: 21 check rows + 4 excluded vs the previous 46 + 2. Differences: - one check id per spec sentence (test variants go in 'details', not new ids) - 'text:' quotes the spec sentence verbatim instead of paraphrasing - '-reject-status' (MUST 400) split from '-reject-error-code' (SHOULD -32001 for standard headers, MUST for custom) - rows with no spec backing dropped (whitespace, base64-padding/chars, prefix/ suffix-literal, control-char-name) - two more SHOULD excludes (log-warning, no-sensitive-params) - check ids use sep-2243- convention - check: key first, excludes grouped at bottom (matches sep-2164) Moved to src/seps/ to match #272 layout (this branch predates it; will reconcile cleanly on rebase). * add negative test for HttpStandardHeadersScenario Self-contained vitest that POSTs initialize with and without Mcp-Method, asserts FAILURE/SUCCESS on the pinned check id, and proves getChecks() is idempotent. No example client/server file needed for this one because the scenario *is* the server — the test crafts the bad request directly. This is the negative-test half of AGENTS.md §Testing your scenario; the everything-client passing-example half is still TODO. * server/http-standard-headers: severity fixes for whitespace + malformed-base64 tests These five tests assert behavior the SEP-2243 text doesn't pin down. Kept because they're useful consistency signals across SDKs, but adjusted so they don't FAIL spec-compliant servers: - server-accepts-whitespace-header-value: this IS a MUST, but per RFC 9110 §5.5 (field parsing MUST exclude OWS), not SEP-2243. Kept as FAILURE, specReference now cites RFC 9110. - server-rejects-invalid-base64-padding / -chars: downgraded to INFO. SEP-2243 says only 'MUST decode them accordingly' without picking RFC 4648 strict vs lenient. Node's Buffer.from() accepts unpadded/dirty input and decodes to a matching value (server accepts); .NET's Convert.FromBase64String throws (server rejects). Either is currently compliant. WARNING would force Tier-1 SDKs into non-default strict decoding (#245); INFO records the behavior without prescribing it, so cross-SDK divergence is visible without tier impact. - server-literal-missing-base64-prefix / -suffix: unchanged. The wrapper syntax is '=?base64?{x}?=' complete; treating partial wrappers as literal is the natural reading. Plumbing: createRejectionCheck gains an optional failSeverity param; testBase64Case gains a 'reject-info' mode. (This is also the hook for the larger 400-MUST / -32001-SHOULD split that still needs doing.) * server/http-standard-headers: split rejection check into status (MUST) + error-code (SHOULD/MUST) SEP-2243 §Server Validation: 'servers MUST return HTTP status 400 ... and SHOULD include a JSON-RPC error response using ... -32001'. So a server that returns 400 with -32600 (or no error body) is compliant for standard headers. The single conflated check FAILed it. For custom headers (§Server Behavior for Custom Headers) -32001 IS a MUST, so both halves stay FAILURE there. createRejectionCheck → createRejectionChecks returning two rows: '' for the 400 status and '-error-code' for -32001. testCase (standard) sets errorCodeSeverity=WARNING; testBase64Case/testMissingCustomHeader (custom) keep FAILURE; the two malformed-base64 INFO probes set both halves to INFO. * rename check ids to sep-2243-* prefix; kebab-case throughout Aligns with the sep-2243.yaml traceability ids and the repo convention (sep-2164-*, sep-2207-*). Variant suffixes are kept (per-method, per-tool) so per-case visibility isn't lost; the YAML's row id is a prefix of the emitted ids, which is fine — extra ids not in the YAML are allowed. Also fixes the mixed hyphen/underscore in reject-invalid-tool ids (toolName has underscores; replace to hyphens). * client: extract BaseHttpScenario to client/http-base.ts; both client scenarios extend it http-standard-headers.ts had its own copy of the start/stop/handleRequest boilerplate (~100 lines) that http-custom-headers.ts already abstracted as BaseHttpScenario. Moved the abstract class to a shared file; both scenario files now import it. HttpStandardHeadersScenario implements handlePost() instead of handleRequest(); its handleInitialize() uses the base sendInitialize() with the resources/prompts capability flags it needs. Net: 465 → 361 lines for http-standard-headers.ts; the third inline copy of the same HTTP-server scaffold is gone. * use req.setEncoding('utf8') instead of per-chunk Buffer.toString() Per-chunk .toString() corrupts a multi-byte UTF-8 char that straddles a TCP chunk boundary (e.g. 0xC3 | 0xBC decodes to two replacement chars instead of 'ü'). The custom-headers scenario sends '日本語'/'naïve' in the body, so this is reachable in principle. setEncoding('utf8') makes 'data' emit strings with boundary handling done by Node's StringDecoder. Fixed in both places: BaseHttpScenario.handleRequest (request body) and sendRawRequest (response body). * fix: migrate SEP-2243 scenarios from specVersions to source (introducedIn/removedIn) The introducedIn/removedIn refactor (#265) replaced the specVersions array with a source property on all scenario interfaces. The SEP-2243 scenarios still used the old specVersions field, causing matchesSpecVersion() to receive undefined and crash in spec-version.test.ts. Replace specVersions with source = { introducedIn: DRAFT_PROTOCOL_VERSION } in BaseHttpScenario and the two server ClientScenario classes. Remove the now-unused SpecVersion imports. * server/http-standard-headers: malformed-base64 padding/chars back to FAILURE Per discussion on #259: the SEP-2243 conformance-test-case table is the approved source of truth and lists these as reject cases, so they're FAILURE-level even though the spec body itself only says 'MUST decode them accordingly'. SDKs whose stdlib base64 is lenient (Node, browsers) will need to validate before decoding; if that's burdensome we'll revisit. Removes the now-unused 'reject-info' mode and statusSeverity/INFO plumbing introduced for these two probes. --------- Co-authored-by: Tarek Mahmoud Sayed Co-authored-by: Tarek Mahmoud Sayed Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Co-authored-by: Paul Carleton --- package.json | 3 +- src/scenarios/client/http-base.ts | 156 +++ src/scenarios/client/http-custom-headers.ts | 879 +++++++++++++++++ .../client/http-standard-headers.test.ts | 76 ++ src/scenarios/client/http-standard-headers.ts | 356 +++++++ src/scenarios/index.ts | 32 +- src/scenarios/server/http-standard-headers.ts | 933 ++++++++++++++++++ src/seps/sep-2243.yaml | 59 ++ 8 files changed, 2490 insertions(+), 4 deletions(-) create mode 100644 src/scenarios/client/http-base.ts create mode 100644 src/scenarios/client/http-custom-headers.ts create mode 100644 src/scenarios/client/http-standard-headers.test.ts create mode 100644 src/scenarios/client/http-standard-headers.ts create mode 100644 src/scenarios/server/http-standard-headers.ts create mode 100644 src/seps/sep-2243.yaml diff --git a/package.json b/package.json index b28437f..79535b0 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "tier-check": "node dist/index.js tier-check", "check": "npm run typecheck && npm run lint", "typecheck": "tsgo --noEmit", - "prepack": "npm run build" + "prepack": "npm run build", + "prepare": "npm run build" }, "files": [ "dist" diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts new file mode 100644 index 0000000..06c2afa --- /dev/null +++ b/src/scenarios/client/http-base.ts @@ -0,0 +1,156 @@ +/** + * 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'; + +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(): 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); + 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)); + } + + protected sendInitialize( + res: http.ServerResponse, + request: any, + capabilities: object = { tools: {} } + ): void { + this.sendJson(res, { + jsonrpc: '2.0', + id: request.id, + result: { + 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, + result: {} + }); + } +} diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts new file mode 100644 index 0000000..1df5cbe --- /dev/null +++ b/src/scenarios/client/http-custom-headers.ts @@ -0,0 +1,879 @@ +/** + * 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' +}; + +/** + * 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') { + 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') { + 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(): Promise { + const urls = await super.start(); + // 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[] { + if (!this.toolCallReceived) { + this.checks.push({ + id: 'sep-2243-param-header-tool-call-gate', + name: 'ClientCustomHeaderToolCall', + description: 'Client calls the tool with x-mcp-header annotations', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Client did not send a tools/call request for test_custom_headers.', + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + } + if (!this.nullToolCallReceived) { + 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: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.', + 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: { + 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: 'number', + 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 numeric value', + 'x-mcp-header': 'FloatVal' + }, + 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: 'number', + 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; + + // Check Mcp-Param-Region header (plain ASCII string) + this.checkParamHeader(req, 'Region', args.region, 'string'); + + // Check Mcp-Param-Priority header (integer number) + this.checkParamHeader(req, 'Priority', args.priority, 'number'); + + // 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'); + } + + // Check Mcp-Param-FloatVal header (floating point number) + if (args.float_val !== undefined && args.float_val !== null) { + this.checkParamHeader(req, 'FloatVal', args.float_val, 'number'); + } + + // 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 + const queryHeader = req.headers['mcp-param-query'] as string | undefined; + this.checks.push({ + id: 'sep-2243-no-mirror-unannotated', + 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: { + 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); + } + } + + this.checks.push({ + id: `sep-2243-param-header-${headerName.toLowerCase()}`, + 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: + typeof bodyValue === 'string' && + needsBase64Encoding(String(bodyValue)) + } + }); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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-keep-valid-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 + const invalidTools = [ + 'invalid_empty_header', + 'invalid_object_header', + 'invalid_array_header', + 'invalid_null_header', + 'invalid_duplicate_same_case', + 'invalid_duplicate_diff_case', + 'invalid_space_in_name', + 'invalid_colon_in_name', + 'invalid_non_ascii_name', + 'invalid_control_char_name' + ]; + + for (const toolName of invalidTools) { + const called = this.calledTools.has(toolName); + this.checks.push({ + id: `sep-2243-reject-invalid-tool-${toolName.replace(/_/g, '-')}`, + 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: { + 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: 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: { + 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..2d4c303 --- /dev/null +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -0,0 +1,76 @@ +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: 'DRAFT-2026-v1', + clientInfo: { name: 'neg-test', version: '0' }, + capabilities: {} + } + }) + }); + } + + it('FAILs sep-2243-mcp-method-header-initialize when Mcp-Method is missing', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(); + try { + await postInitialize(serverUrl, {}); // no Mcp-Method header + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === 'sep-2243-mcp-method-header-initialize' + ); + expect(check?.status).toBe('FAILURE'); + } finally { + await scenario.stop(); + } + }); + + it('SUCCEEDs sep-2243-mcp-method-header-initialize when Mcp-Method matches', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(); + try { + await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); + const checks = scenario.getChecks(); + const check = checks.find( + (c) => c.id === 'sep-2243-mcp-method-header-initialize' + ); + expect(check?.status).toBe('SUCCESS'); + } finally { + await scenario.stop(); + } + }); + + it('getChecks() is idempotent', async () => { + const scenario = new HttpStandardHeadersScenario(); + const { serverUrl } = await scenario.start(); + 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..b3efd05 --- /dev/null +++ b/src/scenarios/client/http-standard-headers.ts @@ -0,0 +1,356 @@ +/** + * 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-mcp-method-header-${method.replace(/\//g, '-')}`, + 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-mcp-name-header-${method.replace(/\//g, '-')}`, + 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-mcp-method-header-${method.replace(/\//g, '-')}`, + 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-mcp-name-header-${method.replace(/\//g, '-')}`, + 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 + } + }); + } + + private handleInitialize(res: http.ServerResponse, request: any): void { + this.sendInitialize(res, request, { + tools: {}, + resources: {}, + prompts: {} + }); + } + + 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: { + 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: { + 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: { + 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: { + 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: { + 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: { + messages: [] + } + }) + ); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 396d3bc..a46604f 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -64,6 +64,11 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { + HttpHeaderValidationScenario, + HttpCustomHeaderServerValidationScenario +} from './server/http-standard-headers'; + import { authScenariosList, backcompatScenariosList, @@ -73,6 +78,12 @@ import { import { listMetadataScenarios } from './client/auth/discovery-metadata'; import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata'; +import { HttpStandardHeadersScenario } from './client/http-standard-headers'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario +} from './client/http-custom-headers'; + // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ // JSON Schema 2020-12 (SEP-1613) @@ -82,7 +93,13 @@ 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, -32001 error code) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario() ]; // All client scenarios @@ -140,7 +157,11 @@ const allClientScenariosList: ClientScenario[] = [ new PromptsGetWithImageScenario(), // Security scenarios - new DNSRebindingProtectionScenario() + new DNSRebindingProtectionScenario(), + + // HTTP Standardization scenarios (SEP-2243) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario() ]; // Active client scenarios (excludes pending) @@ -183,7 +204,12 @@ const scenariosList: Scenario[] = [ ...authScenariosList, ...backcompatScenariosList, ...draftScenariosList, - ...extensionScenariosList + ...extensionScenariosList, + + // HTTP Standardization scenarios (SEP-2243) + new HttpStandardHeadersScenario(), + new HttpCustomHeadersScenario(), + new HttpInvalidToolHeadersScenario() ]; // Core scenarios (tier 1 requirements) diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts new file mode 100644 index 0000000..06ca23b --- /dev/null +++ b/src/scenarios/server/http-standard-headers.ts @@ -0,0 +1,933 @@ +/** + * 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 -32001 (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 { connectToServer } from './client-helper'; + +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 = -32001; + +/** + * 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 -32001 JSON-RPC error code. Per SEP-2243 §Server Validation, 400 is MUST + * but -32001 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. + */ +function createRejectionChecks( + id: 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, + 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: `${id}-error-code`, + name: `${name}ErrorCode`, + description: `${description} — uses JSON-RPC error code -32001 (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 -32001 (HeaderMismatch)`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + let sessionId: string | null = null; + + try { + // Establish a session via normal SDK initialization + const connection = await connectToServer(serverUrl); + const toolsResult = await connection.client.listTools(); + await connection.close(); + + // Get a fresh session for raw requests + const initResponse = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: DRAFT_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'conformance-test-raw-client', + version: '1.0.0' + } + } + }, + { 'Mcp-Method': 'initialize' } + ); + + if (initResponse.status === 200) { + const rawSid = initResponse.headers['mcp-session-id']; + sessionId = (Array.isArray(rawSid) ? rawSid[0] : rawSid) || null; + const notifHeaders: Record = { + 'Mcp-Method': 'notifications/initialized' + }; + if (sessionId) notifHeaders['mcp-session-id'] = sessionId; + await sendRawRequest( + serverUrl, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + notifHeaders + ); + } + + const baseHeaders: Record = { + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }; + if (sessionId) baseHeaders['mcp-session-id'] = sessionId; + + let idCounter = 100; + const nextId = () => idCounter++; + + // --- Header/Body Mismatch Tests --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'reject', + 'sep-2243-server-rejects-mismatched-method-header', + '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', + 'sep-2243-server-rejects-missing-method-header', + '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', + 'sep-2243-server-rejects-mismatched-name-header', + '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', + 'sep-2243-server-rejects-missing-name-header', + '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' + } + ); + } + + // --- Case Sensitivity Tests --- + + await this.testCase( + checks, + serverUrl, + baseHeaders, + nextId, + 'accept', + 'sep-2243-server-accepts-lowercase-header-name', + '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-server-accepts-uppercase-header-name', + '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', + 'sep-2243-server-rejects-case-mismatch-value', + '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 { + const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id }; + const response = await sendRawRequest(serverUrl, requestBody, { + ...baseHeaders, + ...extraHeaders + }); + if (expectation === 'reject') { + // Standard-header rejection: 400 is MUST, -32001 is SHOULD. + checks.push( + ...createRejectionChecks( + checkId, + 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(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + let sessionId: string | null = null; + + try { + const connection = await connectToServer(serverUrl); + const toolsResult = await connection.client.listTools(); + await connection.close(); + + // 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({ + id: 'sep-2243-server-no-xmcp-tool', + name: 'HttpCustomHeaderServerNoTool', + description: + 'Server has no tools with x-mcp-header annotations to test', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REFERENCE_CUSTOM], + details: { + reason: + 'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.' + } + }); + return checks; + } + + // Get a fresh session for raw requests + const initResponse = await sendRawRequest( + serverUrl, + { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: DRAFT_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'conformance-test-base64-client', + version: '1.0.0' + } + } + }, + { 'Mcp-Method': 'initialize' } + ); + + if (initResponse.status === 200) { + const rawSid2 = initResponse.headers['mcp-session-id']; + sessionId = (Array.isArray(rawSid2) ? rawSid2[0] : rawSid2) || null; + const notifHeaders: Record = { + 'Mcp-Method': 'notifications/initialized' + }; + if (sessionId) notifHeaders['mcp-session-id'] = sessionId; + await sendRawRequest( + serverUrl, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + notifHeaders + ); + } + + const baseHeaders: Record = { + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }; + if (sessionId) baseHeaders['mcp-session-id'] = sessionId; + + // 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({ + id: 'sep-2243-server-no-string-param', + name: 'HttpCustomHeaderServerNoStringParam', + description: + 'Server has no string-typed x-mcp-header parameter to test', + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REFERENCE_CUSTOM] + }); + 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-accepts-valid-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-rejects-invalid-base64-padding', + '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-rejects-invalid-base64-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-literal-missing-base64-prefix', + '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-literal-missing-base64-suffix', + '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] + }); + } + + return checks; + } + + 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', + params: { + 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 -32001 are MUST per + // §Server Behavior for Custom Headers. + checks.push( + ...createRejectionChecks( + checkId, + 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', + params: { + 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 -32001 are MUST. + checks.push( + ...createRejectionChecks( + 'sep-2243-server-rejects-missing-custom-header', + '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-rejects-missing-custom-header', + 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/seps/sep-2243.yaml b/src/seps/sep-2243.yaml new file mode 100644 index 0000000..e981da0 --- /dev/null +++ b/src/seps/sep-2243.yaml @@ -0,0 +1,59 @@ +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-mismatch + text: 'Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body.' + - check: sep-2243-server-reject-status + text: 'When rejecting a request due to header validation failure, servers MUST return HTTP status 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 -32001.' + - 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 (number, string, or boolean).' + 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: number 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 -32001 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.' From fee585cf4503816e616f10b967b8139924d4c5c3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 15 May 2026 14:38:23 +0100 Subject: [PATCH 119/226] docs: add review-scenario skill for SEP scenario PR review (#276) --- .claude/skills/review-scenario/SKILL.md | 53 +++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 54 insertions(+) create mode 100644 .claude/skills/review-scenario/SKILL.md 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/.gitignore b/.gitignore index bd9f0f5..e9d9434 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ lefthook-local.yml dist/ .vscode/ .idea/ +.claude/settings.local.json From 5c1b815cd1e03ee3daa435d5755bbc728dea5397 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Wed, 20 May 2026 01:40:52 +0530 Subject: [PATCH 120/226] feat: add client conformance tests for SEP-2575 (#270) * feat: merge latest request-metadata scenario stacked branch * test: implement optional capability conditional checks and simulated negotiation retry --- .../clients/typescript/everything-client.ts | 116 ++++++++ src/scenarios/client/request-metadata.test.ts | 236 +++++++++++++++ src/scenarios/client/request-metadata.ts | 272 ++++++++++++++++++ src/scenarios/index.ts | 2 + src/seps/sep-2575.yaml | 104 +++++++ 5 files changed, 730 insertions(+) create mode 100644 src/scenarios/client/request-metadata.test.ts create mode 100644 src/scenarios/client/request-metadata.ts create mode 100644 src/seps/sep-2575.yaml diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 48c107e..650cbf6 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -87,6 +87,122 @@ async function runBasicClient(serverUrl: string): Promise { registerScenarios(['initialize', 'tools-call'], runBasicClient); +// ============================================================================ +// request-metadata scenario (SEP-2575) +// ============================================================================ + +async function runRequestMetadataClient(serverUrl: string): Promise { + logger.debug('Starting request-metadata client flow...'); + + const meta = { + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + tools: {}, + roots: {}, + sampling: {}, + elicitation: {} + } + }; + + let activeVersion = 'DRAFT-2026-v1'; + + 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(); + if (errorResult.error?.code === -32001) { + logger.debug( + 'Received UnsupportedProtocolVersionError, starting negotiation...' + ); + const serverSupported: string[] = + errorResult.error.data?.supported || []; + const clientSupported = ['DRAFT-2026-v1']; + 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 // ============================================================================ diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts new file mode 100644 index 0000000..41ba4c4 --- /dev/null +++ b/src/scenarios/client/request-metadata.test.ts @@ -0,0 +1,236 @@ +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'; + +// 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': 'DRAFT-2026-v1', + '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': 'DRAFT-2026-v1' + }, + 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 === -32001) { + 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': 'DRAFT-2026-v1' + }, + 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'); + }); +}); + +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..af1fde0 --- /dev/null +++ b/src/scenarios/client/request-metadata.ts @@ -0,0 +1,272 @@ +import http from 'http'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; + +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; + + async start(): Promise { + this.hasSimulatedRejection = false; + this.checks = []; + 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[] { + return this.checks; + } + + private addOrUpdateCheck(check: ConformanceCheck): void { + const index = this.checks.findIndex((c) => c.id === check.id); + if (index !== -1) { + this.checks[index] = check; + } else { + this.checks.push(check); + } + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + const request = JSON.parse(body); + + // 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, + // clientInfo, clientCapabilities." + const hasClientInfo = meta?.['io.modelcontextprotocol/clientInfo']; + const hasCapabilities = + meta?.['io.modelcontextprotocol/clientCapabilities']; + const metaIsValid = metaVersion && hasClientInfo && hasCapabilities; + + this.addOrUpdateCheck({ + id: 'sep-2575-client-populates-meta', + name: 'ClientPopulatesMeta', + description: + 'Client populates _meta on every request with all three required fields', + 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 } + }); + + // 3. "The header value MUST match the io.modelcontextprotocol/protocolVersion + // field carried in the request body's _meta." Only meaningful when both + // are present; absence is already covered by the two checks above. + if (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: 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: { + code: -32001, + message: 'Unsupported protocol version', + data: { + supported: [DRAFT_PROTOCOL_VERSION] + } + } + }) + ); + 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: { + 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 })); + }); + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index a46604f..4b812d9 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -13,6 +13,7 @@ import { InitializeScenario } from './client/initialize'; 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 all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; @@ -201,6 +202,7 @@ const scenariosList: Scenario[] = [ new ToolsCallScenario(), new ElicitationClientDefaultsScenario(), new SSERetryScenario(), + new RequestMetadataScenario(), ...authScenariosList, ...backcompatScenariosList, ...draftScenariosList, diff --git a/src/seps/sep-2575.yaml b/src/seps/sep-2575.yaml new file mode 100644 index 0000000..e48b1e2 --- /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 (-32003).' + 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-stateless-no-prior-context + 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).' + - check: sep-2575-server-stateless-no-connection-reuse-required + text: 'Servers MUST NOT require that a client reuse the same connection to perform related operations.' + - 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-server-disconnect-is-cancel + text: 'Closing the SSE response stream MUST be treated by the server as cancellation of that request.' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1 + - check: sep-2575-http-server-stops-on-cancel + text: 'The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].' + url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-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: '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 From 70f7ba00046b7eb9c9152e6439dea41b859514e4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 May 2026 21:48:31 +0100 Subject: [PATCH 121/226] fix(sep-2243): collapse check IDs to one-per-requirement (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sep-2243 scenario check IDs drifted from the requirement-traceability yaml in #259 — the code emitted one ID per test case while the yaml declares one per normative requirement, so the IDs no longer matched. Rename the emitted IDs so a check ID maps to a single MUST/SHOULD and is emitted once per case (the per-case detail moves to name/description), matching the repo's existing 'same id, vary status/message' convention: - mcp-method-header-* / mcp-name-header-* -> client-includes-standard-headers - reject-invalid-tool-* / keep-valid-tool -> client-reject-invalid-tool - server-accepts-{lower,upper}case-name -> header-name-case-insensitive - server reject status checks (mismatch/missing/case x method/name) -> server-reject-invalid-headers - their error-code variants -> server-reject-error-code Merge the yaml's server-reject-mismatch + server-reject-status into one server-reject-invalid-headers MUST (HTTP 400 on a header-validation failure); keep server-reject-error-code as the SHOULD. Base64/custom-header rejection checks keep their own param-validation IDs (out of scope here). Gates and the whitespace-acceptance check keep their scenario-matching names. No behavior change — only check IDs, descriptions, and the yaml. --- src/scenarios/client/http-custom-headers.ts | 4 +- .../client/http-standard-headers.test.ts | 13 ++++-- src/scenarios/client/http-standard-headers.ts | 8 ++-- src/scenarios/server/http-standard-headers.ts | 40 ++++++++++++++----- src/seps/sep-2243.yaml | 6 +-- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index 1df5cbe..aa363a5 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -626,7 +626,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { // Check that valid_tool WAS called — proves client kept valid tools const validToolCalled = this.calledTools.has('valid_tool'); this.checks.push({ - id: 'sep-2243-keep-valid-tool', + id: 'sep-2243-client-reject-invalid-tool', name: 'ClientKeepsValidTool', description: 'Client MUST keep valid tools while excluding invalid ones', status: validToolCalled ? 'SUCCESS' : 'FAILURE', @@ -654,7 +654,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { for (const toolName of invalidTools) { const called = this.calledTools.has(toolName); this.checks.push({ - id: `sep-2243-reject-invalid-tool-${toolName.replace(/_/g, '-')}`, + id: 'sep-2243-client-reject-invalid-tool', name: `ClientRejectsInvalidTool_${toolName}`, description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`, status: called ? 'FAILURE' : 'SUCCESS', diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts index 2d4c303..8c5ff6a 100644 --- a/src/scenarios/client/http-standard-headers.test.ts +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -31,14 +31,19 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { }); } - it('FAILs sep-2243-mcp-method-header-initialize when Mcp-Method is missing', async () => { + // 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(); try { await postInitialize(serverUrl, {}); // no Mcp-Method header const checks = scenario.getChecks(); const check = checks.find( - (c) => c.id === 'sep-2243-mcp-method-header-initialize' + (c) => c.id === COARSE_ID && c.name === INIT_METHOD_NAME ); expect(check?.status).toBe('FAILURE'); } finally { @@ -46,14 +51,14 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { } }); - it('SUCCEEDs sep-2243-mcp-method-header-initialize when Mcp-Method matches', async () => { + it('SUCCEEDs the initialize Mcp-Method emission when Mcp-Method matches', async () => { const scenario = new HttpStandardHeadersScenario(); const { serverUrl } = await scenario.start(); try { await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); const checks = scenario.getChecks(); const check = checks.find( - (c) => c.id === 'sep-2243-mcp-method-header-initialize' + (c) => c.id === COARSE_ID && c.name === INIT_METHOD_NAME ); expect(check?.status).toBe('SUCCESS'); } finally { diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts index b3efd05..fb527d0 100644 --- a/src/scenarios/client/http-standard-headers.ts +++ b/src/scenarios/client/http-standard-headers.ts @@ -53,7 +53,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { for (const method of expectedMethods) { if (!this.methodHeaderChecks.has(method)) { result.push({ - id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`, + id: 'sep-2243-client-includes-standard-headers', name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, description: `Client sends correct Mcp-Method header on ${method} request`, status: 'SKIPPED', @@ -68,7 +68,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { for (const method of expectedNameMethods) { if (!this.nameHeaderChecks.has(method)) { result.push({ - id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`, + id: 'sep-2243-client-includes-standard-headers', name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, description: `Client sends correct Mcp-Name header on ${method} request`, status: 'SKIPPED', @@ -141,7 +141,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { this.methodHeaderChecks.set(method, errors.length === 0); this.checks.push({ - id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`, + 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', @@ -186,7 +186,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { this.nameHeaderChecks.set(method, errors.length === 0); this.checks.push({ - id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`, + 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', diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 06ca23b..888d419 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -53,6 +53,14 @@ const SPEC_REFERENCE_CUSTOM = { const HEADER_MISMATCH_ERROR_CODE = -32001; +// 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. (Custom-header +// /Base64 rejections map to different requirements and keep their own IDs.) +const REJECT_STATUS_CHECK_ID = 'sep-2243-server-reject-invalid-headers'; +const REJECT_ERROR_CODE_CHECK_ID = 'sep-2243-server-reject-error-code'; + /** * Helper to send a raw HTTP POST request with custom headers. * Uses Node.js http.request to preserve exact header casing and values, @@ -119,9 +127,16 @@ async function sendRawRequest( * but -32001 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 + * so all standard-header cases collapse onto one requirement, while custom-header + * /Base64 callers pass their own per-case ids. The per-case `name`/`description` + * distinguish which rejection case was exercised. */ function createRejectionChecks( - id: string, + statusId: string, + errorCodeId: string, name: string, description: string, response: { status: number; body: any }, @@ -141,7 +156,7 @@ function createRejectionChecks( return [ { - id, + id: statusId, name, description, status: statusOk ? 'SUCCESS' : 'FAILURE', @@ -153,7 +168,7 @@ function createRejectionChecks( details: fullDetails }, { - id: `${id}-error-code`, + id: errorCodeId, name: `${name}ErrorCode`, description: `${description} — uses JSON-RPC error code -32001 (HeaderMismatch)`, status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity, @@ -397,7 +412,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'accept', - 'sep-2243-server-accepts-lowercase-header-name', + 'sep-2243-header-name-case-insensitive', 'ServerAcceptsLowercaseHeaderName', 'Server MUST accept lowercase header name (mcp-method)', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -412,7 +427,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'accept', - 'sep-2243-server-accepts-uppercase-header-name', + 'sep-2243-header-name-case-insensitive', 'ServerAcceptsUppercaseHeaderName', 'Server MUST accept uppercase header name (MCP-METHOD)', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -471,10 +486,13 @@ export class HttpHeaderValidationScenario implements ClientScenario { ...extraHeaders }); if (expectation === 'reject') { - // Standard-header rejection: 400 is MUST, -32001 is SHOULD. + // Standard-header rejection: 400 is MUST, -32001 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( - checkId, + REJECT_STATUS_CHECK_ID, + REJECT_ERROR_CODE_CHECK_ID, checkName, description, response, @@ -840,10 +858,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); } else { // Custom-header rejection: both 400 and -32001 are MUST per - // §Server Behavior for Custom Headers. + // §Server Behavior for Custom Headers. These map to param-validation + // requirements, so they keep their per-case ids (status + -error-code). checks.push( ...createRejectionChecks( checkId, + `${checkId}-error-code`, checkName, description, response, @@ -899,10 +919,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } ); - // Custom-header rejection: both 400 and -32001 are MUST. + // Custom-header rejection: both 400 and -32001 are MUST. Keeps its own + // per-case ids (status + -error-code). checks.push( ...createRejectionChecks( 'sep-2243-server-rejects-missing-custom-header', + 'sep-2243-server-rejects-missing-custom-header-error-code', 'ServerRejectsMissingCustomHeader', 'Server MUST reject request where custom header is omitted but value is present in body', response, diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml index e981da0..bb7803f 100644 --- a/src/seps/sep-2243.yaml +++ b/src/seps/sep-2243.yaml @@ -5,10 +5,8 @@ requirements: 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-mismatch - text: 'Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body.' - - check: sep-2243-server-reject-status - text: 'When rejecting a request due to header validation failure, servers MUST return HTTP status 400 Bad Request.' + - 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 -32001.' - check: sep-2243-client-supports-custom-headers From cfcecdba2cdde9e885043fd0d34abec9381f3320 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 May 2026 22:08:06 +0100 Subject: [PATCH 122/226] feat: `sdk` subcommand to run local conformance against any SDK ref (#277) * feat: add sdk subcommand to run conformance against any SDK ref (#250) * Revise sdk runner: explicit --mode, KNOWN_SDKS-only config, v1/v2 entries Addresses review feedback on the sdk subcommand: - Require --mode (client|server) and remove "both". Each invocation now tests exactly one side with its own exit code; the old default ran client then server but combined exit codes with ||=, which skipped the server side entirely whenever the client run failed. - Resolve build/run config from KNOWN_SDKS + CLI flags only; drop the conformance.config.yaml loader (no SDK ships one yet). The Zod schema stays as the type for the built-in entries. - Split the typescript entry by major version: typescript-sdk (v2/main, pnpm install + build:all, expected-failures.yaml) and typescript-sdk-v1 (v1.x, npm ci + build, conformance-baseline.yml). An entry may set `repo` (real clone target for an alias) and `defaultRef` (branch used when no @ref is given). parseSdkSpec now leaves ref undefined when omitted so defaultRef can apply; a trailing @ is treated as no ref. - Key the clone cache by ref (/) so different refs of the same repo no longer share one checkout. - Bound the server readiness probe with a per-request AbortSignal timeout so a server that accepts the socket but never responds can't hang past the overall deadline. * fix(sdk-runner): resolve -o to absolute path; add --expected-failures override; replaceAll for safeName --- .gitignore | 1 + AGENTS.md | 1 + README.md | 44 +++++ src/index.ts | 4 + src/sdk-runner/checkout.ts | 126 +++++++++++++ src/sdk-runner/config.ts | 25 +++ src/sdk-runner/index.ts | 293 ++++++++++++++++++++++++++++++ src/sdk-runner/known-sdks.ts | 61 +++++++ src/sdk-runner/sdk-runner.test.ts | 87 +++++++++ vitest.config.ts | 2 +- 10 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 src/sdk-runner/checkout.ts create mode 100644 src/sdk-runner/config.ts create mode 100644 src/sdk-runner/index.ts create mode 100644 src/sdk-runner/known-sdks.ts create mode 100644 src/sdk-runner/sdk-runner.test.ts diff --git a/.gitignore b/.gitignore index e9d9434..2fdd7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .vscode/ .idea/ .claude/settings.local.json +.sdk-under-test/ diff --git a/AGENTS.md b/AGENTS.md index fc864ba..c91484e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,7 @@ Keep scenarios separate when they're genuinely independent features or when they - **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.) diff --git a/README.md b/README.md index b2b5f0e..333bfcb 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,50 @@ Run `npx @modelcontextprotocol/conformance list --server` to see all available s - **resources-\*** - Resource management scenarios - **prompts-\*** - Prompt management scenarios +## Running Against an SDK at a Specific Ref + +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 +``` + +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). Overriding for a one-off ref: + +```bash +npm start -- sdk owner/go-sdk@some-branch \ + --mode client \ + --build-cmd 'go build -tags mcp_go_client_oauth -o ./.conformance-client ./conformance/everything-client' \ + --client-cmd './.conformance-client' +``` + +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): diff --git a/src/index.ts b/src/index.ts index 013f44c..a3431cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ import { } from './expected-failures'; import { createTierCheckCommand } from './tier-check'; import { createNewSepCommand } from './new-sep'; +import { createSdkCommand } from './sdk-runner'; import packageJson from '../package.json'; // Note on naming: `command` refers to which CLI command is calling this. @@ -544,6 +545,9 @@ 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()); + // List scenarios command program .command('list') 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..0945b99 --- /dev/null +++ b/src/sdk-runner/config.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +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() +}); + +export type SdkConfig = z.infer; diff --git a/src/sdk-runner/index.ts b/src/sdk-runner/index.ts new file mode 100644 index 0000000..932c51e --- /dev/null +++ b/src/sdk-runner/index.ts @@ -0,0 +1,293 @@ +import { spawn, ChildProcess } from 'child_process'; +import path from 'path'; +import { Command, Option } from 'commander'; +import { SdkConfig } from './config'; +import { parseSdkSpec, ensureCheckout } from './checkout'; +import { lookupBuiltinConfig, knownSdkNames } from './known-sdks'; + +type Mode = 'client' | 'server'; + +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}` + ); +} + +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`); + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } else { + child.kill('SIGTERM'); + } + } +} + +function passThrough(options: { + scenario?: string; + suite?: string; + timeout?: string; + verbose?: boolean; + output?: string; +}): string[] { + const args: string[] = []; + 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); + 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('--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 builtinConfig: SdkConfig = lookupBuiltinConfig(sdkName) ?? {}; + + // 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. + const expectedFailures = options.expectedFailures + ? path.resolve(options.expectedFailures) + : builtinConfig.expectedFailures + ? 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.suite ?? 'all', + timeout: options.timeout, + verbose: options.verbose, + output + }) + ]; + 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, + suite: options.suite, + verbose: options.verbose, + output + }) + ]; + 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..b6550df --- /dev/null +++ b/src/sdk-runner/known-sdks.ts @@ -0,0 +1,61 @@ +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. + 'typescript-sdk-v1': { + repo: 'typescript-sdk', + defaultRef: 'v1.x', + 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' + }, + 'go-sdk': { + build: 'go build -o ./.conformance-server ./examples/server/conformance', + // Upstream go-sdk has no client conformance fixture yet (see go-sdk#859). + server: { + command: './.conformance-server -http=:3000', + url: 'http://localhost:3000' + } + } +}; + +/** + * 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..c58e993 --- /dev/null +++ b/src/sdk-runner/sdk-runner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { parseSdkSpec } from './checkout'; +import { SdkConfigSchema } from './config'; +import { lookupBuiltinConfig, KNOWN_SDKS } from './known-sdks'; + +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('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('rust-sdk')).toBeNull(); + }); + + 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('bare typescript-sdk (v2) has no defaultRef', () => { + expect(lookupBuiltinConfig('typescript-sdk')?.defaultRef).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(); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 93242b5..b0f36f9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ globals: true, environment: 'node', include: ['**/*.test.ts'], - exclude: ['**/node_modules/**', 'dist'], + exclude: ['**/node_modules/**', 'dist', '.sdk-under-test'], // Run test files sequentially to avoid port conflicts fileParallelism: false, // Increase timeout for server tests in CI From 442ba3b1430651b32d38f88b947fa27bc9dd583e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 May 2026 22:22:25 +0100 Subject: [PATCH 123/226] feat: add SEP conformance traceability manifest (#288) squashed; see PR #288 body --- .github/workflows/traceability.yml | 123 +++++++++ .prettierignore | 5 + AGENTS.md | 22 ++ package.json | 1 + src/index.ts | 4 + src/seps/traceability.json | 409 +++++++++++++++++++++++++++++ src/traceability/index.test.ts | 124 +++++++++ src/traceability/index.ts | 316 ++++++++++++++++++++++ src/traceability/types.ts | 79 ++++++ 9 files changed, 1083 insertions(+) create mode 100644 .github/workflows/traceability.yml create mode 100644 .prettierignore create mode 100644 src/seps/traceability.json create mode 100644 src/traceability/index.test.ts create mode 100644 src/traceability/index.ts create mode 100644 src/traceability/types.ts diff --git a/.github/workflows/traceability.yml b/.github/workflows/traceability.yml new file mode 100644 index 0000000..44c85d9 --- /dev/null +++ b/.github/workflows/traceability.yml @@ -0,0 +1,123 @@ +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 + + - 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). + run: | + node dist/index.js sdk "$SDK_REF" --mode client --suite all -o results + node dist/index.js sdk "$SDK_REF" --mode server --suite all --skip-build -o results + + - 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/.prettierignore b/.prettierignore new file mode 100644 index 0000000..43399fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# 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 diff --git a/AGENTS.md b/AGENTS.md index c91484e..5e915ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,27 @@ 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: @@ -101,3 +122,4 @@ Use the existing CLI runner (`npx @modelcontextprotocol/conformance client|serve - `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/package.json b/package.json index 79535b0..621436f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "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", "check": "npm run typecheck && npm run lint", "typecheck": "tsgo --noEmit", "prepack": "npm run build", diff --git a/src/index.ts b/src/index.ts index a3431cd..a3019f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import { import { createTierCheckCommand } from './tier-check'; import { createNewSepCommand } from './new-sep'; import { createSdkCommand } from './sdk-runner'; +import { createTraceabilityCommand } from './traceability'; import packageJson from '../package.json'; // Note on naming: `command` refers to which CLI command is calling this. @@ -548,6 +549,9 @@ 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') diff --git a/src/seps/traceability.json b/src/seps/traceability.json new file mode 100644 index 0000000..5618dd4 --- /dev/null +++ b/src/seps/traceability.json @@ -0,0 +1,409 @@ +{ + "schemaVersion": 1, + "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", + "source": "typescript-sdk@6f0bf49d", + "seps": { + "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": null, + "specUrl": null, + "requirements": [], + "excluded": [], + "unkeyed": [], + "untracked": [ + "sep-2207-client-metadata-grant-types", + "sep-2207-offline-access-not-requested", + "sep-2207-offline-access-requested" + ], + "summary": { + "tested": 0, + "untested": 0, + "excluded": 0, + "untracked": 3, + "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": "untested", + "text": "MCP clients MUST support this feature [custom headers via x-mcp-header]." + }, + { + "check": "sep-2243-client-mirrors-designated-params", + "status": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "text": "x-mcp-header MUST only be applied to parameters with primitive types (number, string, or boolean).", + "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": "untested", + "text": "Clients MUST encode parameter values before including them in HTTP headers: number 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": "untested", + "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": "untested", + "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": "untested", + "text": "Servers MUST reject requests with a recognized Mcp-Param-{Name} header that contain invalid characters." + }, + { + "check": "sep-2243-server-validate-param-match", + "status": "untested", + "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": "untested", + "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-param-header-tool-call-gate", + "sep-2243-server-accepts-whitespace-header-value", + "sep-2243-server-no-xmcp-tool" + ], + "summary": { + "tested": 6, + "untested": 14, + "excluded": 4, + "untracked": 4, + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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-stateless-no-prior-context", + "status": "untested", + "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)." + }, + { + "check": "sep-2575-server-stateless-no-connection-reuse-required", + "status": "untested", + "text": "Servers MUST NOT require that a client reuse the same connection to perform related operations." + }, + { + "check": "sep-2575-server-unsupported-version-error", + "status": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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-server-disconnect-is-cancel", + "status": "untested", + "text": "Closing the SSE response stream MUST be treated by the server as cancellation of that request.", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1" + }, + { + "check": "sep-2575-http-server-stops-on-cancel", + "status": "untested", + "text": "The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].", + "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1" + }, + { + "check": "sep-2575-http-client-sends-version-header", + "status": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "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": [], + "summary": { + "tested": 0, + "untested": 26, + "excluded": 9, + "untracked": 0, + "unkeyed": 0 + } + } + } +} 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; +} From 91755d652c3189d1c707bcceab4fedb49ff5f655 Mon Sep 17 00:00:00 2001 From: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> Date: Tue, 19 May 2026 14:40:35 -0700 Subject: [PATCH 124/226] feat: add server conformance tests for SEP-2575 (#271) * feat: add server conformance tests for SEP-2575 * refactor and add new checks * Fix stateless scenario source field and report SKIPPED for inapplicable capability checks - Add the required `source` field (DRAFT) so the scenario typechecks and is selectable via --spec-version. Without it the class did not satisfy the ClientScenario interface and s.source was dereferenced by the spec-version filter at list time. - Teach runCheck about a SKIPPED status and use it for the two client-capability checks when the server does not return -32003, instead of reporting a green PASS for a requirement that was never exercised. * Remove unimplemented placeholder checks from stateless server scenario The subscriptions/listen, statelessness-invariant, list-changed and disconnect-is-cancel checks emitted SUCCESS without probing the server, which inflated coverage in the traceability report. Drop them until they have real assertions; the corresponding rows remain declared in src/seps/sep-2575.yaml so traceability shows them as not-yet-covered. Also fix the checkErrorId helper to push under the sep-2575-http-server-error-jsonrpc-id slug so its failure path actually short-circuits the aggregate SUCCESS check at the end of the scenario. --------- Co-authored-by: Paul Carleton --- .../servers/typescript/everything-server.ts | 152 +++++ src/scenarios/index.ts | 2 + src/scenarios/server/stateless.test.ts | 138 ++++ src/scenarios/server/stateless.ts | 597 ++++++++++++++++++ 4 files changed, 889 insertions(+) create mode 100644 src/scenarios/server/stateless.test.ts create mode 100644 src/scenarios/server/stateless.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 91559f8..acce242 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1053,6 +1053,158 @@ app.use( // 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']; + + if (!sessionId && (reqVersion || meta)) { + // Missing Transport Header Validation Check + if (!reqVersion) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { code: -32001, message: 'Missing MCP-Protocol-Version header' } + }); + } + + // Per-Request Metadata Integrity Checks (Fields verification) + if ( + !meta || + !meta['io.modelcontextprotocol/protocolVersion'] || + !meta['io.modelcontextprotocol/clientInfo'] || + !meta['io.modelcontextprotocol/clientCapabilities'] + ) { + return res.status(200).json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'Invalid params: missing _meta or required fields' + } + }); + } + + // Header Mismatch Verification (-32001, HTTP 400) + if (reqVersion !== metaVersion) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32001, + message: 'Mismatched MCP-Protocol-Version header' + } + }); + } + + // Protocol Version Negotiation Matrix (-32602, HTTP 400) + if (metaVersion !== 'DRAFT-2026-v1') { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'UnsupportedProtocolVersionError', + data: { supported: ['DRAFT-2026-v1'] } + } + }); + } + + if (method === 'server/discover') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { + tools: { listChanged: false }, // Explicitly declare capability flags to resolve check assertions + prompts: { listChanged: false } + }, + serverInfo: { name: 'everything-stateless-server', version: '1.0.0' } + } + }); + } + + if (method === 'tools/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + tools: [ + { + name: 'test_missing_capability', + description: 'Test tool requiring sampling', + inputSchema: { type: 'object', properties: {} } + } + ] + } + }); + } + + // Mock fallbacks to answer prompts capability matches safely + if (method === 'prompts/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { prompts: [] } + }); + } + + if (method === 'tools/call') { + const name = params.name; + if (name === 'test_missing_capability') { + const clientCaps = meta['io.modelcontextprotocol/clientCapabilities']; + + // Missing Required Client Capability Check (-32003, HTTP 400) + if (!clientCaps?.sampling) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { + code: -32003, + message: 'MissingRequiredClientCapabilityError', + data: { requiredCapabilities: ['sampling'] } + } + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Success' }] } + }); + } + } + + // 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; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 4b812d9..e198311 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -17,6 +17,7 @@ import { RequestMetadataScenario } from './client/request-metadata'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; +import { ServerStatelessScenario } from './server/stateless'; import { PingScenario, @@ -107,6 +108,7 @@ const pendingClientScenariosList: ClientScenario[] = [ const allClientScenariosList: ClientScenario[] = [ // Lifecycle scenarios new ServerInitializeScenario(), + new ServerStatelessScenario(), // Utilities scenarios new LoggingSetLevelScenario(), diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts new file mode 100644 index 0000000..6153529 --- /dev/null +++ b/src/scenarios/server/stateless.test.ts @@ -0,0 +1,138 @@ +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 || {}; + const responseConfig = await handler(body, headers); + + 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'; + } + + 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: ['DRAFT-2026-v1'], + capabilities: {}, + serverInfo: { name: 'bad-meta-server', version: '1.0.0' } + } + } + }; + } + }); + + const scenario = new ServerStatelessScenario(); + const checks = await scenario.run(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 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(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(mockUrl); + + const negotiationMatchCheck = findCheck( + checks, + 'sep-2575-server-unsupported-version-error' + ); + expect(negotiationMatchCheck?.status).toBe('FAILURE'); + }); +}); diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts new file mode 100644 index 0000000..9c195aa --- /dev/null +++ b/src/scenarios/server/stateless.ts @@ -0,0 +1,597 @@ +/** + * Stateless MCP test scenarios for MCP servers (SEP-2575) + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; + +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, capabilities, and serverInfo metadata. +- \`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\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature. +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 \`-32001 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 \`-32003 MissingRequiredClientCapabilityError\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`. +5. **Methods & Routing Mechanics (3 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.`; + + async run(serverUrl: string): Promise { + 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; skipped?: boolean; details?: any } | void> + | ({ error?: string; skipped?: boolean; details?: any } | void), + fallbackDetails = {} + ) { + try { + const result = await fn(); + const errorMessage = result?.error; + const status = errorMessage + ? '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 + }); + } + } + + // Helper to send raw RPC requests via fetch + const sendRpc = async ( + method: string, + params?: any, + headersOverrides?: Record, + id: string | number | null = 1 + ) => { + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION, + ...headersOverrides + }; + + 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; + try { + data = await res.json(); + } catch { + // Response might not be JSON + } + return { res, data }; + }; + + const validMeta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + '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 + }, + { + slug: 'missing-client-info', + description: + 'Rejects request with _meta missing io.modelcontextprotocol/clientInfo', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': + validMeta['io.modelcontextprotocol/protocolVersion'], + 'io.modelcontextprotocol/clientCapabilities': + validMeta['io.modelcontextprotocol/clientCapabilities'] + } + }, + rpcId: 103 + }, + { + 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) { + await runCheck( + `sep-2575-request-meta-invalid-${testCase.slug}`, + 'RequestMetaInvalid', + testCase.description, + async () => { + const { data } = await sendRpc( + 'server/discover', + testCase.params, + undefined, + testCase.rpcId + ); + checkErrorId(data, testCase.rpcId); + + if (data?.error?.code !== -32602) { + return { + error: `Expected error code -32602, got ${data?.error?.code}`, + details: { fieldIssue: testCase.slug, response: data } + }; + } + return { details: { fieldIssue: testCase.slug, response: data } }; + }, + { fieldIssue: testCase.slug } + ); + } + + // ========================================== + // 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}` }; + if ( + !discoverResult?.supportedVersions || + !discoverResult?.capabilities || + !discoverResult?.serverInfo + ) { + return { + error: 'Missing mandatory fields in discover response setup', + details: { result: discoverResult } + }; + } + return { details: { result: discoverResult } }; + } + ); + + 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 } }; + } + ); + + // 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}` + }; + const { data: toolsData } = await sendRpc( + 'tools/list', + { _meta: validMeta }, + undefined, + 202 + ); + + 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 unsupportedMeta = { + ...validMeta, + 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' + }; + const response301 = await sendRpc( + 'server/discover', + { _meta: unsupportedMeta }, + { 'MCP-Protocol-Version': 'v999.0.0' }, + 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.', + () => { + 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)}` + }; + 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': 'mismatch.version' }, + 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 !== -32001) { + return { + error: `Expected HTTP 400 and JSON-RPC error -32001, 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 === -32003; + + 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 (-32003).', + () => { + if (!res401) + return { + error: + 'Capability checking call sequence timed out or dropped connection' + }; + + if (!serverRequiresCapability) { + // The server didn't return -32003, so this requirement isn't + // exercised for this method. Report SKIPPED rather than a green PASS. + return { + skipped: true, + details: { + note: 'Skipped requirement tracking: Server returned a non-32003 response, indicating it does not require explicit client capability authorization constraints for this method.', + response: data401 + } + }; + } + + // If it DOES return -32003, strictly validate the requirement payload structure + const reqCaps = data401?.error?.data?.requiredCapabilities; + if (!Array.isArray(reqCaps) || !reqCaps.includes('sampling')) { + return { + error: `Server responded with error code -32003 but failed to provide an array containing the expected 'sampling' capability in error.data.requiredCapabilities`, + 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) { + // No -32003 means the HTTP-400 requirement doesn't apply here. + // Report SKIPPED rather than a green PASS. + return { + skipped: true, + details: { + note: 'Skipped status tracking: Server did not return a MissingRequiredClientCapabilityError.', + 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 (3 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 } }; + } + ); + + // Final catchall ensuring JSON-RPC id integrity validation rules ran successfully + if (!checks.some((c) => c.id === 'sep-2575-http-server-error-jsonrpc-id')) { + checks.push({ + id: 'sep-2575-http-server-error-jsonrpc-id', + name: 'HttpServerErrorJsonrpcId', + description: 'All error responses carry the request JSON-RPC id', + status: 'SUCCESS', + timestamp, + specReferences: SPEC_REF + }); + } + + return checks; + } +} From 1ef3cd34bdd41e189ed8f13ee4688ba0b26484f8 Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Tue, 19 May 2026 14:59:31 -0700 Subject: [PATCH 125/226] SEP-2352: authorization-server migration scenario (#286) * Add SEP-2352 authorization-server migration scenario SEP-2352 requires that client credentials are bound to the issuing authorization server: when PRM authorization_servers changes to a new issuer, clients MUST re-register and MUST NOT reuse the previous AS's client credentials. - Traceability yaml: 3 checks, 3 excluded (internal state / UI) - New auth/authorization-server-migration scenario (draft suite): two auth servers; PRM flips from AS1 to AS2 after the first authenticated request; AS2 asserts it received a fresh /register and never saw AS1's client_id at /authorize or /token - ConformanceOAuthProvider gains invalidateCredentials and bindIssuer so the everything-client can key credentials by issuer (passing example) - everything-client adds an issuer-aware handler for this scenario that re-reads PRM on each 401 and rebinds before re-authorizing - auth-test-reuse-credentials negative client + vitest case * Drop application_type from DCR metadata (not in SDK OAuthClientMetadata type) --------- Co-authored-by: Paul Carleton --- .../typescript/auth-test-reuse-credentials.ts | 35 +++ .../clients/typescript/everything-client.ts | 71 ++++++ .../helpers/ConformanceOAuthProvider.ts | 28 +++ .../auth/authorization-server-migration.ts | 212 ++++++++++++++++++ src/scenarios/client/auth/index.test.ts | 22 +- src/scenarios/client/auth/index.ts | 4 +- src/seps/sep-2352.yaml | 17 ++ 7 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 examples/clients/typescript/auth-test-reuse-credentials.ts create mode 100644 src/scenarios/client/auth/authorization-server-migration.ts create mode 100644 src/seps/sep-2352.yaml 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index 650cbf6..64610fe 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -21,6 +21,11 @@ import { } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { ClientConformanceContextSchema } from '../../../src/schemas/context.js'; +import { + auth, + extractWWWAuthenticateParams +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; import { withOAuthRetry, withOAuthRetryWithProvider, @@ -270,6 +275,72 @@ registerScenarios( 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'] + } + ); + + 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); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index aa3aef5..e6864bf 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -12,6 +12,8 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { 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, @@ -92,4 +94,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/src/scenarios/client/auth/authorization-server-migration.ts b/src/scenarios/client/auth/authorization-server-migration.ts new file mode 100644 index 0000000..858bddb --- /dev/null +++ b/src/scenarios/client/auth/authorization-server-migration.ts @@ -0,0 +1,212 @@ +/** + * 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 { 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(): 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(this.checks, this.as1.getUrl, { + tokenVerifier, + onRegistrationRequest: () => ({ + clientId: AS1_CLIENT_ID, + clientSecret: 'as1-client-secret' + }) + }); + await this.as1.start(as1App); + + // ── AS₂ ──────────────────────────────────────────────────────────────── + const as2App = createAuthServer(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( + 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/index.test.ts b/src/scenarios/client/auth/index.test.ts index e18a467..92a7220 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -14,6 +14,7 @@ import { runClient as partialScopesClient } from '../../../../examples/clients/t 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 { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -29,7 +30,10 @@ 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' ]); describe('Client Auth Scenarios', () => { @@ -135,6 +139,22 @@ 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 does not use PKCE', async () => { const runner = new InlineClientRunner(noPkceClient); await runClientAgainstScenario(runner, 'auth/metadata-default', { diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 62d60b5..85b3f3f 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -28,6 +28,7 @@ import { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario } from './offline-access'; +import { AuthorizationServerMigrationScenario } from './authorization-server-migration'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -61,5 +62,6 @@ export const extensionScenariosList: Scenario[] = [ export const draftScenariosList: Scenario[] = [ new ResourceMismatchScenario(), new OfflineAccessScopeScenario(), - new OfflineAccessNotSupportedScenario() + new OfflineAccessNotSupportedScenario(), + new AuthorizationServerMigrationScenario() ]; 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' From 26e09df1543c95a61532e61be9b38c8ea21439a3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 May 2026 23:02:24 +0100 Subject: [PATCH 126/226] ci(traceability): enable corepack so the SDK build can use pnpm (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh run failed with 'pnpm: not found' — the reference SDK's build command (typescript-sdk: pnpm install && pnpm run build:all) needs pnpm on PATH. Add 'corepack enable' to the run job. --- .github/workflows/traceability.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/traceability.yml b/.github/workflows/traceability.yml index 44c85d9..70f211c 100644 --- a/.github/workflows/traceability.yml +++ b/.github/workflows/traceability.yml @@ -42,6 +42,10 @@ jobs: 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 From e5afe404ad847fd514d660e6f75c92cd4ddef3ee Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 19 May 2026 23:06:39 +0100 Subject: [PATCH 127/226] ci(traceability): tolerate SDK conformance failures in the run step (#290) The `sdk` command exits non-zero when the SDK has conformance failures not in its baseline. The traceability manifest only needs the emitted check IDs (written regardless of pass/fail), so a failing SDK must not fail the refresh. Add `|| true`; the existing 'no results produced' guard catches a genuinely broken run. --- .github/workflows/traceability.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/traceability.yml b/.github/workflows/traceability.yml index 70f211c..3195105 100644 --- a/.github/workflows/traceability.yml +++ b/.github/workflows/traceability.yml @@ -52,9 +52,12 @@ jobs: - 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 - node dist/index.js sdk "$SDK_REF" --mode server --suite all --skip-build -o results + 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: | From e6fffa2b3071aa0148bd6940daf7f663f7434ab3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 23:13:57 +0100 Subject: [PATCH 128/226] chore: refresh SEP traceability manifest (typescript-sdk@main) (#291) Co-authored-by: github-actions[bot] --- src/seps/traceability.json | 83 ++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/src/seps/traceability.json b/src/seps/traceability.json index 5618dd4..e4091c0 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", - "source": "typescript-sdk@6f0bf49d", + "source": "typescript-sdk@4e153aef0538", "seps": { "2164": { "yaml": "src/seps/sep-2164.yaml", @@ -198,6 +198,51 @@ "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 + } + }, "2575": { "yaml": "src/seps/sep-2575.yaml", "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/lifecycle", @@ -210,13 +255,13 @@ }, { "check": "sep-2575-server-rejects-undeclared-capability", - "status": "untested", + "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": "untested", + "status": "tested", "text": "On HTTP, the response status MUST be 400 Bad Request [for MissingRequiredClientCapabilityError].", "url": "https://modelcontextprotocol.io/specification/draft/basic/index#meta" }, @@ -238,7 +283,7 @@ }, { "check": "sep-2575-server-unsupported-version-error", - "status": "untested", + "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" }, @@ -250,7 +295,7 @@ }, { "check": "sep-2575-server-implements-discover", - "status": "untested", + "status": "tested", "text": "Servers MUST implement server/discover.", "url": "https://modelcontextprotocol.io/specification/draft/server/discover" }, @@ -286,19 +331,19 @@ }, { "check": "sep-2575-http-server-header-mismatch-400", - "status": "untested", + "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": "untested", + "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": "untested", + "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" }, @@ -334,7 +379,7 @@ }, { "check": "sep-2575-server-declares-prompts-in-discover", - "status": "untested", + "status": "tested", "text": "Servers that support prompts MUST declare the prompts capability in their DiscoverResult.", "url": "https://modelcontextprotocol.io/specification/draft/server/prompts#capabilities" }, @@ -396,12 +441,24 @@ } ], "unkeyed": [], - "untracked": [], + "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": 0, - "untested": 26, + "tested": 8, + "untested": 18, "excluded": 9, - "untracked": 0, + "untracked": 11, "unkeyed": 0 } } From 12252466e661963e956e0091b350214612f3e11b Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Tue, 19 May 2026 15:28:50 -0700 Subject: [PATCH 129/226] SEP-837: application_type check in DCR registration (#284) * Add SEP-837 application_type check to DCR registration SEP-837 requires MCP clients to specify an appropriate application_type during Dynamic Client Registration so OIDC authorization servers can apply the correct redirect-URI constraints. - Traceability yaml: 1 check (presence + valid value), 4 excluded (class-specific SHOULDs unobservable; UI/robustness) - Check added in the shared createAuthServer DCR handler so it fires in every auth scenario that performs DCR (no new scenario) - withOAuthRetry now sets application_type: native (passing example; the conformance example clients are CLI tools) - New auth-test-no-application-type negative client + vitest case * Widen ConformanceOAuthProvider metadata type for application_type Fixes CI typecheck (TS2353 at withOAuthRetry.ts:76): the SDK's OAuthClientMetadataSchema doesn't include application_type yet. registerClient() spreads clientMetadata verbatim into the /register POST body, so a local type intersection is sufficient to get the field on the wire without an SDK release. * Set application_type in runAuthMigrationClient The SEP-2352 authorization-server-migration handler constructs its own ConformanceOAuthProvider (not via withOAuthRetry), so it was missing application_type after rebasing onto main. The scenario does DCR twice (old AS, new AS) and the SEP-837 check fires on each. --------- Co-authored-by: Paul Carleton --- .../auth-test-no-application-type.ts | 44 +++++++++++++++++++ .../clients/typescript/everything-client.ts | 3 +- .../helpers/ConformanceOAuthProvider.ts | 14 +++++- .../typescript/helpers/withOAuthRetry.ts | 5 ++- .../client/auth/helpers/createAuthServer.ts | 19 ++++++++ src/scenarios/client/auth/index.test.ts | 8 ++++ src/seps/sep-837.yaml | 14 ++++++ 7 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 examples/clients/typescript/auth-test-no-application-type.ts create mode 100644 src/seps/sep-837.yaml 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index 64610fe..71c4582 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -284,7 +284,8 @@ async function runAuthMigrationClient(serverUrl: string): Promise { 'http://localhost:3000/callback', { client_name: 'auth-migration-client', - redirect_uris: ['http://localhost:3000/callback'] + redirect_uris: ['http://localhost:3000/callback'], + application_type: 'native' } ); diff --git a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts index e6864bf..5dffd3d 100644 --- a/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts +++ b/examples/clients/typescript/helpers/ConformanceOAuthProvider.ts @@ -6,6 +6,16 @@ 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; @@ -17,7 +27,7 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { constructor( private readonly _redirectUrl: string | URL, - private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadata: ConformanceClientMetadata, private readonly _clientMetadataUrl?: string | URL ) {} @@ -25,7 +35,7 @@ export class ConformanceOAuthProvider implements OAuthClientProvider { return this._redirectUrl; } - get clientMetadata(): OAuthClientMetadata { + get clientMetadata(): ConformanceClientMetadata { return this._clientMetadata; } diff --git a/examples/clients/typescript/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index 429ca53..3d5c898 100644 --- a/examples/clients/typescript/helpers/withOAuthRetry.ts +++ b/examples/clients/typescript/helpers/withOAuthRetry.ts @@ -70,7 +70,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/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index c8c4ecd..00aa3db 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -386,6 +386,25 @@ 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. + 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/index.test.ts b/src/scenarios/client/auth/index.test.ts index 92a7220..c5d7981 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -15,6 +15,7 @@ import { runClient as ignore403Client } from '../../../../examples/clients/types 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 { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -155,6 +156,13 @@ describe('Negative tests', () => { ); }); + test('client omits application_type during DCR (SEP-837)', async () => { + const runner = new InlineClientRunner(noAppTypeClient); + await runClientAgainstScenario(runner, 'auth/metadata-default', { + expectedFailureSlugs: ['sep-837-application-type-present'] + }); + }); + test('client does not use PKCE', async () => { const runner = new InlineClientRunner(noPkceClient); await runClientAgainstScenario(runner, 'auth/metadata-default', { 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' From 78d7219198ddbfc63632d5e17f5b9dc8bc7a7eb8 Mon Sep 17 00:00:00 2001 From: Max Gerber <89937743+max-stytch@users.noreply.github.com> Date: Tue, 19 May 2026 15:42:35 -0700 Subject: [PATCH 130/226] feat: add conformance tests for iss parameter (SEP-2468) (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add conformance tests for iss parameter (SEP-2468) Adds 5 draft conformance scenarios testing RFC 9207 issuer parameter validation in OAuth authorization responses: - auth/iss-supported: server advertises support and sends correct iss - auth/iss-not-advertised: server omits iss parameter entirely - auth/iss-supported-missing: client must reject missing iss when required - auth/iss-wrong-issuer: client must reject mismatched iss value - auth/iss-unexpected: client must reject iss when not advertised Also adds auth-test-iss-validation.ts, a reference client that correctly validates iss per RFC 9207, and negative tests confirming the standard client fails all three rejection scenarios. TODO: Update RFC_9207_ISS_PARAMETER spec reference once SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468) is merged. * update scenarios * fix: createAuthServer iss option type/guard and NotAdvertised scenario duplication The doc comments said 'Default: not included' but the destructure defaulted to true/'correct', and the `!== undefined` guard at L155 was unreachable — so there was no way to omit the metadata field, and IssParameterNotAdvertised silently advertised support (a duplicate of IssParameterSupported). Kept the on-by-default behavior (mock AS models a well-behaved server) but made issParameterSupported `boolean | null` so callers pass null to omit, matching the codeChallengeMethodsSupported pattern. Doc comments now match. Scenarios that need omission pass null/'omit' explicitly. * fix: rejection scenarios silently pass when client never reaches auth endpoint correctlyRejected = !tokenRequestMade reports SUCCESS if the client errors out before hitting /authorize. Gate on authReached so a setup failure shows as FAILURE with authReached:false in details. * fix: iss-unexpected scenario contradicts SEP-2468 spec table row 3 The spec table says: supported=false/absent + iss present -> *Compare* to the recorded issuer (not reject). The scenario sent a *correct* iss and FAILed compliant clients for proceeding after a successful comparison. Now sends a mismatched iss so the comparison fails and rejection is the spec-required outcome. Reference client updated to compare-when-present instead of throw-on-presence. * refactor: replace harness-config checks with client-proceeded checks iss-advertised-in-metadata / iss-sent-in-redirect (and the not-* variants) fired in onAuthorizationRequest before the redirect happened, asserting only that the harness was configured correctly — a client that ignores iss passes identically. Replaced with one check per scenario keyed on tokenRequestMade, which observes that the client actually proceeded through the iss path. * refactor: rename check IDs to sep-2468-* and align with spec table rows One ID per spec table row; auth/iss-supported and auth/iss-wrong-issuer both emit sep-2468-client-compare-iss-supported (same comparison, opposite input) per the same-slug-for-SUCCESS-and-FAIL convention. * feat: add sep-2468.yaml requirement traceability 8 check rows (4 client table-row checks, 1 metadata-issuer, 2 AS-side, 1 no-normalization), 1 excluded (error-display is UI-facing). The record-issuer MUST is merged into the compare-iss-supported row text since it has no independent wire observation. * fix: migrate iss scenarios specVersions->source (post-#265) Replaces `specVersions: ['draft']` with `source: { introducedIn: DRAFT_PROTOCOL_VERSION }` in the 5 iss-parameter scenarios. This commit typechecks once the stack is rebased onto main >= #265 (the ScenarioSource migration). Adding it now so the rebase is mechanical. * fix: include application_type in iss-validation example DCR (post-#284) The SEP-837 application_type check now runs in every auth scenario; the hand-rolled DCR in auth-test-iss-validation.ts was omitting the field. --------- Co-authored-by: Paul Carleton --- .../typescript/auth-test-iss-validation.ts | 230 ++++++++++ .../clients/typescript/everything-client.ts | 16 +- .../client/auth/helpers/createAuthServer.ts | 16 + src/scenarios/client/auth/index.test.ts | 31 +- src/scenarios/client/auth/index.ts | 14 +- src/scenarios/client/auth/issuer-parameter.ts | 408 ++++++++++++++++++ src/scenarios/client/auth/spec-references.ts | 5 + src/seps/sep-2468.yaml | 23 + 8 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 examples/clients/typescript/auth-test-iss-validation.ts create mode 100644 src/scenarios/client/auth/issuer-parameter.ts create mode 100644 src/seps/sep-2468.yaml 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..3fdd3f0 --- /dev/null +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -0,0 +1,230 @@ +#!/usr/bin/env node + +/** + * Well-behaved client that validates the iss parameter in authorization responses. + * + * 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, the client MUST reject any redirect + * that unexpectedly contains an iss parameter. + */ + +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(); + + 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index 71c4582..a9cf90c 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -32,6 +32,7 @@ import { handle401 } from './helpers/withOAuthRetry.js'; import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js'; +import { runClient as issValidationClient } from './auth-test-iss-validation.js'; import { logger } from './helpers/logger.js'; /** @@ -270,7 +271,10 @@ registerScenarios( 'auth/resource-mismatch', // SEP-2207: Offline access / refresh token guidance (draft) 'auth/offline-access-scope', - 'auth/offline-access-not-supported' + 'auth/offline-access-not-supported', + // SEP-2468: ISS parameter - positive scenarios (standard client is fine) + 'auth/iss-supported', + 'auth/iss-not-advertised' ], runAuthClient ); @@ -342,6 +346,16 @@ async function runAuthMigrationClient(serverUrl: string): Promise { 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' + ], + issValidationClient +); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 00aa3db..5138e36 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -43,6 +43,10 @@ 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'. */ + issInRedirect?: 'correct' | 'wrong' | 'omit'; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -86,6 +90,8 @@ export function createAuthServer( clientIdMetadataDocumentSupported, disableDynamicRegistration = false, codeChallengeMethodsSupported = ['S256'], + issParameterSupported = true, + issInRedirect = 'correct', tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -146,6 +152,9 @@ 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: @@ -244,6 +253,13 @@ export function createAuthServer( redirectUrl.searchParams.set('state', state); } + // ISS: Include iss parameter in redirect if configured + if (issInRedirect === 'correct') { + redirectUrl.searchParams.set('iss', `${getAuthBaseUrl()}${routePrefix}`); + } else if (issInRedirect === 'wrong') { + redirectUrl.searchParams.set('iss', 'https://evil.example.com'); + } + res.redirect(redirectUrl.toString()); }); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index c5d7981..cd09793 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -16,6 +16,7 @@ import { runClient as noRetryLimitClient } from '../../../../examples/clients/ty 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 { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -34,7 +35,11 @@ const allowClientErrorScenarios = new Set([ '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' + 'auth/authorization-server-migration', + // Client is expected to error when iss validation fails + 'auth/iss-supported-missing', + 'auth/iss-wrong-issuer', + 'auth/iss-unexpected' ]); describe('Client Auth Scenarios', () => { @@ -174,4 +179,28 @@ 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 + }); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 85b3f3f..92e87f6 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -29,6 +29,13 @@ import { OfflineAccessNotSupportedScenario } from './offline-access'; import { AuthorizationServerMigrationScenario } from './authorization-server-migration'; +import { + IssParameterSupportedScenario, + IssParameterNotAdvertisedScenario, + IssParameterSupportedMissingScenario, + IssParameterWrongIssuerScenario, + IssParameterUnexpectedScenario +} from './issuer-parameter'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -63,5 +70,10 @@ export const draftScenariosList: Scenario[] = [ new ResourceMismatchScenario(), new OfflineAccessScopeScenario(), new OfflineAccessNotSupportedScenario(), - new AuthorizationServerMigrationScenario() + new AuthorizationServerMigrationScenario(), + new IssParameterSupportedScenario(), + new IssParameterNotAdvertisedScenario(), + new IssParameterSupportedMissingScenario(), + new IssParameterWrongIssuerScenario(), + new IssParameterUnexpectedScenario() ]; diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts new file mode 100644 index 0000000..d34b73d --- /dev/null +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -0,0 +1,408 @@ +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'; + +const specRefs = [SpecReferences.RFC_9207_ISS_PARAMETER]; + +/** + * 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(): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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(): Promise { + this.checks = []; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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(): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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') + ) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + id: 'sep-2468-client-reject-missing-iss', + name: 'Client rejects missing iss when required', + description: correctlyRejected + ? 'Client correctly rejected authorization response missing required iss parameter' + : 'Client MUST reject authorization response when server advertised iss support but iss is absent from redirect', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + serverAdvertisedSupport: true, + issSentInRedirect: false, + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + 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(): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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') + ) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + id: 'sep-2468-client-compare-iss-supported', + name: 'Client rejects mismatched iss', + description: correctlyRejected + ? 'Client correctly rejected authorization response with mismatched iss parameter' + : 'Client MUST reject authorization response when iss does not match the authorization server issuer', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + serverAdvertisedSupport: true, + issSentInRedirect: 'https://evil.example.com', + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + 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(): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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' + ) + ) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + id: 'sep-2468-client-compare-iss-unadvertised', + name: 'Client compares unadvertised iss and rejects mismatch', + description: correctlyRejected + ? 'Client correctly compared unadvertised iss against recorded issuer and rejected the mismatch' + : 'Client MUST compare a present iss against the recorded issuer regardless of metadata advertisement, and reject on mismatch', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + serverAdvertisedSupport: false, + issSentInRedirect: 'https://evil.example.com', + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 768dd65..908a04e 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -89,6 +89,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' diff --git a/src/seps/sep-2468.yaml b/src/seps/sep-2468.yaml new file mode 100644 index 0000000..6558ae6 --- /dev/null +++ b/src/seps/sep-2468.yaml @@ -0,0 +1,23 @@ +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-as-include-iss + text: 'MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.' + - check: sep-2468-as-advertise-iss-supported + 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).' + - 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: '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' From 8c828a7fab1de75d6e100683d33aee6b2b30386b Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Tue, 19 May 2026 16:02:03 -0700 Subject: [PATCH 131/226] SEP-2350: scope-accumulation check in auth/scope-step-up (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SEP-2350 scope-accumulation check to auth/scope-step-up SEP-2350 clarifies that on step-up re-authorization, clients SHOULD compute the union of previously-granted and newly-challenged scopes so they don't lose permissions for other operations. - Traceability yaml: 1 client check (two spec sentences merged), 1 server check tracked for a future server-side scenario, 1 excluded reword - ScopeStepUpAuthScenario now challenges with only the missing scope so union accumulation is observable on the second authorization request - New check sep-2350-scope-union-on-reauth (WARNING when the previously granted scope is dropped) - everything-client withOAuthRetry now accumulates prior token scope into the re-auth scope (passing example) - New auth-test-echo-scope negative client + vitest case * feat: add client conformance tests for SEP-2575 (#270) * feat: merge latest request-metadata scenario stacked branch * test: implement optional capability conditional checks and simulated negotiation retry * fix(sep-2243): collapse check IDs to one-per-requirement (#287) The sep-2243 scenario check IDs drifted from the requirement-traceability yaml in #259 — the code emitted one ID per test case while the yaml declares one per normative requirement, so the IDs no longer matched. Rename the emitted IDs so a check ID maps to a single MUST/SHOULD and is emitted once per case (the per-case detail moves to name/description), matching the repo's existing 'same id, vary status/message' convention: - mcp-method-header-* / mcp-name-header-* -> client-includes-standard-headers - reject-invalid-tool-* / keep-valid-tool -> client-reject-invalid-tool - server-accepts-{lower,upper}case-name -> header-name-case-insensitive - server reject status checks (mismatch/missing/case x method/name) -> server-reject-invalid-headers - their error-code variants -> server-reject-error-code Merge the yaml's server-reject-mismatch + server-reject-status into one server-reject-invalid-headers MUST (HTTP 400 on a header-validation failure); keep server-reject-error-code as the SHOULD. Base64/custom-header rejection checks keep their own param-validation IDs (out of scope here). Gates and the whitespace-acceptance check keep their scenario-matching names. No behavior change — only check IDs, descriptions, and the yaml. * feat: `sdk` subcommand to run local conformance against any SDK ref (#277) * feat: add sdk subcommand to run conformance against any SDK ref (#250) * Revise sdk runner: explicit --mode, KNOWN_SDKS-only config, v1/v2 entries Addresses review feedback on the sdk subcommand: - Require --mode (client|server) and remove "both". Each invocation now tests exactly one side with its own exit code; the old default ran client then server but combined exit codes with ||=, which skipped the server side entirely whenever the client run failed. - Resolve build/run config from KNOWN_SDKS + CLI flags only; drop the conformance.config.yaml loader (no SDK ships one yet). The Zod schema stays as the type for the built-in entries. - Split the typescript entry by major version: typescript-sdk (v2/main, pnpm install + build:all, expected-failures.yaml) and typescript-sdk-v1 (v1.x, npm ci + build, conformance-baseline.yml). An entry may set `repo` (real clone target for an alias) and `defaultRef` (branch used when no @ref is given). parseSdkSpec now leaves ref undefined when omitted so defaultRef can apply; a trailing @ is treated as no ref. - Key the clone cache by ref (/) so different refs of the same repo no longer share one checkout. - Bound the server readiness probe with a per-request AbortSignal timeout so a server that accepts the socket but never responds can't hang past the overall deadline. * fix(sdk-runner): resolve -o to absolute path; add --expected-failures override; replaceAll for safeName * feat: add SEP conformance traceability manifest (#288) squashed; see PR #288 body * feat: add server conformance tests for SEP-2575 (#271) * feat: add server conformance tests for SEP-2575 * refactor and add new checks * Fix stateless scenario source field and report SKIPPED for inapplicable capability checks - Add the required `source` field (DRAFT) so the scenario typechecks and is selectable via --spec-version. Without it the class did not satisfy the ClientScenario interface and s.source was dereferenced by the spec-version filter at list time. - Teach runCheck about a SKIPPED status and use it for the two client-capability checks when the server does not return -32003, instead of reporting a green PASS for a requirement that was never exercised. * Remove unimplemented placeholder checks from stateless server scenario The subscriptions/listen, statelessness-invariant, list-changed and disconnect-is-cancel checks emitted SUCCESS without probing the server, which inflated coverage in the traceability report. Drop them until they have real assertions; the corresponding rows remain declared in src/seps/sep-2575.yaml so traceability shows them as not-yet-covered. Also fix the checkErrorId helper to push under the sep-2575-http-server-error-jsonrpc-id slug so its failure path actually short-circuits the aggregate SUCCESS check at the end of the scenario. --------- Co-authored-by: Paul Carleton * SEP-2352: authorization-server migration scenario (#286) * Add SEP-2352 authorization-server migration scenario SEP-2352 requires that client credentials are bound to the issuing authorization server: when PRM authorization_servers changes to a new issuer, clients MUST re-register and MUST NOT reuse the previous AS's client credentials. - Traceability yaml: 3 checks, 3 excluded (internal state / UI) - New auth/authorization-server-migration scenario (draft suite): two auth servers; PRM flips from AS1 to AS2 after the first authenticated request; AS2 asserts it received a fresh /register and never saw AS1's client_id at /authorize or /token - ConformanceOAuthProvider gains invalidateCredentials and bindIssuer so the everything-client can key credentials by issuer (passing example) - everything-client adds an issuer-aware handler for this scenario that re-reads PRM on each 401 and rebinds before re-authorizing - auth-test-reuse-credentials negative client + vitest case * Drop application_type from DCR metadata (not in SDK OAuthClientMetadata type) --------- Co-authored-by: Paul Carleton * ci(traceability): enable corepack so the SDK build can use pnpm (#289) The refresh run failed with 'pnpm: not found' — the reference SDK's build command (typescript-sdk: pnpm install && pnpm run build:all) needs pnpm on PATH. Add 'corepack enable' to the run job. * ci(traceability): tolerate SDK conformance failures in the run step (#290) The `sdk` command exits non-zero when the SDK has conformance failures not in its baseline. The traceability manifest only needs the emitted check IDs (written regardless of pass/fail), so a failing SDK must not fail the refresh. Add `|| true`; the existing 'no results produced' guard catches a genuinely broken run. * chore: refresh SEP traceability manifest (typescript-sdk@main) (#291) Co-authored-by: github-actions[bot] * Make scopesSupported disjoint from challenge scopes so the SEP-2350 union check can't be satisfied by scopes_supported ∪ challenge A client that unions scopes_supported with the challenge — instead of its prior grant with the challenge — would have requested mcp:basic mcp:write and falsely passed sep-2350-scope-union-on-reauth, since scopesSupported happened to coincide with the previously-granted scope. Setting it to a disjoint value makes the check actually require the prior token's scope. Spec backing: clients MUST NOT assume any set relationship between the challenged scope set and scopes_supported, so a disjoint advertisement is realistic. * SEP-837: application_type check in DCR registration (#284) * Add SEP-837 application_type check to DCR registration SEP-837 requires MCP clients to specify an appropriate application_type during Dynamic Client Registration so OIDC authorization servers can apply the correct redirect-URI constraints. - Traceability yaml: 1 check (presence + valid value), 4 excluded (class-specific SHOULDs unobservable; UI/robustness) - Check added in the shared createAuthServer DCR handler so it fires in every auth scenario that performs DCR (no new scenario) - withOAuthRetry now sets application_type: native (passing example; the conformance example clients are CLI tools) - New auth-test-no-application-type negative client + vitest case * Widen ConformanceOAuthProvider metadata type for application_type Fixes CI typecheck (TS2353 at withOAuthRetry.ts:76): the SDK's OAuthClientMetadataSchema doesn't include application_type yet. registerClient() spreads clientMetadata verbatim into the /register POST body, so a local type intersection is sufficient to get the field on the wire without an SDK release. * Set application_type in runAuthMigrationClient The SEP-2352 authorization-server-migration handler constructs its own ConformanceOAuthProvider (not via withOAuthRetry), so it was missing application_type after rebasing onto main. The scenario does DCR twice (old AS, new AS) and the SEP-837 check fires on each. --------- Co-authored-by: Paul Carleton * feat: add conformance tests for iss parameter (SEP-2468) (#220) * feat: add conformance tests for iss parameter (SEP-2468) Adds 5 draft conformance scenarios testing RFC 9207 issuer parameter validation in OAuth authorization responses: - auth/iss-supported: server advertises support and sends correct iss - auth/iss-not-advertised: server omits iss parameter entirely - auth/iss-supported-missing: client must reject missing iss when required - auth/iss-wrong-issuer: client must reject mismatched iss value - auth/iss-unexpected: client must reject iss when not advertised Also adds auth-test-iss-validation.ts, a reference client that correctly validates iss per RFC 9207, and negative tests confirming the standard client fails all three rejection scenarios. TODO: Update RFC_9207_ISS_PARAMETER spec reference once SEP-2468 (modelcontextprotocol/modelcontextprotocol#2468) is merged. * update scenarios * fix: createAuthServer iss option type/guard and NotAdvertised scenario duplication The doc comments said 'Default: not included' but the destructure defaulted to true/'correct', and the `!== undefined` guard at L155 was unreachable — so there was no way to omit the metadata field, and IssParameterNotAdvertised silently advertised support (a duplicate of IssParameterSupported). Kept the on-by-default behavior (mock AS models a well-behaved server) but made issParameterSupported `boolean | null` so callers pass null to omit, matching the codeChallengeMethodsSupported pattern. Doc comments now match. Scenarios that need omission pass null/'omit' explicitly. * fix: rejection scenarios silently pass when client never reaches auth endpoint correctlyRejected = !tokenRequestMade reports SUCCESS if the client errors out before hitting /authorize. Gate on authReached so a setup failure shows as FAILURE with authReached:false in details. * fix: iss-unexpected scenario contradicts SEP-2468 spec table row 3 The spec table says: supported=false/absent + iss present -> *Compare* to the recorded issuer (not reject). The scenario sent a *correct* iss and FAILed compliant clients for proceeding after a successful comparison. Now sends a mismatched iss so the comparison fails and rejection is the spec-required outcome. Reference client updated to compare-when-present instead of throw-on-presence. * refactor: replace harness-config checks with client-proceeded checks iss-advertised-in-metadata / iss-sent-in-redirect (and the not-* variants) fired in onAuthorizationRequest before the redirect happened, asserting only that the harness was configured correctly — a client that ignores iss passes identically. Replaced with one check per scenario keyed on tokenRequestMade, which observes that the client actually proceeded through the iss path. * refactor: rename check IDs to sep-2468-* and align with spec table rows One ID per spec table row; auth/iss-supported and auth/iss-wrong-issuer both emit sep-2468-client-compare-iss-supported (same comparison, opposite input) per the same-slug-for-SUCCESS-and-FAIL convention. * feat: add sep-2468.yaml requirement traceability 8 check rows (4 client table-row checks, 1 metadata-issuer, 2 AS-side, 1 no-normalization), 1 excluded (error-display is UI-facing). The record-issuer MUST is merged into the compare-iss-supported row text since it has no independent wire observation. * fix: migrate iss scenarios specVersions->source (post-#265) Replaces `specVersions: ['draft']` with `source: { introducedIn: DRAFT_PROTOCOL_VERSION }` in the 5 iss-parameter scenarios. This commit typechecks once the stack is rebased onto main >= #265 (the ScenarioSource migration). Adding it now so the rebase is mechanical. * fix: include application_type in iss-validation example DCR (post-#284) The SEP-837 application_type check now runs in every auth scenario; the hand-rolled DCR in auth-test-iss-validation.ts was omitting the field. --------- Co-authored-by: Paul Carleton --------- Co-authored-by: Anubhav Dhawan Co-authored-by: Paul Carleton Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> Co-authored-by: Paul Carleton Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Max Gerber <89937743+max-stytch@users.noreply.github.com> --- .../typescript/auth-test-echo-scope.ts | 62 ++++++++++++++++++ .../typescript/helpers/withOAuthRetry.ts | 22 ++++++- src/scenarios/client/auth/index.test.ts | 13 +++- src/scenarios/client/auth/scope-handling.ts | 65 +++++++++++++++---- src/seps/sep-2350.yaml | 11 ++++ 5 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 examples/clients/typescript/auth-test-echo-scope.ts create mode 100644 src/seps/sep-2350.yaml 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/helpers/withOAuthRetry.ts b/examples/clients/typescript/helpers/withOAuthRetry.ts index 3d5c898..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, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index cd09793..929c528 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -17,6 +17,7 @@ import { runClient as noPkceClient } from '../../../../examples/clients/typescri 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 echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -126,7 +127,17 @@ describe('Negative tests', () => { test('client only responds to 401, not 403', async () => { const runner = new InlineClientRunner(ignore403Client); await runClientAgainstScenario(runner, 'auth/scope-step-up', { - expectedFailureSlugs: ['scope-step-up-escalation'] + expectedFailureSlugs: [ + 'scope-step-up-escalation', + 'sep-2350-scope-union-on-reauth' + ] + }); + }); + + test('client echoes challenge scope without accumulating prior grant (SEP-2350)', async () => { + const runner = new InlineClientRunner(echoScopeClient); + await runClientAgainstScenario(runner, 'auth/scope-step-up', { + expectedFailureSlugs: ['sep-2350-scope-union-on-reauth'] }); }); diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index e1d0ff1..c24d51d 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -295,7 +295,11 @@ export class ScopeStepUpAuthScenario implements Scenario { this.checks = []; 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; @@ -323,21 +327,40 @@ 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' + } + }); + + 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 +411,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', @@ -418,7 +443,12 @@ export class ScopeStepUpAuthScenario implements Scenario { { prmPath: '/.well-known/oauth-protected-resource/mcp', requiredScopes: escalatedScopes, - scopesSupported: [initialScope], + // 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 +495,15 @@ export class ScopeStepUpAuthScenario implements Scenario { timestamp: new Date().toISOString(), specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] }); + 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; diff --git a/src/seps/sep-2350.yaml b/src/seps/sep-2350.yaml new file mode 100644 index 0000000..486c768 --- /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 + - check: sep-2350-server-single-challenge + text: 'Regardless of the approach chosen, servers SHOULD include all scopes required for the current operation in a single challenge.' + + - 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' From 1d6cda37b8ff377161339c988d1487367715d2dc Mon Sep 17 00:00:00 2001 From: Max Gerber <89937743+max-stytch@users.noreply.github.com> Date: Tue, 19 May 2026 16:03:26 -0700 Subject: [PATCH 132/226] fix: use dynamic port in server scenario tests to avoid conflicts (#221) Co-authored-by: Paul Carleton --- src/scenarios/server/all-scenarios.test.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 689d529..9f2d014 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -1,14 +1,28 @@ import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'net'; import { getClientScenario, listActiveClientScenarios } from '../index'; 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 +32,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 @@ -114,7 +128,7 @@ describe('Server Scenarios', () => { throw new Error(`Scenario ${scenarioName} not found`); } - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(serverUrl); // Verify checks were returned expect(checks.length).toBeGreaterThan(0); From 70b2800d1838461362e8688686e730f994903285 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 20 May 2026 00:17:11 +0100 Subject: [PATCH 133/226] chore: refresh SEP traceability manifest (typescript-sdk@main) (#292) Co-authored-by: github-actions[bot] --- src/seps/traceability.json | 132 +++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/seps/traceability.json b/src/seps/traceability.json index e4091c0..8bc933d 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -3,6 +3,44 @@ "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", "source": "typescript-sdk@4e153aef0538", "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 + } + }, "2164": { "yaml": "src/seps/sep-2164.yaml", "specUrl": "https://modelcontextprotocol.io/specification/draft/server/resources#error-handling", @@ -198,6 +236,38 @@ "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" + }, + { + "check": "sep-2350-server-single-challenge", + "status": "untested", + "text": "Regardless of the approach chosen, servers SHOULD include all scopes required for the current operation in a single challenge." + } + ], + "excluded": [ + { + "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": 1, + "excluded": 1, + "untracked": 0, + "unkeyed": 0 + } + }, "2352": { "yaml": "src/seps/sep-2352.yaml", "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-binding", @@ -243,6 +313,68 @@ "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": "untested", + "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-as-include-iss", + "status": "untested", + "text": "MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2." + }, + { + "check": "sep-2468-as-advertise-iss-supported", + "status": "untested", + "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)." + }, + { + "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": "untested", + "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": "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": 4, + "untested": 4, + "excluded": 1, + "untracked": 0, + "unkeyed": 0 + } + }, "2575": { "yaml": "src/seps/sep-2575.yaml", "specUrl": "https://modelcontextprotocol.io/specification/draft/basic/lifecycle", From 1d05b1f06f366c52b7e32799cce5c9116f4c6fb3 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 20 May 2026 00:44:03 +0100 Subject: [PATCH 134/226] chore: add SEP-2207 requirement-traceability YAML (OIDC refresh tokens) (#294) --- src/seps/sep-2207.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/seps/sep-2207.yaml diff --git a/src/seps/sep-2207.yaml b/src/seps/sep-2207.yaml new file mode 100644 index 0000000..cfe554a --- /dev/null +++ b/src/seps/sep-2207.yaml @@ -0,0 +1,12 @@ +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' + - check: sep-2207-server-no-offline-access + 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' + + - 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' From 0371b188bf61543d1da0b4e45d45ff6c741903a2 Mon Sep 17 00:00:00 2001 From: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> Date: Wed, 20 May 2026 01:38:31 -0700 Subject: [PATCH 135/226] fix: unsupported header takes precedence over header mismatch (#293) --- src/scenarios/server/stateless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index 9c195aa..17b3697 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -403,7 +403,7 @@ export class ServerStatelessScenario implements ClientScenario { const responseAbsent = await sendRpc( 'server/discover', { _meta: headerMismatchMeta }, - { 'MCP-Protocol-Version': 'mismatch.version' }, + { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }, 302 ).catch(() => null); const resAbsent: any = responseAbsent?.res ?? null; From c57795e2a31291d520331a0a41ab27c707048b61 Mon Sep 17 00:00:00 2001 From: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> Date: Thu, 21 May 2026 11:17:31 -0700 Subject: [PATCH 136/226] chore: additional server conformance tests for SEP-2575 (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: additional server conformance tests for SEP-2575 * fix(sep-2575): use the spec's notifications filter object in subscriptions/listen The scenario sent params.subscriptions: [{type: 'tools/list-changed'}], a shape that does not exist in the SEP. SubscriptionsListenRequestParams requires params.notifications: {toolsListChanged?, promptsListChanged?, ...} (see schema.ts SubscriptionFilter and the canonical SubscriptionsListenRequest example). A compliant server would either reject the old request as invalid params or treat it as subscribing to nothing, failing the ack/list-changed checks. Also makes the everything-server's acknowledgment echo the agreed notifications filter, as the SubscriptionsAcknowledgedNotification schema requires. * fix(sep-2575): match the spec's notifications/*/list_changed method names The list-changed checks and the everything-server used notifications/tools/list-changed (hyphen); the spec's method is notifications/tools/list_changed (underscore) per ToolListChangedNotification / PromptListChangedNotification in schema.ts. With the hyphen form the notification-filter check cannot catch a real leak, and the list-changed checks fail a server that emits the correct name. Also drops the f.params.toolsListChanged === true fallback match: that field is the subscriptions/listen request filter, not a notification parameter, so it never appears on a compliant server's notification. * fix(sep-2575): keep collected frames when the listen stream read times out listenToStream aborts the fetch after timeoutMs. If the abort fired while blocked in reader.read(), the rejection propagated to the outer catch and the helper returned [] — discarding every frame already received. A compliant server holds a subscriptions/listen stream open indefinitely, so the timeout is the *normal* way these reads end; previously every subscription check would report a false 'no frames received' FAILURE against such a server. The current everything-server only avoided this by res.end()ing immediately after the ack. * fix(sep-2575): open the listen stream before triggering list mutations The notification-filter and list-changed checks fired the mutation trigger *before* opening the subscriptions/listen stream. A compliant server only delivers list-changed notifications to streams that are open at the time of the change, so the SHOULD checks would fail any server that does not replay past changes to new subscribers, and the filter check could never observe a real leak. listenToStream now takes an onFirstFrame hook; the three trigger-based checks open the stream, wait for the acknowledgment, then fire the mutation on a separate connection and keep reading. The everything-server now keeps subscriptions/listen streams open, registers them with their filters, and fans the list_changed notification out to open streams when a trigger tool runs - instead of unconditionally emitting a list-changed frame on every stream open (which itself violated the notification-filter requirement in spirit: it announced a change when nothing had changed). * fix(sep-2575): check the subscription id on every listen-stream notification server-tags-subscription-id only inspected the acknowledgment frame, so a server that tags the ack but omits io.modelcontextprotocol/subscriptionId from subsequent notifications passed. The check now triggers a tool-list change once the stream is acknowledged and asserts every notification frame on the stream carries the id in params._meta. Also drops the f.body.method / f.params.method fallbacks when resolving a frame's method - those shapes don't exist in JSON-RPC and only masked malformed frames. * fix(sep-2575): report WARNING, not FAILURE, for the SHOULD-level list-changed checks The spec text behind server-sends-{tools,prompts}-list-changed-on-subscription is a SHOULD ('servers that declared the listChanged capability SHOULD send a notification...'). Severity follows the spec keyword: MUST -> FAILURE, SHOULD -> WARNING. runCheck now accepts a warning flag and the two list-changed checks use it on their failure paths. * fix(sep-2575): skip stream checks when the diagnostic tool is not exposed http-server-no-independent-requests-on-stream and server-no-log-without-loglevel reported SUCCESS when the server rejected the tools/call outright (e.g. test_streaming_elicitation / test_logging_tool do not exist): a single error frame contains no independent request and no log notification, so the check passed without exercising anything. The sibling list-changed checks already SKIP when their trigger tool is missing; these now do the same. * chore(sep-2575): exclude the four unobservable server requirements from traceability Marks the section-C rows of #296 as excluded rather than untested: no-prior-context, no-connection-reuse-required, disconnect-is-cancel and stops-on-cancel describe internal server state that a black-box harness cannot observe on the wire. With these excluded and the part-B checks landed, every testable server-side SEP-2575 requirement is covered; the remaining untested rows are all client-side and blocked on a SEP-2575-aware reference client. --------- Co-authored-by: Paul Carleton --- .../servers/typescript/everything-server.ts | 168 +++++- src/scenarios/server/stateless.test.ts | 275 ++++++++- src/scenarios/server/stateless.ts | 565 +++++++++++++++++- src/seps/sep-2575.yaml | 20 +- 4 files changed, 1008 insertions(+), 20 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index acce242..5b12c61 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1041,6 +1041,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({ @@ -1113,6 +1145,54 @@ app.post('/mcp', async (req, res) => { }); } + // 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 res.json({ jsonrpc: '2.0', @@ -1120,8 +1200,8 @@ app.post('/mcp', async (req, res) => { result: { supportedVersions: ['DRAFT-2026-v1'], capabilities: { - tools: { listChanged: false }, // Explicitly declare capability flags to resolve check assertions - prompts: { listChanged: false } + tools: { listChanged: true }, // Explicitly announce dynamic capabilities matching Section 7 expectations + prompts: { listChanged: true } }, serverInfo: { name: 'everything-stateless-server', version: '1.0.0' } } @@ -1138,6 +1218,17 @@ app.post('/mcp', async (req, res) => { name: 'test_missing_capability', description: 'Test tool requiring sampling', 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: {} } } ] } @@ -1176,6 +1267,79 @@ app.post('/mcp', async (req, res) => { result: { content: [{ type: 'text', text: 'Success' }] } }); } + + // 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 res.json({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'Mutation triggered' }] } + }); + } } // Removed Methods per SEP-2575 (Changed status from 200 to 400/404 per Transport Spec) diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 6153529..99b376a 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -13,7 +13,69 @@ describe('Stateless Server Scenario Negative Tests', () => { global.fetch = async (_url: any, init: any) => { const body = JSON.parse(init.body); const headers = init.headers || {}; - const responseConfig = await handler(body, 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 }; + } + }; + + return { + status: responseConfig?.status ?? 200, + body: { + getReader: () => mockReader, + releaseLock: () => {} + } + } as unknown as Response; + } return { status: responseConfig?.status ?? 404, @@ -135,4 +197,215 @@ describe('Stateless Server Scenario Negative Tests', () => { ); 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(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(mockUrl); + + const independentRequestCheck = findCheck( + checks, + 'sep-2575-http-server-no-independent-requests-on-stream' + ); + expect(independentRequestCheck?.status).toBe('FAILURE'); + }); + + 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(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(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: ['DRAFT-2026-v1'], + 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(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 index 17b3697..def16e4 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -38,8 +38,14 @@ export class ServerStatelessScenario implements ClientScenario { - Absent or altered protocol version header metadata must trigger a \`-32001 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 \`-32003 MissingRequiredClientCapabilityError\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`. -5. **Methods & Routing Mechanics (3 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.`; +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(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -51,15 +57,29 @@ export class ServerStatelessScenario implements ClientScenario { name: string, description: string, fn: () => - | Promise<{ error?: string; skipped?: boolean; details?: any } | void> - | ({ error?: string; skipped?: boolean; details?: any } | void), + | 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 - ? 'FAILURE' + ? result?.warning + ? 'WARNING' + : 'FAILURE' : result?.skipped ? 'SKIPPED' : 'SUCCESS'; @@ -119,6 +139,126 @@ export class ServerStatelessScenario implements ClientScenario { 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 = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }; + + 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': DRAFT_PROTOCOL_VERSION, 'io.modelcontextprotocol/clientInfo': { @@ -512,7 +652,7 @@ export class ServerStatelessScenario implements ClientScenario { ); // ========================================== - // 5. Methods & Routing Mechanics (3 Checks) + // 5. Methods & Routing Mechanics (5 Checks) // ========================================== const expectedSlugs = [ 'initialize', @@ -580,7 +720,418 @@ export class ServerStatelessScenario implements ClientScenario { } ); - // Final catchall ensuring JSON-RPC id integrity validation rules ran successfully + 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: {}, + _meta: validMeta + }, + 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 (e.g. the diagnostic tool does + // not exist), nothing was streamed and the requirement was not + // exercised - report SKIPPED rather than a vacuous SUCCESS. + if (frames.every((f) => f?.error !== undefined)) { + return { + skipped: true, + details: { + note: 'Server does not expose diagnostic tool test_streaming_elicitation; the response stream could not be exercised.', + 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 (e.g. the diagnostic tool does + // not exist), the server never had anything to log and the + // requirement was not exercised - report SKIPPED rather than a + // vacuous SUCCESS. + if (frames.every((f) => f?.error !== undefined)) { + return { + skipped: true, + details: { + note: 'Server does not expose diagnostic tool test_logging_tool; the no-log-without-logLevel requirement could not be exercised.', + 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) + // ========================================== + 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', + () => { + if (streamFrames[0]?.error?.code === -32601) { + return { + skipped: true, + details: { + note: 'Server does not support subscriptions/listen (Method not found)' + } + }; + } + 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', + () => { + if (streamFrames[0]?.error?.code === -32601) { + return { + skipped: true, + details: { + note: 'Server does not support subscriptions/listen (Method not found)' + } + }; + } + 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 + }); + } + ); + + if (narrowFrames[0]?.error?.code === -32601) { + return { + skipped: true, + details: { + note: 'Server does not support subscriptions/listen (Method not found)' + } + }; + } + 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) { + return { + skipped: true, + details: { + note: 'Server does not expose diagnostic hook test_trigger_prompt_change to mutate lists' + } + }; + } + + 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) { + return { + skipped: true, + details: { + note: 'Server does not expose diagnostic hook test_trigger_tool_change to mutate lists' + } + }; + } + + 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')) { checks.push({ id: 'sep-2575-http-server-error-jsonrpc-id', diff --git a/src/seps/sep-2575.yaml b/src/seps/sep-2575.yaml index e48b1e2..798cae4 100644 --- a/src/seps/sep-2575.yaml +++ b/src/seps/sep-2575.yaml @@ -13,10 +13,6 @@ requirements: - 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-stateless-no-prior-context - 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).' - - check: sep-2575-server-stateless-no-connection-reuse-required - text: 'Servers MUST NOT require that a client reuse the same connection to perform related operations.' - 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 @@ -29,12 +25,6 @@ requirements: - 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-server-disconnect-is-cancel - text: 'Closing the SSE response stream MUST be treated by the server as cancellation of that request.' - url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1 - - check: sep-2575-http-server-stops-on-cancel - text: 'The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].' - url: https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-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 @@ -78,6 +68,16 @@ requirements: 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.' From 583d42d2d255421080be4965325bb4b5459b0e69 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Fri, 22 May 2026 12:22:38 -0700 Subject: [PATCH 137/226] feat: add conformance test yaml for sep-2260 (#278) All text excluded as MRTR makes this spec language obsolete and enforces these associations by default. --- src/seps/sep-2260.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/seps/sep-2260.yaml 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 From 7f1b8712ab9440c933ff247154e9db1e8586fa10 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Fri, 22 May 2026 12:57:49 -0700 Subject: [PATCH 138/226] feat: add conformance Tests for SEP-2549: TTL for List Results (#275) --- .../servers/typescript/everything-server.ts | 45 +++ .../typescript/sep-2549-no-caching-hints.ts | 159 ++++++++ src/scenarios/index.ts | 3 + src/scenarios/server/caching.ts | 356 ++++++++++++++++++ src/scenarios/server/negative.test.ts | 42 +++ src/seps/sep-2549.yaml | 44 +++ 6 files changed, 649 insertions(+) create mode 100644 examples/servers/typescript/sep-2549-no-caching-hints.ts create mode 100644 src/scenarios/server/caching.ts create mode 100644 src/seps/sep-2549.yaml diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 5b12c61..910a4b4 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -22,6 +22,9 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js import { ElicitResultSchema, ListToolsRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, type ListToolsResult, type Tool } from '@modelcontextprotocol/sdk/types.js'; @@ -128,6 +131,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: @@ -1024,6 +1067,8 @@ function createMcpServer() { _meta: tool._meta }; }) + // Note: SEP-2549 caching hints are added automatically by the + // setRequestHandler wrapper above }; } ); 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..8fe7952 --- /dev/null +++ b/examples/servers/typescript/sep-2549-no-caching-hints.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +/** + * SEP-2549 Negative Test Server + * + * 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 { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + ListToolsRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema +} from '@modelcontextprotocol/sdk/types.js'; +import express from 'express'; +import { randomUUID } from 'crypto'; + +const transports: Record = {}; + +function isInitializeRequest(body: any): boolean { + return body?.method === 'initialize'; +} + +function createServer() { + const server = new Server( + { name: 'sep-2549-no-caching-hints', version: '1.0.0' }, + { + capabilities: { + tools: {}, + resources: {}, + prompts: {} + } + } + ); + + // Deliberately omit ttlMs and cacheScope from all responses + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test_tool', + description: 'A test tool', + inputSchema: { type: 'object' as const } + } + ] + })); + + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [ + { + name: 'test_prompt', + description: 'A test prompt' + } + ] + })); + + server.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: [ + { + uri: 'test://static-text', + name: 'Static Text', + description: 'A static text resource' + } + ] + })); + + server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ + resourceTemplates: [] + })); + + server.setRequestHandler(ReadResourceRequestSchema, async () => ({ + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'Static text content.' + } + ] + })); + + return server; +} + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + if (sessionId && transports[sessionId]) { + await transports[sessionId].handleRequest(req, res, req.body); + return; + } + + if (!sessionId && isInitializeRequest(req.body)) { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId) => { + transports[newSessionId] = transport; + } + }); + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) delete transports[sid]; + }; + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Invalid or missing session ID' }, + id: null + }); + } 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 + }); + } + } +}); + +app.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && transports[sessionId]) { + await transports[sessionId].handleRequest(req, res); + } else { + res.status(400).json({ error: 'Invalid or missing session ID' }); + } +}); + +app.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && transports[sessionId]) { + await transports[sessionId].handleRequest(req, res); + } else { + res.status(400).json({ error: 'Invalid or missing session ID' }); + } +}); + +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/src/scenarios/index.ts b/src/scenarios/index.ts index e198311..a63fced 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -65,6 +65,7 @@ import { } from './server/prompts'; import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +import { CachingScenario } from './server/caching'; import { HttpHeaderValidationScenario, @@ -162,6 +163,8 @@ const allClientScenariosList: ClientScenario[] = [ // Security scenarios new DNSRebindingProtectionScenario(), + // Caching scenarios (SEP-2549) + new CachingScenario(), // HTTP Standardization scenarios (SEP-2243) new HttpHeaderValidationScenario(), new HttpCustomHeaderServerValidationScenario() diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts new file mode 100644 index 0000000..5c58002 --- /dev/null +++ b/src/scenarios/server/caching.ts @@ -0,0 +1,356 @@ +/** + * 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 { connectToServer } from './client-helper'; +import { + ListToolsResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema +} from '@modelcontextprotocol/sdk/types.js'; + +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: Record): CachingFields { + const hasTtlMs = 'ttlMs' in result; + const hasCacheScope = 'cacheScope' in result; + return { + ttlMs: hasTtlMs ? result.ttlMs : undefined, + cacheScope: hasCacheScope ? result.cacheScope : undefined, + 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(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const allFields: Array<{ endpoint: string; fields: CachingFields }> = []; + + try { + const connection = await connectToServer(serverUrl); + + // 1. tools/list + try { + const toolsResult = await connection.client.request( + { method: 'tools/list', params: {} }, + ListToolsResultSchema + ); + const fields = extractCachingFields( + toolsResult as Record + ); + allFields.push({ endpoint: 'tools/list', fields }); + checks.push( + buildPresenceCheck( + 'sep-2549-tools-list-caching-hints', + 'ToolsListCachingHints', + 'tools/list', + fields + ) + ); + } catch (error) { + checks.push({ + id: 'sep-2549-tools-list-caching-hints', + name: 'ToolsListCachingHints', + description: + 'tools/list response includes ttlMs and cacheScope caching hints', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `tools/list request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + } + + // 2. prompts/list + try { + const promptsResult = await connection.client.request( + { method: 'prompts/list', params: {} }, + ListPromptsResultSchema + ); + const fields = extractCachingFields( + promptsResult as Record + ); + allFields.push({ endpoint: 'prompts/list', fields }); + checks.push( + buildPresenceCheck( + 'sep-2549-prompts-list-caching-hints', + 'PromptsListCachingHints', + 'prompts/list', + fields + ) + ); + } catch (error) { + checks.push({ + id: 'sep-2549-prompts-list-caching-hints', + name: 'PromptsListCachingHints', + description: + 'prompts/list response includes ttlMs and cacheScope caching hints', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `prompts/list request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + } + + // 3. resources/list + let firstResourceUri: string | undefined; + try { + const resourcesResult = await connection.client.request( + { method: 'resources/list', params: {} }, + ListResourcesResultSchema + ); + const fields = extractCachingFields( + resourcesResult as Record + ); + allFields.push({ endpoint: 'resources/list', fields }); + checks.push( + buildPresenceCheck( + 'sep-2549-resources-list-caching-hints', + 'ResourcesListCachingHints', + 'resources/list', + fields + ) + ); + // Capture the first resource URI for the resources/read check + if (resourcesResult.resources && resourcesResult.resources.length > 0) { + firstResourceUri = resourcesResult.resources[0].uri; + } + } catch (error) { + checks.push({ + id: 'sep-2549-resources-list-caching-hints', + name: 'ResourcesListCachingHints', + description: + 'resources/list response includes ttlMs and cacheScope caching hints', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `resources/list request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + } + + // 4. resources/templates/list + try { + const templatesResult = await connection.client.request( + { method: 'resources/templates/list', params: {} }, + ListResourceTemplatesResultSchema + ); + const fields = extractCachingFields( + templatesResult as Record + ); + allFields.push({ endpoint: 'resources/templates/list', fields }); + checks.push( + buildPresenceCheck( + 'sep-2549-resources-templates-list-caching-hints', + 'ResourcesTemplatesListCachingHints', + 'resources/templates/list', + fields + ) + ); + } catch (error) { + checks.push({ + id: 'sep-2549-resources-templates-list-caching-hints', + name: 'ResourcesTemplatesListCachingHints', + description: + 'resources/templates/list response includes ttlMs and cacheScope caching hints', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `resources/templates/list request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + } + + // 5. resources/read — use first resource from resources/list + if (firstResourceUri) { + try { + const readResult = await connection.client.request( + { + method: 'resources/read', + params: { uri: firstResourceUri } + }, + ReadResourceResultSchema + ); + const fields = extractCachingFields( + readResult as Record + ); + allFields.push({ endpoint: 'resources/read', fields }); + checks.push( + buildPresenceCheck( + 'sep-2549-resources-read-caching-hints', + 'ResourcesReadCachingHints', + 'resources/read', + fields + ) + ); + } catch (error) { + checks.push({ + id: 'sep-2549-resources-read-caching-hints', + name: 'ResourcesReadCachingHints', + description: + 'resources/read response includes ttlMs and cacheScope caching hints', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + } + } + + // 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 + })) + } + }); + + await connection.close(); + } catch (error) { + // Connection-level failure — push a single failure check + 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; + } +} diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 769374a..66b481c 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { DNSRebindingProtectionScenario } from './dns-rebinding'; import { ResourcesNotFoundErrorScenario } from './resources'; +import { CachingScenario } from './caching'; function startServer(scriptPath: string, port: number): Promise { return new Promise((resolve, reject) => { @@ -106,4 +107,45 @@ describe('Server scenario negative tests', () => { 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(`http://localhost:${PORT}/mcp`); + + // 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'); + } + }, 15000); + }); }); diff --git a/src/seps/sep-2549.yaml b/src/seps/sep-2549.yaml new file mode 100644 index 0000000..75f76e6 --- /dev/null +++ b/src/seps/sep-2549.yaml @@ -0,0 +1,44 @@ +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 returned by tools/list' + - check: sep-2549-prompts-list-caching-hints + text: 'Servers MUST include caching hints on results returned by prompts/list' + - check: sep-2549-resources-list-caching-hints + text: 'Servers MUST include caching hints on results returned by resources/list' + - check: sep-2549-resources-templates-list-caching-hints + text: 'Servers MUST include caching hints on results returned by resources/templates/list' + - check: sep-2549-resources-read-caching-hints + text: 'Servers MUST include caching hints on results 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' From 513abdbd973049a1d13a52b0e87d5e480731108e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 22 May 2026 21:59:22 +0100 Subject: [PATCH 139/226] fix(sep-2575): resolve the remaining untested traceability rows (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: additional server conformance tests for SEP-2575 * fix(sep-2575): use the spec's notifications filter object in subscriptions/listen The scenario sent params.subscriptions: [{type: 'tools/list-changed'}], a shape that does not exist in the SEP. SubscriptionsListenRequestParams requires params.notifications: {toolsListChanged?, promptsListChanged?, ...} (see schema.ts SubscriptionFilter and the canonical SubscriptionsListenRequest example). A compliant server would either reject the old request as invalid params or treat it as subscribing to nothing, failing the ack/list-changed checks. Also makes the everything-server's acknowledgment echo the agreed notifications filter, as the SubscriptionsAcknowledgedNotification schema requires. * fix(sep-2575): match the spec's notifications/*/list_changed method names The list-changed checks and the everything-server used notifications/tools/list-changed (hyphen); the spec's method is notifications/tools/list_changed (underscore) per ToolListChangedNotification / PromptListChangedNotification in schema.ts. With the hyphen form the notification-filter check cannot catch a real leak, and the list-changed checks fail a server that emits the correct name. Also drops the f.params.toolsListChanged === true fallback match: that field is the subscriptions/listen request filter, not a notification parameter, so it never appears on a compliant server's notification. * fix(sep-2575): keep collected frames when the listen stream read times out listenToStream aborts the fetch after timeoutMs. If the abort fired while blocked in reader.read(), the rejection propagated to the outer catch and the helper returned [] — discarding every frame already received. A compliant server holds a subscriptions/listen stream open indefinitely, so the timeout is the *normal* way these reads end; previously every subscription check would report a false 'no frames received' FAILURE against such a server. The current everything-server only avoided this by res.end()ing immediately after the ack. * fix(sep-2575): open the listen stream before triggering list mutations The notification-filter and list-changed checks fired the mutation trigger *before* opening the subscriptions/listen stream. A compliant server only delivers list-changed notifications to streams that are open at the time of the change, so the SHOULD checks would fail any server that does not replay past changes to new subscribers, and the filter check could never observe a real leak. listenToStream now takes an onFirstFrame hook; the three trigger-based checks open the stream, wait for the acknowledgment, then fire the mutation on a separate connection and keep reading. The everything-server now keeps subscriptions/listen streams open, registers them with their filters, and fans the list_changed notification out to open streams when a trigger tool runs - instead of unconditionally emitting a list-changed frame on every stream open (which itself violated the notification-filter requirement in spirit: it announced a change when nothing had changed). * fix(sep-2575): check the subscription id on every listen-stream notification server-tags-subscription-id only inspected the acknowledgment frame, so a server that tags the ack but omits io.modelcontextprotocol/subscriptionId from subsequent notifications passed. The check now triggers a tool-list change once the stream is acknowledged and asserts every notification frame on the stream carries the id in params._meta. Also drops the f.body.method / f.params.method fallbacks when resolving a frame's method - those shapes don't exist in JSON-RPC and only masked malformed frames. * fix(sep-2575): report WARNING, not FAILURE, for the SHOULD-level list-changed checks The spec text behind server-sends-{tools,prompts}-list-changed-on-subscription is a SHOULD ('servers that declared the listChanged capability SHOULD send a notification...'). Severity follows the spec keyword: MUST -> FAILURE, SHOULD -> WARNING. runCheck now accepts a warning flag and the two list-changed checks use it on their failure paths. * fix(sep-2575): skip stream checks when the diagnostic tool is not exposed http-server-no-independent-requests-on-stream and server-no-log-without-loglevel reported SUCCESS when the server rejected the tools/call outright (e.g. test_streaming_elicitation / test_logging_tool do not exist): a single error frame contains no independent request and no log notification, so the check passed without exercising anything. The sibling list-changed checks already SKIP when their trigger tool is missing; these now do the same. * chore(sep-2575): exclude the four unobservable server requirements from traceability Marks the section-C rows of #296 as excluded rather than untested: no-prior-context, no-connection-reuse-required, disconnect-is-cancel and stops-on-cancel describe internal server state that a black-box harness cannot observe on the wire. With these excluded and the part-B checks landed, every testable server-side SEP-2575 requirement is covered; the remaining untested rows are all client-side and blocked on a SEP-2575-aware reference client. * fix(sep-2575): emit the request-metadata checks even when the client never connects The reference SDK conformance client has no handler registered for the request-metadata scenario, so it exits without sending a request, the scenario emits zero checks, the suite reports "0 passed, 0 failed", and the traceability manifest records all seven client-side requirements as untested. Declare the scenario check IDs up front and backfill any that were never emitted as FAILURE from getChecks(): the emitted ID set is now the same for every client, and a client that never connects fails loudly. A check that is legitimately not applicable opts out by being emitted as SKIPPED explicitly (version-header-matches-meta now does this when either version field is absent). The positive-path test pins the declared list against the reference client in both directions. Also refresh the traceability manifest from a run against typescript-sdk@22595b96: the seven client rows flip to tested and the manifest catches up with the server-side scenarios from #297. --------- Co-authored-by: Yuan Teoh --- src/scenarios/client/request-metadata.test.ts | 60 +++++++++++++ src/scenarios/client/request-metadata.ts | 85 ++++++++++++++----- src/seps/traceability.json | 74 ++++++++-------- 3 files changed, 160 insertions(+), 59 deletions(-) diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index 41ba4c4..6938464 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -5,6 +5,7 @@ import { } from './auth/test_helpers/testClient'; import { getHandler } from '../../../examples/clients/typescript/everything-client'; import { getScenario } from '../index'; +import { DECLARED_CHECK_IDS } from './request-metadata'; // A bad client that does not send _meta async function badClient(serverUrl: string) { @@ -182,6 +183,65 @@ describe('request-metadata client scenario — positive test', () => { 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(); + 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' + ); }); }); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index af1fde0..47d4f00 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -6,6 +6,21 @@ import { DRAFT_PROTOCOL_VERSION } from '../../types'; +/** + * 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-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; @@ -15,10 +30,12 @@ export class RequestMetadataScenario implements Scenario { private server: http.Server | null = null; private checks: ConformanceCheck[] = []; private hasSimulatedRejection = false; + private requestsObserved = 0; async start(): Promise { this.hasSimulatedRejection = false; this.checks = []; + this.requestsObserved = 0; return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); @@ -46,6 +63,30 @@ export class RequestMetadataScenario implements Scenario { } 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; } @@ -68,6 +109,7 @@ export class RequestMetadataScenario implements Scenario { }); req.on('end', () => { const request = JSON.parse(body); + this.requestsObserved++; // Extract version and headers const meta = request.params?._meta; @@ -117,25 +159,30 @@ export class RequestMetadataScenario implements Scenario { }); // 3. "The header value MUST match the io.modelcontextprotocol/protocolVersion - // field carried in the request body's _meta." Only meaningful when both - // are present; absence is already covered by the two checks above. - if (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: 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 } - }); - } + // 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']; diff --git a/src/seps/traceability.json b/src/seps/traceability.json index 8bc933d..9c4ab45 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", - "source": "typescript-sdk@4e153aef0538", + "source": "typescript-sdk@22595b96", "seps": { "837": { "yaml": "src/seps/sep-837.yaml", @@ -381,7 +381,7 @@ "requirements": [ { "check": "sep-2575-client-populates-meta", - "status": "untested", + "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" }, @@ -399,20 +399,10 @@ }, { "check": "sep-2575-server-tags-subscription-id", - "status": "untested", + "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-stateless-no-prior-context", - "status": "untested", - "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)." - }, - { - "check": "sep-2575-server-stateless-no-connection-reuse-required", - "status": "untested", - "text": "Servers MUST NOT require that a client reuse the same connection to perform related operations." - }, { "check": "sep-2575-server-unsupported-version-error", "status": "tested", @@ -421,7 +411,7 @@ }, { "check": "sep-2575-client-retry-supported-version", - "status": "untested", + "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" }, @@ -433,31 +423,19 @@ }, { "check": "sep-2575-http-server-no-independent-requests-on-stream", - "status": "untested", + "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-server-disconnect-is-cancel", - "status": "untested", - "text": "Closing the SSE response stream MUST be treated by the server as cancellation of that request.", - "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1" - }, - { - "check": "sep-2575-http-server-stops-on-cancel", - "status": "untested", - "text": "The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it [HTTP].", - "url": "https://modelcontextprotocol.io/specification/draft/basic/transports#cancellation-1" - }, { "check": "sep-2575-http-client-sends-version-header", - "status": "untested", + "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": "untested", + "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" }, @@ -481,31 +459,31 @@ }, { "check": "sep-2575-server-honors-notification-filter", - "status": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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" }, @@ -517,24 +495,40 @@ }, { "check": "sep-2575-server-sends-prompts-list-changed-on-subscription", - "status": "untested", + "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": "untested", + "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": "untested", + "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" @@ -587,9 +581,9 @@ "sep-2575-request-meta-invalid-missing-protocol-version" ], "summary": { - "tested": 8, - "untested": 18, - "excluded": 9, + "tested": 22, + "untested": 0, + "excluded": 13, "untracked": 11, "unkeyed": 0 } From 43fbf60d3d4d15bc809760ce43877bdf4c3326af Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Fri, 22 May 2026 14:26:11 -0700 Subject: [PATCH 140/226] Conformance Tests for SEP-2322 MRTR (#188) --- .../clients/typescript/everything-client.ts | 143 ++ .../servers/typescript/everything-server.ts | 592 +++++- .../typescript/sep-2322-mrtr-broken-server.ts | 172 ++ src/scenarios/client/mrtr-client.test.ts | 41 + src/scenarios/client/mrtr-client.ts | 486 +++++ src/scenarios/index.ts | 40 +- .../server/input-required-result-helpers.ts | 166 ++ src/scenarios/server/input-required-result.ts | 1629 +++++++++++++++++ src/scenarios/server/negative-mrtr.test.ts | 125 ++ src/seps/sep-2322.yaml | 116 ++ 10 files changed, 3507 insertions(+), 3 deletions(-) create mode 100644 examples/servers/typescript/sep-2322-mrtr-broken-server.ts create mode 100644 src/scenarios/client/mrtr-client.test.ts create mode 100644 src/scenarios/client/mrtr-client.ts create mode 100644 src/scenarios/server/input-required-result-helpers.ts create mode 100644 src/scenarios/server/input-required-result.ts create mode 100644 src/scenarios/server/negative-mrtr.test.ts create mode 100644 src/seps/sep-2322.yaml diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index a9cf90c..245128b 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -726,6 +726,149 @@ registerScenario( runEnterpriseManagedAuthorization ); +// ============================================================================ +// 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); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 910a4b4..36f9719 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -31,12 +31,43 @@ import { import { z } from 'zod'; import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.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 } = {}; @@ -1264,6 +1295,52 @@ app.post('/mcp', async (req, res) => { 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: @@ -1285,12 +1362,74 @@ app.post('/mcp', async (req, res) => { return res.json({ jsonrpc: '2.0', id, - result: { prompts: [] } + result: { + prompts: [ + { + name: 'test_input_required_result_prompt', + description: 'MRTR: prompt that requires elicitation input' + } + ] + } }); } + // 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 res.json({ + jsonrpc: '2.0', + id, + result: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with context: ${context}` + } + } + ] + } + }); + } + return res.json({ + 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'] + } + } + } + } + } + }); + } + } + 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']; @@ -1313,6 +1452,455 @@ app.post('/mcp', async (req, res) => { }); } + // ===== 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 res.json({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: `Hello, ${userName}!` }] } + }); + } + return res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}` + } + ] + } + }); + } + return res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] + } + }); + } + return res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'state-ok: requestState validated' } + ] + } + }); + } + } + return res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Name: ${userName}; Greeting: ${greeting}; Roots: ${roots.length}` + } + ] + } + }); + } + } + return res.json({ + 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 res.json({ + 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 res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Multi-round complete for ${userName} who likes ${color}` + } + ] + } + }); + } + // Fallback: restart + return res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'requestState integrity check failed' + } + }); + } + if (verified.kind === 'tamper-test' && inputResponses?.['confirm']) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'integrity-ok: state verified' } + ] + } + }); + } + } + return res.json({ + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `capabilities-ok: received ${Object.keys(inputResponses).join(',')}` + } + ] + } + }); + } + if (Object.keys(inputRequests).length === 0) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'No supported capabilities declared' } + ] + } + }); + } + return res.json({ + 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, { 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..232e794 --- /dev/null +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -0,0 +1,172 @@ +#!/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 + * + * 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: ['DRAFT-2026-v1'], + 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: {} } + } + ] +}); + +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_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/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..431fafe --- /dev/null +++ b/src/scenarios/client/mrtr-client.ts @@ -0,0 +1,486 @@ +/** + * 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 'notifications/initialized': { + res.status(204).end(); + return; + } + + case 'tools/list': { + res.json({ + jsonrpc: '2.0', + id, + result: { 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: { 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: { + 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: { + 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: { + 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: { 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(): 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/index.ts b/src/scenarios/index.ts index a63fced..8abae1a 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -14,6 +14,7 @@ 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'; @@ -67,6 +68,24 @@ import { 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 { HttpHeaderValidationScenario, HttpCustomHeaderServerValidationScenario @@ -167,7 +186,23 @@ const allClientScenariosList: ClientScenario[] = [ new CachingScenario(), // HTTP Standardization scenarios (SEP-2243) new HttpHeaderValidationScenario(), - new HttpCustomHeaderServerValidationScenario() + new HttpCustomHeaderServerValidationScenario(), + + // 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) @@ -213,6 +248,9 @@ const scenariosList: Scenario[] = [ ...draftScenariosList, ...extensionScenariosList, + // MRTR client conformance (SEP-2322) + new MRTRClientScenario(), + // HTTP Standardization scenarios (SEP-2243) new HttpStandardHeadersScenario(), new HttpCustomHeadersScenario(), 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..a5b73ab --- /dev/null +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -0,0 +1,166 @@ +/** + * Helpers for SEP-2322 conformance tests. + * + * Provides InputRequiredResult-specific type guards, mock response builders, + * and a stateless JSON-RPC transport helper. + */ + +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +// ─── JSON-RPC Types ────────────────────────────────────────────────────────── + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +// ─── Stateless RPC Helper ──────────────────────────────────────────────────── + +let nextId = 1; + +/** + * Send a stateless JSON-RPC request (SEP-2575 pattern). + * Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities. + */ +export async function sendRpc( + serverUrl: string, + method: string, + params?: Record +): Promise { + const id = nextId++; + + const enrichedParams = { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + sampling: {}, + elicitation: {}, + roots: { listChanged: true } + }, + ...(params?._meta as Record | undefined) + } + }; + + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params: enrichedParams }) + }); + + return (await response.json()) 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..f471f58 --- /dev/null +++ b/src/scenarios/server/input-required-result.ts @@ -0,0 +1,1629 @@ +/** + * 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 { + sendRpc, + isInputRequiredResult, + isCompleteResult, + mockElicitResponse, + mockSamplingResponse, + mockListRootsResponse, + MRTR_SPEC_REFERENCES +} from './input-required-result-helpers'; + +// ─── 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + 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[] = []; + + if (resp.error) { + errors.push(`JSON-RPC error: ${resp.error.message}`); + } else if (!result) { + errors.push('No result in response'); + } else if (isInputRequiredResult(result) && result.inputRequests) { + // Check that no elicitation requests are included (client didn't declare it) + for (const [key, req] of Object.entries(result.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: { 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(serverUrl: string): Promise { + 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(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Send inputResponses with invalid structure (number instead of object for the response) + const resp = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: 12345 as unknown as Record + } + }); + + 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) + const resp2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: null as unknown as Record + }); + + 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/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts new file mode 100644 index 0000000..e12dbe1 --- /dev/null +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -0,0 +1,125 @@ +/** + * SEP-2322 MRTR negative tests. + * + * Positive tests run via all-scenarios.test.ts 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 +} from './input-required-result'; + +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(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(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(SERVER_URL); + + const tamperedCheck = checks.find( + (c) => c.id === 'sep-2322-reject-tampered-state' + ); + expect(tamperedCheck).toBeDefined(); + expect(tamperedCheck?.status).toBe('FAILURE'); + }, 10000); +}); 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' From e15c0f8c9a599e081ea09e659b68206c96bbcb0e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 22 May 2026 23:01:32 +0100 Subject: [PATCH 141/226] chore: refresh SEP traceability manifest after SEP-2322 (#188) (#301) * chore: refresh SEP traceability manifest (typescript-sdk@main) Regenerated from a client+server suite run against typescript-sdk@5fc42e9be115 following the recipe in .github/workflows/traceability.yml. New entries since the last refresh (typescript-sdk@22595b96): - SEP-2322 (MRTR, #188): 17 tested, 0 untested, 16 excluded, 3 untracked - SEP-2549 (TTL for list results, #275): 7 tested, 0 untested, 13 excluded - SEP-2260: 12 excluded rows, no checks - SEP-2207: yaml rows added since the last refresh now appear (1 tested, 1 untested: sep-2207-server-no-offline-access) No previously-tested requirement regressed. * Exclude sep-2207 server offline_access guidance until RS auth scenarios exist sep-2207-server-no-offline-access was declared in the yaml but no scenario emits it, so it surfaced as the only untested requirement in the refreshed manifest. The check needs to probe the SDK server's Protected Resource Metadata scopes_supported and WWW-Authenticate challenge scope, and the server suite does not yet exercise the SDK server as an OAuth protected resource at all. Mark the requirement excluded with a pointer to #116 (server-side authorization baseline) rather than leaving it as a permanently-untested row; revisit when server-side authorization scenarios land. --- src/seps/sep-2207.yaml | 5 +- src/seps/traceability.json | 377 ++++++++++++++++++++++++++++++++++++- 2 files changed, 371 insertions(+), 11 deletions(-) diff --git a/src/seps/sep-2207.yaml b/src/seps/sep-2207.yaml index cfe554a..97439df 100644 --- a/src/seps/sep-2207.yaml +++ b/src/seps/sep-2207.yaml @@ -3,9 +3,10 @@ spec_url: https://modelcontextprotocol.io/specification/draft/basic/authorizatio 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' - - check: sep-2207-server-no-offline-access - 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' + - 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' diff --git a/src/seps/traceability.json b/src/seps/traceability.json index 9c4ab45..cb0e375 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", - "source": "typescript-sdk@22595b96", + "source": "typescript-sdk@5fc42e9be115", "seps": { "837": { "yaml": "src/seps/sep-837.yaml", @@ -75,21 +75,40 @@ } }, "2207": { - "yaml": null, - "specUrl": null, - "requirements": [], - "excluded": [], + "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-client-metadata-grant-types", "sep-2207-offline-access-not-requested", "sep-2207-offline-access-requested" ], "summary": { - "tested": 0, + "tested": 1, "untested": 0, - "excluded": 0, - "untracked": 3, + "excluded": 3, + "untracked": 2, "unkeyed": 0 } }, @@ -236,6 +255,242 @@ "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", @@ -375,6 +630,110 @@ "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", From d31a0299affc8d02c81b7a06db3efbf941aa770a Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 22 May 2026 23:22:13 +0100 Subject: [PATCH 142/226] chore(sep-2350): exclude the server single-challenge requirement from traceability (#302) 'All scopes required for the current operation' has no harness-observable ground truth: the WWW-Authenticate challenge is the only place the server declares what an operation requires, so there is nothing independent to compare it against. Detecting the negative (incremental challenging) would need server-side auth scenarios that do not exist yet and would exercise the example app's scope configuration rather than SDK behavior, and the spec explicitly permits dynamic per-request scope determination, so a second challenge is not conclusively non-conformant. The client-side half of SEP-2350 (scope union on re-authorization) remains tested by auth/scope-step-up. --- src/seps/sep-2350.yaml | 4 ++-- src/seps/traceability.json | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/seps/sep-2350.yaml b/src/seps/sep-2350.yaml index 486c768..5f3ebfb 100644 --- a/src/seps/sep-2350.yaml +++ b/src/seps/sep-2350.yaml @@ -4,8 +4,8 @@ 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 - - check: sep-2350-server-single-challenge - text: 'Regardless of the approach chosen, servers SHOULD include all scopes required for the current operation in a single challenge.' + - 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/traceability.json b/src/seps/traceability.json index cb0e375..5e2897b 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -500,14 +500,13 @@ "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" - }, - { - "check": "sep-2350-server-single-challenge", - "status": "untested", - "text": "Regardless of the approach chosen, servers SHOULD include all scopes required for the current operation in a single challenge." } ], "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" @@ -517,8 +516,8 @@ "untracked": [], "summary": { "tested": 1, - "untested": 1, - "excluded": 1, + "untested": 0, + "excluded": 2, "untracked": 0, "unkeyed": 0 } From 909ea0b5fdbe25e3a8e24adae4c412dcb657fb1f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 22 May 2026 23:37:25 +0100 Subject: [PATCH 143/226] fix(sep-2243): map custom-header check IDs to their requirements (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The x-mcp-header / Mcp-Param / Base64 half of SEP-2243 was already tested, but the scenarios emitted one check ID per test case while sep-2243.yaml declares one per normative requirement, so the traceability join (exact ID match) reported 14 of 20 requirements as untested. #287 collapsed the standard-header IDs but left the custom-header ones per-case. This finishes the rename and adds DECLARED_CHECK_IDS backfill (the request-metadata.ts pattern) so every requirement-level ID is emitted on every run — exercised, backfilled as FAILURE, or SKIPPED when the server under test exposes no x-mcp-header tool. SEP-2243 traceability goes from 6/20 to 18/20 tested; the remaining 2 (server-not-expect-null, server-reject-missing-required) have no test case yet. Client scenarios: - param-header-{name} -> client-mirrors-designated-params / client-encode-values / client-base64-unsafe, picked by what the parameter exercises - no-mirror-unannotated -> client-mirrors-designated-params - param-header-tool-call-gate -> client-supports-custom-headers, now also emitted SUCCESS when the annotated tool is called with Mcp-Param headers - the invalid-tool loop emits the specific x-mcp-header constraint each tool violates (not-empty / charset / unique / primitive-only) Server scenarios: - accepts-valid-base64 -> server-decode-base64 - rejects-invalid-base64-{padding,chars} -> server-reject-invalid-param-chars - literal-missing-base64-{prefix,suffix} -> server-validate-param-match - rejects-missing-custom-header -> server-validate-param-match - every custom-header rejection's -32001 check -> server-reject-param-mismatch - standard-header reject cases pass the coarse ID through the catch path too --- .../client/http-custom-headers.test.ts | 200 ++++++++++++++++++ src/scenarios/client/http-custom-headers.ts | 147 +++++++++---- src/scenarios/server/http-standard-headers.ts | 116 ++++++++-- 3 files changed, 399 insertions(+), 64 deletions(-) create mode 100644 src/scenarios/client/http-custom-headers.test.ts 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..88714ee --- /dev/null +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -0,0 +1,200 @@ +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 { + await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); +} + +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(); + 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(); + 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(); + 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(); + } + }); +}); + +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(); + 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(); + 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(); + } + }); +}); diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index aa363a5..e91cf2c 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -29,6 +29,52 @@ const SPEC_REFERENCE_TOOL_DEF = { 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_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}?= @@ -182,28 +228,24 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { } getChecks(): ConformanceCheck[] { - if (!this.toolCallReceived) { + // 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: 'sep-2243-param-header-tool-call-gate', - name: 'ClientCustomHeaderToolCall', - description: 'Client calls the tool with x-mcp-header annotations', + id, + name: 'NotObserved', + description: `Declared check ${id} was never emitted`, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: - 'Client did not send a tools/call request for test_custom_headers.', - specReferences: [SPEC_REFERENCE_CUSTOM] - }); - } - if (!this.nullToolCallReceived) { - 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: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: - 'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.', + 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] }); } @@ -380,6 +422,26 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { 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'); @@ -486,10 +548,11 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { this.checkParamHeader(req, 'Tab', args.tab_val, 'string'); } - // Check that 'query' (no x-mcp-header) is NOT mirrored + // 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-no-mirror-unannotated', + id: 'sep-2243-client-mirrors-designated-params', name: 'ClientCustomHeaderNoMirrorUnannotated', description: 'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header', @@ -576,8 +639,20 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { } } + // 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 === 'boolean' + ? 'sep-2243-client-encode-values' + : 'sep-2243-client-mirrors-designated-params'; + this.checks.push({ - id: `sep-2243-param-header-${headerName.toLowerCase()}`, + id: checkId, name: `ClientCustomHeader_${headerName}`, description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`, status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -589,9 +664,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { rawHeaderValue, bodyValue, valueType, - needsBase64: - typeof bodyValue === 'string' && - needsBase64Encoding(String(bodyValue)) + needsBase64 } }); } @@ -637,24 +710,16 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { specReferences: [SPEC_REFERENCE_TOOL_DEF] }); - // Check that the client did NOT call any of the invalid tools - const invalidTools = [ - 'invalid_empty_header', - 'invalid_object_header', - 'invalid_array_header', - 'invalid_null_header', - 'invalid_duplicate_same_case', - 'invalid_duplicate_diff_case', - 'invalid_space_in_name', - 'invalid_colon_in_name', - 'invalid_non_ascii_name', - 'invalid_control_char_name' - ]; - - for (const toolName of invalidTools) { + // 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: 'sep-2243-client-reject-invalid-tool', + id: constraintId, name: `ClientRejectsInvalidTool_${toolName}`, description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`, status: called ? 'FAILURE' : 'SUCCESS', diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 888d419..528b958 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -56,11 +56,31 @@ const HEADER_MISMATCH_ERROR_CODE = -32001; // 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. (Custom-header -// /Base64 rejections map to different requirements and keep their own IDs.) +// 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 -32001 error-code half of every custom-header +// rejection is the "reject with 400 + JSON-RPC error code -32001 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; + /** * Helper to send a raw HTTP POST request with custom headers. * Uses Node.js http.request to preserve exact header casing and values, @@ -130,9 +150,10 @@ async function sendRawRequest( * * 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 - * so all standard-header cases collapse onto one requirement, while custom-header - * /Base64 callers pass their own per-case ids. The per-case `name`/`description` - * distinguish which rejection case was exercised. + * 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, @@ -300,7 +321,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-mismatched-method-header', + 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' }, @@ -315,7 +336,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-missing-method-header', + REJECT_STATUS_CHECK_ID, 'ServerRejectsMissingMethodHeader', 'Server rejects requests with missing Mcp-Method header', { jsonrpc: '2.0', id: 0, method: 'tools/list' }, @@ -333,7 +354,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-mismatched-name-header', + REJECT_STATUS_CHECK_ID, 'ServerRejectsMismatchedNameHeader', 'Server rejects tools/call where Mcp-Name does not match body params.name', { @@ -384,7 +405,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-missing-name-header', + REJECT_STATUS_CHECK_ID, 'ServerRejectsMissingNameHeader', 'Server MUST reject tools/call with missing Mcp-Name header when body has params.name', { @@ -442,7 +463,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-case-mismatch-value', + 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' }, @@ -574,6 +595,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario 'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.' } }); + this.skipDeclaredChecks( + checks, + 'Server exposes no tool with x-mcp-header annotations.' + ); return checks; } @@ -632,6 +657,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario timestamp: new Date().toISOString(), specReferences: [SPEC_REFERENCE_CUSTOM] }); + this.skipDeclaredChecks( + checks, + 'Server exposes no string-typed x-mcp-header parameter.' + ); return checks; } const [paramName, paramDef] = annotatedEntry as [string, any]; @@ -678,7 +707,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'accept', - 'sep-2243-server-accepts-valid-base64', + 'sep-2243-server-decode-base64', 'ServerAcceptsValidBase64', 'Server decodes valid Base64 header value and validates against body', xMcpTool.name, @@ -702,7 +731,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-invalid-base64-padding', + '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, @@ -721,7 +750,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'reject', - 'sep-2243-server-rejects-invalid-base64-chars', + '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, @@ -740,7 +769,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'accept', - 'sep-2243-server-literal-missing-base64-prefix', + 'sep-2243-server-validate-param-match', 'ServerLiteralMissingBase64Prefix', 'Server treats value without =?base64? prefix as literal (not Base64)', xMcpTool.name, @@ -759,7 +788,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario baseHeaders, nextId, 'accept', - 'sep-2243-server-literal-missing-base64-suffix', + 'sep-2243-server-validate-param-match', 'ServerLiteralMissingBase64Suffix', 'Server treats value without ?= suffix as literal (not Base64)', xMcpTool.name, @@ -796,9 +825,47 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario }); } + // Declared-but-unemitted -> FAILURE. Reached only when setup threw partway + // through (the gate-out paths emit SKIPPED rows and the happy path emits + // every declared ID). + 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] + }); + } + return checks; } + /** + * Emit one SKIPPED row per declared requirement check when the scenario + * cannot run against this server (no x-mcp-header tool). The IDs must still + * reach checks.json so the traceability manifest records that a scenario + * exists for each requirement. + */ + private skipDeclaredChecks(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({ + id, + name: 'NotApplicable', + description: `Declared check ${id} is not testable against this server`, + status: 'SKIPPED', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REFERENCE_CUSTOM], + details: { reason } + }); + } + } + private async testBase64Case( checks: ConformanceCheck[], serverUrl: string, @@ -858,12 +925,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); } else { // Custom-header rejection: both 400 and -32001 are MUST per - // §Server Behavior for Custom Headers. These map to param-validation - // requirements, so they keep their per-case ids (status + -error-code). + // §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 + -32001 if any validation fails" + // requirement. checks.push( ...createRejectionChecks( checkId, - `${checkId}-error-code`, + PARAM_REJECT_ERROR_CODE_CHECK_ID, checkName, description, response, @@ -919,12 +988,13 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } ); - // Custom-header rejection: both 400 and -32001 are MUST. Keeps its own - // per-case ids (status + -error-code). + // Custom-header rejection: both 400 and -32001 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-rejects-missing-custom-header', - 'sep-2243-server-rejects-missing-custom-header-error-code', + '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, @@ -941,7 +1011,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ); } catch (error) { checks.push({ - id: 'sep-2243-server-rejects-missing-custom-header', + 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', From f12baed382c3a5fce31a7b6b5bda533bee424b3b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Sat, 23 May 2026 00:04:55 +0100 Subject: [PATCH 144/226] feat(sep-2468): cover the remaining untested traceability rows (#304) Add two client scenarios for the SEP-2468 requirements that had no conformance coverage: - auth/metadata-issuer-mismatch: the AS metadata document declares an issuer that differs from the issuer identifier used to construct the well-known URL; the client must not use the metadata (sep-2468-client-validate-metadata-issuer). - auth/iss-normalized: the authorization redirect carries an iss value that only matches the recorded issuer after URL normalization; the client must compare with simple string comparison and reject (sep-2468-client-no-normalization). The two authorization-server-side rows (AS SHOULD include iss, AS MUST advertise authorization_response_iss_parameter_supported) are excluded: observing iss in an authorization response requires driving an Authorization Code Grant against the AS under test, which the authorization-server suite does not implement yet (#208). --- .../typescript/auth-test-iss-normalize.ts | 243 ++++++++++++++++++ .../typescript/auth-test-iss-validation.ts | 20 +- .../clients/typescript/everything-client.ts | 4 +- .../client/auth/helpers/createAuthServer.ts | 24 +- src/scenarios/client/auth/index.test.ts | 22 +- src/scenarios/client/auth/index.ts | 8 +- src/scenarios/client/auth/issuer-parameter.ts | 200 ++++++++++++++ src/seps/sep-2468.yaml | 10 +- 8 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 examples/clients/typescript/auth-test-iss-normalize.ts 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 index 3fdd3f0..327cb4c 100644 --- a/examples/clients/typescript/auth-test-iss-validation.ts +++ b/examples/clients/typescript/auth-test-iss-validation.ts @@ -3,12 +3,19 @@ /** * 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, the client MUST reject any redirect - * that unexpectedly contains an iss parameter. + * - 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'; @@ -75,6 +82,15 @@ async function oauthFlowWithIssValidation( } 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; diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 245128b..496a368 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -351,7 +351,9 @@ registerScenarios( [ 'auth/iss-supported-missing', 'auth/iss-wrong-issuer', - 'auth/iss-unexpected' + 'auth/iss-unexpected', + 'auth/iss-normalized', + 'auth/metadata-issuer-mismatch' ], issValidationClient ); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 5138e36..d4acb7f 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -45,8 +45,20 @@ export interface AuthServerOptions { 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'. */ - issInRedirect?: 'correct' | 'wrong' | 'omit'; + /** + * 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). + */ + metadataIssuer?: string; tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -92,6 +104,7 @@ export function createAuthServer( codeChallengeMethodsSupported = ['S256'], issParameterSupported = true, issInRedirect = 'correct', + metadataIssuer, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -140,7 +153,7 @@ export function createAuthServer( }); const metadata: any = { - issuer: `${getAuthBaseUrl()}${routePrefix}`, + issuer: metadataIssuer ?? `${getAuthBaseUrl()}${routePrefix}`, authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, ...(!disableDynamicRegistration && { @@ -258,6 +271,11 @@ export function createAuthServer( redirectUrl.searchParams.set('iss', `${getAuthBaseUrl()}${routePrefix}`); } 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', `${getAuthBaseUrl()}${routePrefix}/`); } res.redirect(redirectUrl.toString()); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 929c528..6089333 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -17,6 +17,7 @@ import { runClient as noPkceClient } from '../../../../examples/clients/typescri 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 issNormalizeClient } from '../../../../examples/clients/typescript/auth-test-iss-normalize'; import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; @@ -40,7 +41,10 @@ const allowClientErrorScenarios = new Set([ // Client is expected to error when iss validation fails 'auth/iss-supported-missing', 'auth/iss-wrong-issuer', - 'auth/iss-unexpected' + 'auth/iss-unexpected', + 'auth/iss-normalized', + // Client is expected to error when AS metadata issuer validation fails + 'auth/metadata-issuer-mismatch' ]); describe('Client Auth Scenarios', () => { @@ -214,4 +218,20 @@ describe('Negative tests', () => { 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 + }); + }); }); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 92e87f6..f29c972 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -34,7 +34,9 @@ import { IssParameterNotAdvertisedScenario, IssParameterSupportedMissingScenario, IssParameterWrongIssuerScenario, - IssParameterUnexpectedScenario + IssParameterUnexpectedScenario, + IssParameterNormalizedVariantScenario, + MetadataIssuerMismatchScenario } from './issuer-parameter'; // Auth scenarios (required for tier 1) @@ -75,5 +77,7 @@ export const draftScenariosList: Scenario[] = [ new IssParameterNotAdvertisedScenario(), new IssParameterSupportedMissingScenario(), new IssParameterWrongIssuerScenario(), - new IssParameterUnexpectedScenario() + new IssParameterUnexpectedScenario(), + new IssParameterNormalizedVariantScenario(), + new MetadataIssuerMismatchScenario() ]; diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index d34b73d..ecb2bc2 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -7,6 +7,10 @@ import { SpecReferences } from './spec-references.js'; import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; const specRefs = [SpecReferences.RFC_9207_ISS_PARAMETER]; +const metadataSpecRefs = [ + SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST, + SpecReferences.MCP_AUTH_DISCOVERY +]; /** * Scenario: ISS Parameter Supported (positive) @@ -406,3 +410,199 @@ export class IssParameterUnexpectedScenario implements Scenario { 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(): Promise { + this.checks = []; + this.authReached = false; + this.tokenRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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')) { + const correctlyRejected = this.authReached && !this.tokenRequestMade; + this.checks.push({ + id: 'sep-2468-client-no-normalization', + name: 'Client compares iss without URL normalization', + description: correctlyRejected + ? 'Client rejected an iss value that only matches the recorded issuer after URL normalization' + : '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', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + recordedIssuer: this.authServer.getUrl(), + issSentInRedirect: `${this.authServer.getUrl()}/`, + authReached: this.authReached, + tokenRequestMade: this.tokenRequestMade + } + }); + } + + 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(): Promise { + this.checks = []; + this.metadataEndpointsUsed = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(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( + 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 correctlyRejected = + metadataRequested && !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' + : metadataRequested + ? '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' + : 'Client never retrieved the authorization server metadata document, so issuer validation could not be observed', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: metadataSpecRefs, + details: { + expectedIssuer: this.authServer.getUrl(), + metadataIssuer: 'https://attacker.example.com', + metadataRequested, + metadataEndpointsUsed: this.metadataEndpointsUsed + } + }); + } + + return this.checks; + } +} diff --git a/src/seps/sep-2468.yaml b/src/seps/sep-2468.yaml index 6558ae6..969f4a4 100644 --- a/src/seps/sep-2468.yaml +++ b/src/seps/sep-2468.yaml @@ -4,10 +4,6 @@ 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-as-include-iss - text: 'MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2.' - - check: sep-2468-as-advertise-iss-supported - 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).' - 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 @@ -19,5 +15,11 @@ requirements: - 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' From ed1ff0ca030e6025e8bde0c95bd2e95b2ac3a9d0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Sat, 23 May 2026 00:24:03 +0100 Subject: [PATCH 145/226] SEP-2106: traceability YAML + JSON Schema 2020-12 conformance checks (#295) * chore: add SEP-2106 requirement-traceability YAML (tools JSON Schema 2020-12) * Drop structured-content text-fallback row from SEP-2106 That SHOULD predates SEP-2106 (pre-existing context line in tools.mdx); 2106 only relaxes structuredContent to allow non-object values (permissive). Point spec_url at the SEP doc security section where the remaining obligations live. * feat: SEP-2106 JSON Schema 2020-12 vocabulary preservation checks Extend the json-schema-2020-12 server scenario (previously SEP-1613 only) with three checks that verify the broader JSON Schema 2020-12 vocabulary SEP-2106 permits in a tool inputSchema survives tools/list rather than being stripped: composition (allOf/anyOf), conditional (if/then/else), and reference ($anchor) keywords. - everything-server: add these keywords to json_schema_2020_12_tool's raw schema - add a negative-test server (sep-2106-stripped-schema.ts) plus a negative test asserting the checks emit FAILURE when the vocabulary is stripped * feat: SEP-2106 client scenario for network $ref dereferencing Add json-schema-ref-no-deref, a client-suite scenario that advertises a tool whose inputSchema contains a $ref pointing at a canary URL hosted by the scenario. The sep-2106-no-network-ref-deref check fails if the client fetches the canary while processing the schema, covering the SEP-2106 requirement that implementations MUST NOT automatically dereference network-URI $ref values. - register the scenario in the client scenarios list - everything-client: handle the scenario with the basic connect+listTools flow - tests: compliant client passes, a deliberately dereferencing client is caught, and a client that never lists tools cannot pass * Address review findings in SEP-2106 scenarios - json-schema-ref-deref: make getChecks() idempotent (fresh array per call, matching the http-standard-headers convention) and guard the async /mcp handler like the example servers do - json-schema-2020-12: include the SEP-2106 keywords ($anchor, allOf/anyOf, if/then/else) in the description's example inputSchema so a server that copies it verbatim passes the new checks - sep-2106-stripped-schema: default to port 3007 to match negative.test.ts and avoid sharing a default with sep-2549-no-caching-hints * Soft-gate SEP-2106 keyword checks on the negotiated protocol version The json-schema-2020-12 scenario is introducedIn 2025-11-25 (SEP-1613) but the three SEP-2106 keyword-preservation checks test draft-era behavior. Rather than failing servers that only negotiated 2025-11-25, gate the failure on the wire protocolVersion from the initialize handshake: preserved keywords are SUCCESS at any version, stripped keywords are FAILURE only when the server negotiated the draft version and SKIPPED otherwise. The published SDK does not advertise the draft version yet, so the negative test asserts SKIPPED for the stripped-schema server; the FAILURE branch is covered by unit tests on sep2106KeywordCheckStatus. --- .../clients/typescript/everything-client.ts | 6 + .../servers/typescript/everything-server.ts | 21 +- .../typescript/sep-2106-stripped-schema.ts | 84 ++++++++ .../client/json-schema-ref-deref.test.ts | 106 +++++++++ src/scenarios/client/json-schema-ref-deref.ts | 201 ++++++++++++++++++ src/scenarios/index.ts | 6 +- src/scenarios/server/json-schema-2020-12.ts | 174 ++++++++++++++- src/scenarios/server/negative.test.ts | 76 +++++++ src/seps/sep-2106.yaml | 15 ++ 9 files changed, 680 insertions(+), 9 deletions(-) create mode 100644 examples/servers/typescript/sep-2106-stripped-schema.ts create mode 100644 src/scenarios/client/json-schema-ref-deref.test.ts create mode 100644 src/scenarios/client/json-schema-ref-deref.ts create mode 100644 src/seps/sep-2106.yaml diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 496a368..74168f0 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -93,6 +93,12 @@ async function runBasicClient(serverUrl: string): Promise { registerScenarios(['initialize', '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, so the basic connect+listTools flow is the +// correct behavior here. +registerScenario('json-schema-ref-no-deref', runBasicClient); + // ============================================================================ // request-metadata scenario (SEP-2575) // ============================================================================ diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 36f9719..1d9dcc9 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -123,6 +123,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' }, @@ -132,8 +134,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 }; 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/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..b11d2d2 --- /dev/null +++ b/src/scenarios/client/json-schema-ref-deref.test.ts @@ -0,0 +1,106 @@ +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'; + +/** + * 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(); +} + +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('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(); + 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..91ccc9e --- /dev/null +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -0,0 +1,201 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ListToolsRequestSchema } 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 { + 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(): 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) => { + 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/index.ts b/src/scenarios/index.ts index 8abae1a..9b54e81 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -105,6 +105,7 @@ import { HttpCustomHeadersScenario, HttpInvalidToolHeadersScenario } from './client/http-custom-headers'; +import { JsonSchemaRefDerefScenario } from './client/json-schema-ref-deref'; // Pending client scenarios (not yet fully tested/implemented) const pendingClientScenariosList: ClientScenario[] = [ @@ -254,7 +255,10 @@ const scenariosList: Scenario[] = [ // HTTP Standardization scenarios (SEP-2243) new HttpStandardHeadersScenario(), new HttpCustomHeadersScenario(), - new HttpInvalidToolHeadersScenario() + new HttpInvalidToolHeadersScenario(), + + // JSON Schema network $ref dereferencing (SEP-2106) + new JsonSchemaRefDerefScenario() ]; // Core scenarios (tier 1 requirements) diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts index ae1d9c0..b5693f7 100644 --- a/src/scenarios/server/json-schema-2020-12.ts +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -1,25 +1,57 @@ /** - * 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 } from '../../types.js'; +import { + CheckStatus, + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; import { connectToServer } from './client-helper.js'; const EXPECTED_TOOL_NAME = 'json_schema_2020_12_tool'; const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; +/** + * 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 of what it negotiated. Stripping them is only a conformance + * FAILURE once the server claims a protocol version that includes SEP-2106 + * (the draft); a server that only negotiated an earlier dated version is not + * yet subject to the requirement, so the check is SKIPPED rather than failed. + */ +export function sep2106KeywordCheckStatus( + preserved: boolean, + negotiatedProtocolVersion: string | undefined +): CheckStatus { + if (preserved) { + return 'SUCCESS'; + } + return negotiatedProtocolVersion === DRAFT_PROTOCOL_VERSION + ? 'FAILURE' + : 'SKIPPED'; +} + export class JsonSchema2020_12Scenario implements ClientScenario { name = 'json-schema-2020-12'; readonly source = { introducedIn: '2025-11-25' } as const; - description = `Validates JSON Schema 2020-12 keyword preservation (SEP-1613). + 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 { @@ -30,6 +62,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema "type": "object", "$defs": { "address": { + "$anchor": "addressDef", "type": "object", "properties": { "street": { "type": "string" }, @@ -39,14 +72,26 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON 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" } }, + "allOf": [ + { "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] } + ], + "if": { + "properties": { "contactMethod": { "const": "phone" } }, + "required": ["contactMethod"] + }, + "then": { "required": ["phone"] }, + "else": { "required": ["email"] }, "additionalProperties": false } } \`\`\` -**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 { const checks: ConformanceCheck[] = []; @@ -56,9 +101,20 @@ 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); + // Negotiated wire protocolVersion from the initialize handshake; used to + // soft-gate the SEP-2106 checks below. + const negotiatedVersion = ( + connection.client.transport as { protocolVersion?: string } | undefined + )?.protocolVersion; const result = await connection.client.listTools(); // Find the test tool @@ -164,6 +220,110 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema } }); + // 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 negotiated + // protocol version (see sep2106KeywordCheckStatus): stripping the + // keywords is only a FAILURE for servers that negotiated the draft + // protocol version, and SKIPPED otherwise. + const skippedSuffix = ` (server negotiated protocol version ${negotiatedVersion ?? 'unknown'}; 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, + negotiatedVersion + ); + + 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, + negotiatedProtocolVersion: negotiatedVersion + } + }); + + // 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, + negotiatedVersion + ); + + 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, + negotiatedProtocolVersion: negotiatedVersion + } + }); + + // 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, + negotiatedVersion + ); + + 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 connection.close(); } catch (error) { checks.push({ diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 66b481c..d602d64 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -3,6 +3,11 @@ import path from 'path'; import { DNSRebindingProtectionScenario } from './dns-rebinding'; import { ResourcesNotFoundErrorScenario } from './resources'; import { CachingScenario } from './caching'; +import { + JsonSchema2020_12Scenario, + sep2106KeywordCheckStatus +} from './json-schema-2020-12'; +import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; function startServer(scriptPath: string, port: number): Promise { return new Promise((resolve, reject) => { @@ -148,4 +153,75 @@ describe('Server scenario negative tests', () => { } }, 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(`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. The stripped + // server negotiates 2025-11-25 (the published SDK does not advertise the + // draft protocol version), 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('sep2106KeywordCheckStatus (soft version gate)', () => { + it('passes preserved keywords at any negotiated version', () => { + expect(sep2106KeywordCheckStatus(true, DRAFT_PROTOCOL_VERSION)).toBe( + 'SUCCESS' + ); + expect(sep2106KeywordCheckStatus(true, LATEST_SPEC_VERSION)).toBe( + 'SUCCESS' + ); + expect(sep2106KeywordCheckStatus(true, undefined)).toBe('SUCCESS'); + }); + + it('fails stripped keywords only when the server negotiated the draft version', () => { + expect(sep2106KeywordCheckStatus(false, DRAFT_PROTOCOL_VERSION)).toBe( + 'FAILURE' + ); + expect(sep2106KeywordCheckStatus(false, LATEST_SPEC_VERSION)).toBe( + 'SKIPPED' + ); + expect(sep2106KeywordCheckStatus(false, undefined)).toBe('SKIPPED'); + }); + }); }); diff --git a/src/seps/sep-2106.yaml b/src/seps/sep-2106.yaml new file mode 100644 index 0000000..870484e --- /dev/null +++ b/src/seps/sep-2106.yaml @@ -0,0 +1,15 @@ +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' + + - 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' From 276c6805a8d81bf83975e9836c14c139d71d4ba6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Sat, 23 May 2026 00:28:09 +0100 Subject: [PATCH 146/226] chore: refresh SEP traceability manifest after #295, #303, and #304 (#305) * chore: refresh SEP traceability manifest after #303 and #304 Regenerated from a full client+server suite run against typescript-sdk@258f1a04a5d3 on current main: - sep-2468: the two new client checks are emitted (tested) and the two authorization-server rows move to excluded (#304) - sep-2243: 12 rows flip to tested now that the custom-header check IDs match their requirements (#303) * chore: regenerate the manifest on top of SEP-2106 (#295) Re-ran both suites after rebasing onto main so the manifest also picks up the new sep-2106 block (1 tested, 4 excluded, 3 untracked check IDs emitted by the server scenario with no yaml row yet). --- src/seps/traceability.json | 105 ++++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 32 deletions(-) diff --git a/src/seps/traceability.json b/src/seps/traceability.json index 5e2897b..bdf6c62 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "docs": "https://github.com/modelcontextprotocol/conformance/blob/main/AGENTS.md#traceability-manifest", - "source": "typescript-sdk@5fc42e9be115", + "source": "typescript-sdk@258f1a04a5d3", "seps": { "837": { "yaml": "src/seps/sep-837.yaml", @@ -41,6 +41,48 @@ "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", @@ -138,35 +180,35 @@ }, { "check": "sep-2243-client-supports-custom-headers", - "status": "untested", + "status": "tested", "text": "MCP clients MUST support this feature [custom headers via x-mcp-header]." }, { "check": "sep-2243-client-mirrors-designated-params", - "status": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "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": "untested", + "status": "tested", "text": "x-mcp-header MUST only be applied to parameters with primitive types (number, string, or boolean).", "url": "https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers" }, @@ -178,17 +220,17 @@ }, { "check": "sep-2243-client-encode-values", - "status": "untested", + "status": "tested", "text": "Clients MUST encode parameter values before including them in HTTP headers: number 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": "untested", + "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": "untested", + "status": "tested", "text": "Servers and intermediaries that need to inspect these values MUST decode them accordingly." }, { @@ -208,17 +250,17 @@ }, { "check": "sep-2243-server-reject-invalid-param-chars", - "status": "untested", + "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": "untested", + "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": "untested", + "status": "tested", "text": "Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32001 if any validation fails." } ], @@ -243,15 +285,14 @@ "unkeyed": [], "untracked": [ "sep-2243-invalid-tool-tools-list-gate", - "sep-2243-param-header-tool-call-gate", "sep-2243-server-accepts-whitespace-header-value", "sep-2243-server-no-xmcp-tool" ], "summary": { - "tested": 6, - "untested": 14, + "tested": 18, + "untested": 2, "excluded": 4, - "untracked": 4, + "untracked": 3, "unkeyed": 0 } }, @@ -573,20 +614,10 @@ "requirements": [ { "check": "sep-2468-client-validate-metadata-issuer", - "status": "untested", + "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-as-include-iss", - "status": "untested", - "text": "MCP authorization servers SHOULD include the iss parameter in authorization responses, including error responses, as defined in RFC9207 Section 2." - }, - { - "check": "sep-2468-as-advertise-iss-supported", - "status": "untested", - "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)." - }, { "check": "sep-2468-client-compare-iss-supported", "status": "tested", @@ -609,11 +640,21 @@ }, { "check": "sep-2468-client-no-normalization", - "status": "untested", + "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" @@ -622,9 +663,9 @@ "unkeyed": [], "untracked": [], "summary": { - "tested": 4, - "untested": 4, - "excluded": 1, + "tested": 6, + "untested": 0, + "excluded": 3, "untracked": 0, "unkeyed": 0 } From bcfd400c9a53dd133f315c3afafc7961e488a005 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Sat, 23 May 2026 00:35:25 +0100 Subject: [PATCH 147/226] chore: bump version to 0.2.0-alpha.0 (#306) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f7034d..ced0a02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.16", + "version": "0.2.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.1.16", + "version": "0.2.0-alpha.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 621436f..fe13329 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.1.16", + "version": "0.2.0-alpha.0", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From e2e65e5595839c7bb83a6ddd939fbcc3c2b51cc7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 18:00:44 +0100 Subject: [PATCH 148/226] Split draft-spec scenarios into their own suite (#317) * feat: split draft-spec scenarios into their own suite - add --suite draft to the server command, selecting scenarios whose source.introducedIn is the draft protocol version - exclude draft scenarios from the default active suite until the draft spec is published as a dated release - derive the client command's draft suite from source.introducedIn so the non-auth draft scenarios (SEP-2243/2575/2322/2106) are included alongside the auth ones - keep everything-server self-test coverage for draft scenarios in all-scenarios.test.ts (active + non-pending draft) - document available suites in the README and add suite-membership tests * fix: repair broken specReferences URLs - SEP-986: SEP/SEP-986.md no longer exists in the spec repo; point at the Tool Names section of the published 2025-11-25 tools page instead - SEP-990: the ext-auth spec file is enterprise-managed-authorization.mdx, not enterprise-oauth.mdx Fixes #313 --- .prettierignore | 4 ++ README.md | 4 +- src/index.ts | 9 +++- src/scenarios/client/auth/spec-references.ts | 2 +- src/scenarios/index.ts | 30 +++++++++-- src/scenarios/server/all-scenarios.test.ts | 17 +++++-- src/scenarios/server/tools.ts | 2 +- src/scenarios/spec-version.test.ts | 53 +++++++++++++++++++- vitest.config.ts | 10 +++- 9 files changed, 117 insertions(+), 14 deletions(-) diff --git a/.prettierignore b/.prettierignore index 43399fc..26ebc79 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,7 @@ # 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 + +# Local tooling workspaces (not part of the repo). +.claude/ +.sdk-under-test/ diff --git a/README.md b/README.md index 333bfcb..71a8cb2 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ 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`, `DRAFT-2026-v1`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios - `--expected-failures ` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures)) - `--timeout` - Timeout in milliseconds (default: 30000) @@ -81,7 +81,7 @@ 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" - `--expected-failures ` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures)) - `--verbose` - Show verbose output diff --git a/src/index.ts b/src/index.ts index a3019f0..4fd84a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { listExtensionScenarios, listBackcompatScenarios, listDraftScenarios, + listDraftClientScenarios, listScenariosForSpec, listClientScenariosForSpec, getScenarioSpecVersions, @@ -314,7 +315,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( @@ -378,9 +379,13 @@ program 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); } diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 908a04e..681c3e4 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -104,7 +104,7 @@ 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', diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 9b54e81..1422c90 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -206,13 +206,24 @@ const allClientScenariosList: ClientScenario[] = [ 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 @@ -329,8 +340,21 @@ 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 draftScenariosList.map((scenario) => scenario.name); + return draftSpecScenariosList.map((scenario) => scenario.name); +} + +export function listDraftClientScenarios(): string[] { + return draftClientScenariosList.map((scenario) => scenario.name); } export { listMetadataScenarios }; diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 9f2d014..bc697a3 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -1,6 +1,11 @@ import { spawn, ChildProcess } from 'child_process'; import { createServer } from 'net'; -import { getClientScenario, listActiveClientScenarios } from '../index'; +import { + getClientScenario, + listActiveClientScenarios, + listDraftClientScenarios, + listPendingClientScenarios +} from '../index'; import path from 'path'; function getFreePort(): Promise { @@ -116,8 +121,14 @@ 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)) + ]; for (const scenarioName of scenarios) { it(`${scenarioName}`, async () => { diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index 2fb4717..5875e72 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -21,7 +21,7 @@ const TOOLS_NAME_FORMAT_SPEC_REFS = [ }, { id: 'SEP-986', - url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/SEP/SEP-986.md' + url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names' } ]; diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index c77e43e..93b4662 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -3,11 +3,14 @@ import { listScenarios, listScenariosForSpec, listDraftScenarios, + listDraftClientScenarios, + listActiveClientScenarios, listExtensionScenarios, getScenarioSpecVersions, resolveSpecVersion, ALL_SPEC_VERSIONS, - scenarios + scenarios, + clientScenarios } from './index'; import { DATED_SPEC_VERSIONS, @@ -94,3 +97,51 @@ describe('specVersions helpers', () => { } }); }); + +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/vitest.config.ts b/vitest.config.ts index b0f36f9..bd0c67a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,15 @@ export default defineConfig({ globals: true, environment: 'node', include: ['**/*.test.ts'], - exclude: ['**/node_modules/**', 'dist', '.sdk-under-test'], + 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 From 076b417b23fc356505488b8093068469aace6341 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 21:10:23 +0100 Subject: [PATCH 149/226] Make the draft-spec scenarios internally consistent (fixes #311, #312, #315) (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(everything-server): serve resources and caching hints on the stateless draft path The stateless (SEP-2575) request path now answers resources/list, resources/templates/list and resources/read — including the SEP-2164 -32602 + data.uri error for unknown URIs — and includes the SEP-2549 ttlMs/cacheScope hints on its cacheable list results, matching what the draft-spec scenarios exercise. * feat: make the harness's own draft-spec traffic conformant Fixes the class of issues where a strictly-conformant SDK rejects the harness's requests for reasons unrelated to the behaviour under test (#311, #312, #315). - add a shared stateless draft request helper (draft-client.ts) that puts the cross-cutting SEP-2243 headers (Mcp-Method, Mcp-Name, MCP-Protocol-Version, Accept) and SEP-2575 _meta (protocolVersion, clientInfo, clientCapabilities) on every request, with overrides for negative cases and the SHOULD-level retry on UnsupportedProtocolVersionError - migrate the draft scenarios onto it: the SEP-2322 helpers and server-stateless now send Mcp-Method/Mcp-Name (#312); the SEP-2243 server scenarios drop the legacy initialize handshake and carry _meta on every raw request (#311); caching and sep-2164-resource-not-found are stateless DRAFT-2026-v1 requests instead of an SDK 2025-11-25 session (#315) - add a draft self-conformance gate (draft-self-conformance.test.ts) that points the harness's own draft drivers at the existing client-testing judge scenarios (request-metadata, http-standard-headers) and requires zero failures or warnings - align the request-metadata mock's unsupported-version rejection with the draft schema (-32004) and teach the everything-client to recognize it - rewrite the SEP-2549/SEP-2164 negative example servers as stateless draft servers so they keep exercising the violation, not the transport * refactor: rename the stateless request helpers and harden them per review - rename draft-client.ts to stateless-client.ts and name the helpers by feature (sendStatelessRequest, buildStandardHeaders, buildRequestMeta, withRequestMeta, CONFORMANCE_CLIENT_INFO); 'draft' now only appears in DRAFT_PROTOCOL_VERSION, the one name that tracks the moving target - retry only genuine UnsupportedProtocolVersionError rejections (-32004, tolerating -32001/-32602) and only with recognized versions; record the retry on the response - parse text/event-stream responses incrementally and resolve on the matching event instead of blocking until the timeout - keep the MCP-Protocol-Version header and _meta protocolVersion in sync when the version is overridden via meta overrides - make server-stateless's sendRpc SSE-tolerant - emit explicit setup failures in the SEP-2243 server scenarios when the tools/list discovery fails instead of misleading per-case results - latch the worst status per check in the request-metadata judge so an early violation cannot be masked by a later conformant request - rename the gate to harness-traffic-conformance.test.ts and derive its driver list from the draft suite with documented per-judge exclusions (35 pairings) - everything-server: advertise resources in stateless server/discover and use -32004 for unsupported protocol version - add unit tests for the helper (retry guard, SSE handling, header/_meta version sync, default headers) * refactor: slim the stateless client helper for the initial merge --- .../clients/typescript/everything-client.ts | 7 +- .../servers/typescript/everything-server.ts | 81 +++- .../typescript/sep-2164-empty-contents.ts | 72 ++-- .../typescript/sep-2549-no-caching-hints.ts | 211 ++++----- src/scenarios/client/request-metadata.test.ts | 3 +- src/scenarios/client/request-metadata.ts | 30 +- src/scenarios/server/caching.ts | 399 +++++++----------- src/scenarios/server/http-standard-headers.ts | 165 ++++---- .../server/input-required-result-helpers.ts | 52 +-- src/scenarios/server/resources.ts | 56 +-- src/scenarios/server/stateless-client.test.ts | 107 +++++ src/scenarios/server/stateless-client.ts | 274 ++++++++++++ src/scenarios/server/stateless.ts | 43 +- 13 files changed, 899 insertions(+), 601 deletions(-) create mode 100644 src/scenarios/server/stateless-client.test.ts create mode 100644 src/scenarios/server/stateless-client.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 74168f0..63ca051 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -155,7 +155,12 @@ async function runRequestMetadataClient(serverUrl: string): Promise { const clone = response.clone(); try { const errorResult = await clone.json(); - if (errorResult.error?.code === -32001) { + // -32004 is UnsupportedProtocolVersionError in the draft schema; + // -32001 is tolerated for servers that predate the dedicated code. + if ( + errorResult.error?.code === -32004 || + errorResult.error?.code === -32001 + ) { logger.debug( 'Received UnsupportedProtocolVersionError, starting negotiation...' ); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 1d9dcc9..258fe3d 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1227,13 +1227,13 @@ app.post('/mcp', async (req, res) => { }); } - // Protocol Version Negotiation Matrix (-32602, HTTP 400) + // Protocol Version Negotiation Matrix (-32004, HTTP 400) if (metaVersion !== 'DRAFT-2026-v1') { return res.status(400).json({ jsonrpc: '2.0', id, error: { - code: -32602, + code: -32004, message: 'UnsupportedProtocolVersionError', data: { supported: ['DRAFT-2026-v1'] } } @@ -1296,7 +1296,10 @@ app.post('/mcp', async (req, res) => { supportedVersions: ['DRAFT-2026-v1'], capabilities: { tools: { listChanged: true }, // Explicitly announce dynamic capabilities matching Section 7 expectations - prompts: { listChanged: true } + prompts: { listChanged: true }, + // resources/list, resources/templates/list and resources/read are + // served on this path, so the capability must be declared too. + resources: {} }, serverInfo: { name: 'everything-stateless-server', version: '1.0.0' } } @@ -1371,7 +1374,10 @@ app.post('/mcp', async (req, res) => { description: 'Diagnostic logging validator tool', inputSchema: { type: 'object', properties: {} } } - ] + ], + // SEP-2549 caching hints are required on cacheable list results. + ttlMs: 300000, + cacheScope: 'public' } }); } @@ -1387,7 +1393,9 @@ app.post('/mcp', async (req, res) => { name: 'test_input_required_result_prompt', description: 'MRTR: prompt that requires elicitation input' } - ] + ], + ttlMs: 300000, + cacheScope: 'public' } }); } @@ -1442,6 +1450,69 @@ app.post('/mcp', async (req, res) => { } } + // Resources on the stateless path (SEP-2575): SEP-2549 hints + SEP-2164 errors. + if (method === 'resources/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + 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' + } + }); + } + + if (method === 'resources/templates/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + resourceTemplates: [], + ttlMs: 300000, + cacheScope: 'public' + } + }); + } + + if (method === 'resources/read') { + const uri = params.uri as string | undefined; + if (uri === 'test://stateless-static-text') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + contents: [ + { + uri, + mimeType: 'text/plain', + text: 'Static text content from the stateless draft path.' + } + ], + ttlMs: 300000, + cacheScope: 'private' + } + }); + } + // SEP-2164: unknown resources get -32602 with the requested uri in data. + return res.status(200).json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'Resource not found', + data: { uri } + } + }); + } + if (method === 'tools/call') { const name = params.name; const inputResponses = params.inputResponses as diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts index 6ec1bdf..570b456 100644 --- a/examples/servers/typescript/sep-2164-empty-contents.ts +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -3,58 +3,44 @@ /** * SEP-2164 Negative Test Server * - * 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. + * 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. */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - ListResourcesRequestSchema, - ReadResourceRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; -function createServer() { - const server = new Server( - { name: 'sep-2164-empty-contents', version: '1.0.0' }, - { capabilities: { resources: {} } } - ); - - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [] - })); - - server.setRequestHandler(ReadResourceRequestSchema, async () => ({ - contents: [] - })); - - 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({ +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', - error: { - code: -32603, - message: `Internal error: ${error instanceof Error ? error.message : String(error)}` - }, - id: null + id, + result: { + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { resources: {} }, + serverInfo: { name: 'sep-2164-empty-contents', version: '1.0.0' } + } + }); + case 'resources/list': + return res.json({ jsonrpc: '2.0', id, result: { resources: [] } }); + case 'resources/read': + // Deliberately return an empty contents array instead of an error. + return res.json({ jsonrpc: '2.0', id, result: { contents: [] } }); + default: + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } }); - } } }); diff --git a/examples/servers/typescript/sep-2549-no-caching-hints.ts b/examples/servers/typescript/sep-2549-no-caching-hints.ts index 8fe7952..318c9fd 100644 --- a/examples/servers/typescript/sep-2549-no-caching-hints.ts +++ b/examples/servers/typescript/sep-2549-no-caching-hints.ts @@ -3,151 +3,96 @@ /** * SEP-2549 Negative Test Server * - * 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. + * 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 { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - ListToolsRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; -import { randomUUID } from 'crypto'; - -const transports: Record = {}; - -function isInitializeRequest(body: any): boolean { - return body?.method === 'initialize'; -} - -function createServer() { - const server = new Server( - { name: 'sep-2549-no-caching-hints', version: '1.0.0' }, - { - capabilities: { - tools: {}, - resources: {}, - prompts: {} - } - } - ); - - // Deliberately omit ttlMs and cacheScope from all responses - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test_tool', - description: 'A test tool', - inputSchema: { type: 'object' as const } - } - ] - })); - - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [ - { - name: 'test_prompt', - description: 'A test prompt' - } - ] - })); - - server.setRequestHandler(ListResourcesRequestSchema, async () => ({ - resources: [ - { - uri: 'test://static-text', - name: 'Static Text', - description: 'A static text resource' - } - ] - })); - - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ - resourceTemplates: [] - })); - - server.setRequestHandler(ReadResourceRequestSchema, async () => ({ - contents: [ - { - uri: 'test://static-text', - mimeType: 'text/plain', - text: 'Static text content.' - } - ] - })); - - return server; -} const app = express(); app.use(express.json()); -app.post('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; +app.post('/mcp', (req, res) => { + const body = req.body || {}; + const id = body.id ?? null; + const method = body.method; - try { - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res, req.body); - return; - } - - if (!sessionId && isInitializeRequest(req.body)) { - const server = createServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - transports[newSessionId] = transport; + // Deliberately omit ttlMs and cacheScope from every result below. + switch (method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'sep-2549-no-caching-hints', version: '1.0.0' } } }); - transport.onclose = () => { - const sid = transport.sessionId; - if (sid) delete transports[sid]; - }; - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - return; - } - - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Invalid or missing session ID' }, - id: null - }); - } catch (error) { - if (!res.headersSent) { - res.status(500).json({ + case 'tools/list': + return res.json({ jsonrpc: '2.0', - error: { - code: -32603, - message: `Internal error: ${error instanceof Error ? error.message : String(error)}` - }, - id: null + 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' } }); - } - } -}); - -app.get('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res); - } else { - res.status(400).json({ error: 'Invalid or missing session ID' }); - } -}); - -app.delete('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res); - } else { - res.status(400).json({ error: 'Invalid or missing session ID' }); } }); diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index 6938464..b2f9b63 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -104,9 +104,10 @@ async function incompatibleVersionClient(serverUrl: string) { if (response.status === 400) { const body = await response.json(); - if (body.error?.code === -32001) { + if (body.error?.code === -32004 || body.error?.code === -32001) { return body; // Abort cleanly } + return body; } return response.json(); } diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 47d4f00..9d9fd69 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -3,9 +3,23 @@ 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 @@ -92,10 +106,17 @@ export class RequestMetadataScenario implements Scenario { private addOrUpdateCheck(check: ConformanceCheck): void { const index = this.checks.findIndex((c) => c.id === check.id); - if (index !== -1) { - this.checks[index] = check; - } else { + 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; } } @@ -256,7 +277,8 @@ export class RequestMetadataScenario implements Scenario { jsonrpc: '2.0', id: request.id ?? null, error: { - code: -32001, + // UnsupportedProtocolVersionError per the draft schema. + code: -32004, message: 'Unsupported protocol version', data: { supported: [DRAFT_PROTOCOL_VERSION] diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts index 5c58002..7b1bd22 100644 --- a/src/scenarios/server/caching.ts +++ b/src/scenarios/server/caching.ts @@ -10,14 +10,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { connectToServer } from './client-helper'; -import { - ListToolsResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ReadResourceResultSchema -} from '@modelcontextprotocol/sdk/types.js'; +import { sendStatelessRequest } from './stateless-client'; const SPEC_REFS = [ { @@ -98,259 +91,181 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p const checks: ConformanceCheck[] = []; const allFields: Array<{ endpoint: string; fields: CachingFields }> = []; - try { - const connection = await connectToServer(serverUrl); - - // 1. tools/list + // SEP-2549 only exists in the draft spec, so each cacheable endpoint is + // queried over the stateless path (SEP-2575): protocolVersion DRAFT-2026-v1 + // plus the cross-cutting _meta and standard headers (issue #315). + const queryEndpoint = async ( + checkId: string, + checkName: string, + endpoint: string, + params?: Record + ): Promise | undefined> => { + const description = `${endpoint} response includes ttlMs and cacheScope caching hints`; try { - const toolsResult = await connection.client.request( - { method: 'tools/list', params: {} }, - ListToolsResultSchema + const response = await sendStatelessRequest( + serverUrl, + endpoint, + params ); - const fields = extractCachingFields( - toolsResult as Record - ); - allFields.push({ endpoint: 'tools/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-tools-list-caching-hints', - 'ToolsListCachingHints', - 'tools/list', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-tools-list-caching-hints', - name: 'ToolsListCachingHints', - description: - 'tools/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `tools/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - - // 2. prompts/list - try { - const promptsResult = await connection.client.request( - { method: 'prompts/list', params: {} }, - ListPromptsResultSchema - ); - const fields = extractCachingFields( - promptsResult as Record - ); - allFields.push({ endpoint: 'prompts/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-prompts-list-caching-hints', - 'PromptsListCachingHints', - 'prompts/list', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-prompts-list-caching-hints', - name: 'PromptsListCachingHints', - description: - 'prompts/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `prompts/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - - // 3. resources/list - let firstResourceUri: string | undefined; - try { - const resourcesResult = await connection.client.request( - { method: 'resources/list', params: {} }, - ListResourcesResultSchema - ); - const fields = extractCachingFields( - resourcesResult as Record - ); - allFields.push({ endpoint: 'resources/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-resources-list-caching-hints', - 'ResourcesListCachingHints', - 'resources/list', - fields - ) - ); - // Capture the first resource URI for the resources/read check - if (resourcesResult.resources && resourcesResult.resources.length > 0) { - firstResourceUri = resourcesResult.resources[0].uri; - } - } catch (error) { - checks.push({ - id: 'sep-2549-resources-list-caching-hints', - name: 'ResourcesListCachingHints', - description: - 'resources/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `resources/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - - // 4. resources/templates/list - try { - const templatesResult = await connection.client.request( - { method: 'resources/templates/list', params: {} }, - ListResourceTemplatesResultSchema - ); - const fields = extractCachingFields( - templatesResult as Record - ); - allFields.push({ endpoint: 'resources/templates/list', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-resources-templates-list-caching-hints', - 'ResourcesTemplatesListCachingHints', - 'resources/templates/list', - fields - ) - ); - } catch (error) { - checks.push({ - id: 'sep-2549-resources-templates-list-caching-hints', - name: 'ResourcesTemplatesListCachingHints', - description: - 'resources/templates/list response includes ttlMs and cacheScope caching hints', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `resources/templates/list request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS - }); - } - - // 5. resources/read — use first resource from resources/list - if (firstResourceUri) { - try { - const readResult = await connection.client.request( - { - method: 'resources/read', - params: { uri: firstResourceUri } - }, - ReadResourceResultSchema - ); - const fields = extractCachingFields( - readResult as Record - ); - allFields.push({ endpoint: 'resources/read', fields }); - checks.push( - buildPresenceCheck( - 'sep-2549-resources-read-caching-hints', - 'ResourcesReadCachingHints', - 'resources/read', - fields - ) - ); - } catch (error) { + const result = response.body?.result; + if (!result) { + const error = response.body?.error; checks.push({ - id: 'sep-2549-resources-read-caching-hints', - name: 'ResourcesReadCachingHints', - description: - 'resources/read response includes ttlMs and cacheScope caching hints', + id: checkId, + name: checkName, + description, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: `resources/read request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS + errorMessage: error + ? `${endpoint} returned JSON-RPC error ${error.code}: ${error.message}` + : `${endpoint} returned HTTP ${response.status} with no result`, + specReferences: SPEC_REFS, + details: { httpStatus: response.status, error } }); + return undefined; } + const fields = extractCachingFields(result); + allFields.push({ endpoint, fields }); + checks.push(buildPresenceCheck(checkId, checkName, endpoint, fields)); + return result; + } catch (error) { + checks.push({ + id: checkId, + name: checkName, + description, + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: SPEC_REFS + }); + return undefined; } + }; - // 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`); - } - } - } + // 1. tools/list + await queryEndpoint( + 'sep-2549-tools-list-caching-hints', + 'ToolsListCachingHints', + 'tools/list' + ); - 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 - })) - } - }); + // 2. prompts/list + await queryEndpoint( + 'sep-2549-prompts-list-caching-hints', + 'PromptsListCachingHints', + 'prompts/list' + ); - // 7. Aggregate: cacheScope must be "public" or "private" - const scopeErrors: string[] = []; - const endpointsWithScope = allFields.filter( - (f) => f.fields.hasCacheScope + // 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 } ); - 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"` - ); - } - } - } - + } 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-cache-scope-valid', - name: 'CacheScopeValid', - description: 'All cacheScope values are "public" or "private"', - status: scopeErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + 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: - scopeErrors.length > 0 ? scopeErrors.join('; ') : undefined, - specReferences: SPEC_REFS, - details: { - endpoints: allFields.map((f) => ({ - endpoint: f.endpoint, - cacheScope: f.fields.cacheScope - })) - } - }); - - await connection.close(); - } catch (error) { - // Connection-level failure — push a single failure check - 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)}`, + 'resources/read was not exercised: resources/list failed or returned no resources.', specReferences: SPEC_REFS }); } + // 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/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 528b958..fd8135f 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -20,7 +20,7 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { connectToServer } from './client-helper'; +import { withRequestMeta, sendStatelessRequest } from './stateless-client'; const SPEC_REFERENCE = { id: 'SEP-2243-Server-Validation', @@ -264,51 +264,39 @@ export class HttpHeaderValidationScenario implements ClientScenario { async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; - let sessionId: string | null = null; try { - // Establish a session via normal SDK initialization - const connection = await connectToServer(serverUrl); - const toolsResult = await connection.client.listTools(); - await connection.close(); - - // Get a fresh session for raw requests - const initResponse = await sendRawRequest( - serverUrl, - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: DRAFT_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: 'conformance-test-raw-client', - version: '1.0.0' - } - } - }, - { 'Mcp-Method': 'initialize' } - ); - - if (initResponse.status === 200) { - const rawSid = initResponse.headers['mcp-session-id']; - sessionId = (Array.isArray(rawSid) ? rawSid[0] : rawSid) || null; - const notifHeaders: Record = { - 'Mcp-Method': 'notifications/initialized' - }; - if (sessionId) notifHeaders['mcp-session-id'] = sessionId; - await sendRawRequest( - serverUrl, - { jsonrpc: '2.0', method: 'notifications/initialized' }, - notifHeaders - ); + // 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 } + }); + return checks; } + const toolsResult = toolsResponse.body.result as { + tools?: Array<{ name: string; inputSchema?: unknown }>; + }; const baseHeaders: Record = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; - if (sessionId) baseHeaders['mcp-session-id'] = sessionId; let idCounter = 100; const nextId = () => idCounter++; @@ -501,7 +489,13 @@ export class HttpHeaderValidationScenario implements ClientScenario { details: Record ): Promise { try { - const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id }; + // 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 @@ -565,12 +559,37 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; - let sessionId: string | null = null; try { - const connection = await connectToServer(serverUrl); - const toolsResult = await connection.client.listTools(); - await connection.close(); + // 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) => { @@ -602,43 +621,9 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario return checks; } - // Get a fresh session for raw requests - const initResponse = await sendRawRequest( - serverUrl, - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: DRAFT_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: 'conformance-test-base64-client', - version: '1.0.0' - } - } - }, - { 'Mcp-Method': 'initialize' } - ); - - if (initResponse.status === 200) { - const rawSid2 = initResponse.headers['mcp-session-id']; - sessionId = (Array.isArray(rawSid2) ? rawSid2[0] : rawSid2) || null; - const notifHeaders: Record = { - 'Mcp-Method': 'notifications/initialized' - }; - if (sessionId) notifHeaders['mcp-session-id'] = sessionId; - await sendRawRequest( - serverUrl, - { jsonrpc: '2.0', method: 'notifications/initialized' }, - notifHeaders - ); - } - const baseHeaders: Record = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; - if (sessionId) baseHeaders['mcp-session-id'] = sessionId; // Find the first x-mcp-header annotated STRING property // that is callable with minimal arguments to avoid schema validation failures @@ -828,6 +813,16 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario // Declared-but-unemitted -> FAILURE. Reached only when setup threw partway // through (the gate-out paths emit SKIPPED 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({ @@ -841,8 +836,6 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario specReferences: [SPEC_REFERENCE_CUSTOM] }); } - - return checks; } /** @@ -890,10 +883,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario jsonrpc: '2.0', id: nextId(), method: 'tools/call', - params: { + // 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, @@ -974,10 +969,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario jsonrpc: '2.0', id: nextId(), method: 'tools/call', - params: { + // 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, diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index a5b73ab..689807c 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -5,60 +5,32 @@ * and a stateless JSON-RPC transport helper. */ -import { DRAFT_PROTOCOL_VERSION } from '../../types'; - // ─── JSON-RPC Types ────────────────────────────────────────────────────────── -export interface JsonRpcResponse { - jsonrpc: '2.0'; - id: number; - result?: Record; - error?: { code: number; message: string; data?: unknown }; -} +export type { JsonRpcResponse } from './stateless-client'; // ─── Stateless RPC Helper ──────────────────────────────────────────────────── -let nextId = 1; +import { sendStatelessRequest, JsonRpcResponse } from './stateless-client'; /** * Send a stateless JSON-RPC request (SEP-2575 pattern). - * Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities. + * 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 ): Promise { - const id = nextId++; - - const enrichedParams = { - ...params, - _meta: { - 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, - 'io.modelcontextprotocol/clientInfo': { - name: 'conformance-test-client', - version: '1.0.0' - }, - 'io.modelcontextprotocol/clientCapabilities': { - sampling: {}, - elicitation: {}, - roots: { listChanged: true } - }, - ...(params?._meta as Record | undefined) - } - }; - - const response = await fetch(serverUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION - }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, params: enrichedParams }) - }); - - return (await response.json()) as JsonRpcResponse; + const response = await sendStatelessRequest(serverUrl, method, params); + 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 ─────────────────────────────────────────────── diff --git a/src/scenarios/server/resources.ts b/src/scenarios/server/resources.ts index be332d4..36f5660 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -8,10 +8,10 @@ import { DRAFT_PROTOCOL_VERSION } from '../../types'; import { connectToServer } from './client-helper'; +import { sendStatelessRequest } from './stateless-client'; import { TextResourceContents, - BlobResourceContents, - McpError + BlobResourceContents } from '@modelcontextprotocol/sdk/types.js'; export class ResourcesListScenario implements ClientScenario { @@ -484,9 +484,13 @@ This scenario does not require the server to register any specific resource — } ]; - let connection; + // SEP-2164 is a draft-spec requirement, so the request is sent statelessly + // with the draft protocol version and the cross-cutting _meta/headers. + let response; try { - connection = await connectToServer(serverUrl); + response = await sendStatelessRequest(serverUrl, 'resources/read', { + uri: nonexistentUri + }); } catch (error) { checks.push({ id: 'sep-2164-error-code', @@ -495,19 +499,16 @@ This scenario does not require the server to register any specific resource — 'Server returns -32602 (Invalid Params) for non-existent resource', status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`, + 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 connection.client.readResource({ uri: nonexistentUri }); - } catch (error) { - caughtError = error; - } + const result = response.body?.result as + | { contents?: unknown[] } + | undefined; + const rpcError = response.body?.error; // Check 1: MUST NOT return an empty contents array const returnedEmptyContents = @@ -531,13 +532,12 @@ This scenario does not require the server to register any specific resource — }); // Check 2: SHOULD return JSON-RPC error with code -32602 - const errorCode = - caughtError instanceof McpError ? caughtError.code : 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 (!(caughtError instanceof McpError)) { - errorCodeMessage = `Expected a JSON-RPC error, got: ${caughtError instanceof Error ? caughtError.message : String(caughtError)}`; + } else if (!rpcError) { + errorCodeMessage = `Expected a JSON-RPC error, got HTTP ${response.status} with no JSON-RPC error in the body.`; } else if (errorCode !== -32602) { errorCodeMessage = `Expected error code -32602 (Invalid Params), got ${errorCode}. ` + @@ -563,10 +563,7 @@ This scenario does not require the server to register any specific resource — }); // Check 3: SHOULD include uri in error data field - const errorData = - caughtError instanceof McpError - ? (caughtError.data as { uri?: string } | undefined) - : undefined; + const errorData = rpcError?.data as { uri?: string } | undefined; const dataUriMatches = errorData?.uri === nonexistentUri; checks.push({ @@ -574,19 +571,13 @@ This scenario does not require the server to register any specific resource — name: 'ResourcesNotFoundDataUri', description: 'Server includes the requested URI in the error data field (SHOULD)', - status: - caughtError instanceof McpError - ? dataUriMatches - ? 'SUCCESS' - : 'WARNING' - : 'FAILURE', + status: rpcError ? (dataUriMatches ? 'SUCCESS' : 'WARNING') : 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: - caughtError instanceof McpError - ? 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.', + 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, @@ -594,7 +585,6 @@ This scenario does not require the server to register any specific resource — } }); - await connection.close(); return checks; } } diff --git a/src/scenarios/server/stateless-client.test.ts b/src/scenarios/server/stateless-client.test.ts new file mode 100644 index 0000000..76e21cf --- /dev/null +++ b/src/scenarios/server/stateless-client.test.ts @@ -0,0 +1,107 @@ +/** + * 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, + CONFORMANCE_CLIENT_INFO, + DEFAULT_CLIENT_CAPABILITIES +} from './stateless-client'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +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: [] } + }) + ); + }); + }); + 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: [] }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/src/scenarios/server/stateless-client.ts b/src/scenarios/server/stateless-client.ts new file mode 100644 index 0000000..4a66a6b --- /dev/null +++ b/src/scenarios/server/stateless-client.ts @@ -0,0 +1,274 @@ +/** + * Stateless request helpers for server scenarios (SEP-2575 + SEP-2243). + * + * Every request the harness sends to a server under test carries the + * cross-cutting obligations regardless of what a scenario actually tests: + * the standard headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name, Accept) + * and the `_meta` fields (protocolVersion, clientInfo, clientCapabilities), + * pinned to `DRAFT_PROTOCOL_VERSION`. Scenarios exercising these SEPs MUST + * build their requests through these helpers 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 } from '../../types'; + +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; 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; + } + 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. + */ +export function buildStandardHeaders( + method: string, + params?: Record, + options: { headers?: Record } = {} +): Record { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': 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. + */ +export function withRequestMeta( + params?: Record +): Record { + return { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + '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 } = {} +): Promise { + const id = nextRequestId++; + const headers = buildStandardHeaders(method, params, { + headers: options.headers + }); + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + params: withRequestMeta(params) + }); + + 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); + return { + status: res.status, + headers: res.headers, + contentType, + events, + body: matched + }; + } + + const text = await res.text(); + try { + return { + status: res.status, + headers: res.headers, + contentType, + body: text ? (JSON.parse(text) as JsonRpcResponse) : undefined + }; + } 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(); + } +} diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index def16e4..ed97928 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -7,6 +7,10 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; +import { + buildStandardHeaders, + readSseJsonRpcResponse +} from './stateless-client'; const SPEC_REF = [ { @@ -115,12 +119,13 @@ export class ServerStatelessScenario implements ClientScenario { headersOverrides?: Record, id: string | number | null = 1 ) => { - const headers = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION, - ...headersOverrides - }; + // 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 + }); const body = JSON.stringify({ jsonrpc: '2.0', @@ -131,10 +136,22 @@ export class ServerStatelessScenario implements ClientScenario { const res = await fetch(serverUrl, { method: 'POST', headers, body }); let data: any = null; - try { - data = await res.json(); - } catch { - // Response might not be JSON + // 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 + } } return { res, data }; }; @@ -150,11 +167,7 @@ export class ServerStatelessScenario implements ClientScenario { timeoutMs = 1000, onFirstFrame?: () => Promise ): Promise => { - const headers = { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION - }; + const headers = buildStandardHeaders(method, params); const body = JSON.stringify({ jsonrpc: '2.0', From 7d3b897618927422a27c98e520c999ad07dcc9d6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 26 May 2026 21:12:23 +0100 Subject: [PATCH 150/226] chore: bump version to 0.2.0-alpha.1 (#320) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ced0a02..5647308 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.0", + "version": "0.2.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.0", + "version": "0.2.0-alpha.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index fe13329..90b53c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.0", + "version": "0.2.0-alpha.1", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From b1bb7298e82db070479c53ff40d0470f5e86d4fc Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:09:13 +0100 Subject: [PATCH 151/226] feat: version-aware Connection abstraction for server scenarios (#318) * feat: vendor spec schema types per-version Copies schema/{version}/schema.ts from the modelcontextprotocol spec repo into src/spec-types/{version}.ts so the conformance suite can type against draft spec versions before any SDK ships them. npm run sync-schema -- refreshes the copies and records the spec commit in src/spec-types/SOURCE. * feat: add Connection abstraction and RunContext Connection encapsulates how the conformance suite talks to a server-under-test for a given spec version: - connectStateful: 2025-x lifecycle. Thin adapter over the SDK Client (initialize handshake, session id, SSE handled by the SDK). - connectStateless: 2026-x lifecycle (SEP-2575). Raw fetch with per-request _meta + MCP-Protocol-Version header. Decoupled from the SDK so the suite can test draft spec versions before the SDK implements them. connectFor(specVersion) picks the implementation. RunContext bundles serverUrl, specVersion and a bound connect() for the runner to hand to each scenario. Nothing uses this yet; wiring follows in the next commit. * refactor: thread RunContext through ClientScenario.run ClientScenario.run(serverUrl) becomes run(ctx: RunContext). The runner builds the context from --spec-version and the server URL; scenarios destructure ctx.serverUrl and otherwise behave identically. No scenario uses ctx.connect() yet, so behaviour is unchanged: 214/214 tests pass, all-scenarios.test.ts still drives the everything-server fixture exactly as before. Test files use a testContext(url) helper to construct a RunContext. The authorization-server scenario list is retyped to ClientScenarioForAuthorizationServer since those scenarios test an OAuth server, not an MCP server, and keep run(serverUrl). * refactor: migrate server scenarios to ctx.connect() + conn.request() 22 carry-forward and lifecycle scenarios now go through the Connection abstraction instead of connectToServer + SDK Client: - tools.ts (8): list, call x6, with-progress - prompts.ts (5) - resources.ts (7): list, read x3, subscribe x2, not-found - utils.ts (3): completion, ping, set-level - json-schema-2020-12.ts, caching.ts, http-standard-headers.ts Result types come from spec-types/{introducedIn}. Same scenario code now passes under both --spec-version 2025-11-25 (SDK-backed stateful) and --spec-version draft (raw stateless). ToolsCallSampling/Elicitation/WithLogging and elicitation-* keep connectToServer (need setRequestHandler/setLoggingLevel SDK surface); they are tagged removedIn: DRAFT in the next commit. stateless.ts and input-required-result.ts keep their sendRpc helper; migrating those is deferred to the DRAFT-scenario coherence pass. * fix: normalize Connection error to JsonRpcError; clean up RunContext import connectStateful now catches the SDK's McpError and rethrows as JsonRpcError so scenarios always see the same error class regardless of which Connection impl ran. ResourcesNotFoundError uses instanceof JsonRpcError instead of duck-typing. types.ts uses a normal top-level import for RunContext instead of an inline import() type. * fix(dns-rebinding): use version-appropriate probe body The 'valid Host accepted' check was sending an initialize body, which a 2026 server returns 404 for. Probe body is now picked from ctx.specVersion: initialize for the stateful lifecycle, server/discover with _meta for the stateless lifecycle. The Host/Origin rejection check is unchanged since rejection happens before body parsing. * feat(everything-server): route stateless carry-forward methods to McpServer Adds an in-memory dispatch client connected to the same McpServer the stateful path uses. Stateless requests for tools/call, resources/*, prompts/get and completion/complete that fall through the MRTR-specific handlers are routed to it, so the fixture serves the carry-forward scenarios under --spec-version draft without duplicating ~500 lines of tool/resource/prompt registrations. tools/list now merges the McpServer's tool list with the MRTR-only stubs so json-schema-2020-12 finds its tool. draft suite against the fixture: 36/39 (was 13/39). Remaining 3 are fixture-side SEP gaps (no SSE forwarding for progress, no SEP-2549 ttlMs, no SEP-2243 Mcp-Method validation in the stateless path). * refactor(connection): drop unused RequestOptions; move sdk-client; add tests Addresses self-review findings on the new connection module: - RequestOptions.handlers/.meta and ServerRequestHandler removed: zero callers (the scenarios that motivated them are deferred). They can be reintroduced when something actually uses them. - scenarios/server/client-helper.ts moved to connection/sdk-client.ts so the connection module no longer depends on the scenarios module. - connectStateless: handle CRLF SSE event separators; throw a useful error for non-JSON/non-SSE responses instead of a JSON parse error. - JSONRPCNotification consistently imported from spec-types/2025-11-25. - New connection.test.ts (8 tests) covering connectFor selection, _meta injection, error mapping, SSE LF/CRLF parsing, and the server-request-on-stream rejection. * fix: address bughunt findings (response.ok check; targetVersion naming) - connectStateless: throw on non-2xx responses that lack a JSON-RPC error envelope (e.g. gateway/framework error JSON), matching the stateful path's behavior. Previously such a response would return undefined as the result. - json-schema-2020-12: rename negotiatedVersion to targetVersion and reword the skip message and details field. The value is ctx.specVersion (the run's --spec-version), not a negotiated version; the old wording was misleading. Drop dead 'unknown' fallback (specVersion is required) and the corresponding undefined test cases. * fix(sse-multiple-streams): keep scenario in draft; version-aware request scaffolding Concurrent POSTs each answered with JSON or their own SSE stream are core transport behavior in the draft spec too, so removedIn was wrong for this scenario. Only the request scaffolding is version-specific: stateful runs keep the initialize/session-id setup, draft runs send _meta + the MCP-Protocol-Version header on each request instead. The two checks are unchanged. * refactor: fold stateless-client helpers into connection/stateless The two stateless request paths were ~90% duplicated: - scenarios/server/stateless-client.ts (main #319): buildStandardHeaders, withRequestMeta, readSseJsonRpcResponse, sendStatelessRequest -> raw {status, body}; sends Mcp-Method/Mcp-Name (SEP-2243); has timeout - connection/stateless.ts (this PR): connectStateless -> Connection.request -> throws JsonRpcError; missing Mcp-Method/Mcp-Name; no timeout connection/stateless.ts now exports both layers: - low-level sendStatelessRequest() (and the building blocks) moved verbatim from stateless-client.ts - connectStateless() rebuilt as a thin wrapper over sendStatelessRequest(): classifies SSE events into the notification sink, surfaces server->client requests on the response stream as a spec violation, throws JsonRpcError on error responses So connectStateless() now also picks up the SEP-2243 headers and the 10s timeout that the previous impl lacked. stateless-client.ts deleted; its 4 importers + test file repointed at '../../connection'. Test file moved to connection/stateless.test.ts. Co-Authored-By: Claude Opus 4.8 * fix(everything-server): make stateless resources reach the carry-forward dispatch The merge of main left two competing handler sets for resources/read, resources/list and resources/templates/list: the inline stateless handlers (test://stateless-static-text + a catch-all -32602) and the carry-forward dispatch to the McpServer-registered resources. The catch-all made the carry-forward unreachable, so test://static-text, test://static-binary and the templates errored under --spec-version draft. Now resources/list and resources/templates/list dispatch to the McpServer and merge with the inline entries (same pattern as tools/list and prompts/list), and resources/read keeps the inline static-text response but falls through to the carry-forward for unknown URIs (preserving SEP-2164 data.uri on errors). Also wrap the tools/list and prompts/list dispatch in try/finally so dispatch.close() always runs, matching tools/call. Co-Authored-By: Claude Opus 4.8 * fix(scenarios): send SEP-2243 Mcp-Method header on hand-rolled stateless requests sse-multiple-streams and dns-rebinding bypass sendStatelessRequest() because they need raw fetch/undici for low-level inspection, but their hand-built draft-path headers omitted Mcp-Method, so a strictly-conformant draft server would 400 them before the behaviour under test runs. Build the headers via buildStandardHeaders() instead, layering the scenario-specific overrides (Accept ordering, Host/Origin) on top. Co-Authored-By: Claude Opus 4.8 * fix(runner): infer draft spec version for draft-only scenarios When --spec-version is omitted, the runner defaulted to LATEST_SPEC_VERSION, so draft-only scenarios (caching, resources-not-found, ...) silently ran over the stateful connection instead of the stateless draft transport they were written for; all-scenarios.test.ts had the same problem via testContext(). Infer the spec version from scenario.source.introducedIn (as negative.test.ts already does) when no explicit version is given. Co-Authored-By: Claude Opus 4.8 * fix(connection): only throw JsonRpcError for properly-shaped error objects connectStateless() destructured response.body.error unconditionally, so a proxy returning {"error": "upstream timeout"} produced a JsonRpcError with undefined code/message and lost the HTTP status. Require an object with a numeric code before treating it as a JSON-RPC error; otherwise surface the HTTP status and raw body in the thrown Error. Adds a unit test for the non-conformant 502 case. Co-Authored-By: Claude Opus 4.8 * fix(connection): align connectStateful normalization with connectStateless Two consistency nits from automated review: - Collected notifications were missing the jsonrpc field (the SDK's Zod parsing strips it), violating the JSONRPCNotification type contract. - McpError messages kept the SDK's "MCP error : " prefix, so JsonRpcError.message differed between the stateful and stateless impls. Also restore progressToken filtering in tools-call-with-progress, which was lost when the scenario migrated from the SDK onprogress callback to conn.notifications: the server must echo the request's token back, and notifications for other tokens are now ignored. Co-Authored-By: Claude Opus 4.8 * feat(runner): skip inapplicable scenario/spec-version combos; add --force; plumb spec version to the wire Running a draft-only scenario with an explicit dated --spec-version (e.g. server-stateless --spec-version 2025-11-25) used to silently pass: the flag only affected ctx.connect(), which hand-rolling scenarios never call, so they sent draft requests regardless and the run looked green while testing nothing the flag claimed. Three changes: - buildStandardHeaders / withRequestMeta / sendStatelessRequest / connectStateless accept an optional specVersion (default: draft), and hand-rolling scenarios pass ctx.specVersion through, so requests declare the version the run was invoked with. - The runner skips (exit 0) when an explicit --spec-version falls outside the scenario's introducedIn/removedIn window, with a message instead of a misleading green run. - A new --force flag overrides the skip for deliberate mismatch probing; combined with the plumbing, a forced server-stateless run at 2025-11-25 now genuinely sends that version (8/20 checks pass against the everything-server instead of a vacuous 26/26). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Paul Carleton Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + .prettierignore | 5 + .../servers/typescript/everything-server.ts | 398 ++- package.json | 1 + scripts/sync-schema.ts | 52 + src/connection/connection.test.ts | 130 + src/connection/index.ts | 78 + .../sdk-client.ts} | 0 src/connection/select.ts | 25 + src/connection/stateful.ts | 71 + .../stateless.test.ts} | 55 +- .../stateless.ts} | 118 +- src/connection/testing.ts | 19 + src/index.ts | 17 +- src/runner/server.test.ts | 52 + src/runner/server.ts | 59 +- src/scenarios/index.ts | 23 +- src/scenarios/server/all-scenarios.test.ts | 12 +- src/scenarios/server/caching.ts | 76 +- src/scenarios/server/dns-rebinding.ts | 92 +- src/scenarios/server/elicitation-defaults.ts | 17 +- src/scenarios/server/elicitation-enums.ts | 17 +- src/scenarios/server/http-standard-headers.ts | 12 +- .../server/input-required-result-helpers.ts | 4 +- src/scenarios/server/input-required-result.ts | 43 +- src/scenarios/server/json-schema-2020-12.ts | 56 +- src/scenarios/server/lifecycle.test.ts | 17 +- src/scenarios/server/lifecycle.ts | 17 +- src/scenarios/server/negative-mrtr.test.ts | 7 +- src/scenarios/server/negative.test.ts | 30 +- src/scenarios/server/prompts.ts | 46 +- src/scenarios/server/resources.ts | 105 +- src/scenarios/server/sse-multiple-streams.ts | 129 +- src/scenarios/server/sse-polling.ts | 15 +- src/scenarios/server/stateless.test.ts | 17 +- src/scenarios/server/stateless.ts | 17 +- src/scenarios/server/tools.ts | 147 +- src/scenarios/server/utils.ts | 45 +- src/spec-types/2025-03-26.ts | 1260 +++++++ src/spec-types/2025-06-18.ts | 1623 +++++++++ src/spec-types/2025-11-25.ts | 2587 ++++++++++++++ src/spec-types/README.md | 29 + src/spec-types/SOURCE | 1 + src/spec-types/draft.ts | 2983 +++++++++++++++++ src/types.ts | 4 +- 45 files changed, 10015 insertions(+), 497 deletions(-) create mode 100644 scripts/sync-schema.ts create mode 100644 src/connection/connection.test.ts create mode 100644 src/connection/index.ts rename src/{scenarios/server/client-helper.ts => connection/sdk-client.ts} (100%) create mode 100644 src/connection/select.ts create mode 100644 src/connection/stateful.ts rename src/{scenarios/server/stateless-client.test.ts => connection/stateless.test.ts} (64%) rename src/{scenarios/server/stateless-client.ts => connection/stateless.ts} (63%) create mode 100644 src/connection/testing.ts create mode 100644 src/runner/server.test.ts create mode 100644 src/spec-types/2025-03-26.ts create mode 100644 src/spec-types/2025-06-18.ts create mode 100644 src/spec-types/2025-11-25.ts create mode 100644 src/spec-types/README.md create mode 100644 src/spec-types/SOURCE create mode 100644 src/spec-types/draft.ts diff --git a/.gitignore b/.gitignore index 2fdd7d0..2b6bcd5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ .idea/ .claude/settings.local.json .sdk-under-test/ +.sync-schema-tmp/ diff --git a/.prettierignore b/.prettierignore index 26ebc79..959f18f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,11 @@ # output (and the refresh workflow's `git diff` check). src/seps/traceability.json +# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts 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 + # Local tooling workspaces (not part of the repo). .claude/ .sdk-under-test/ diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 258fe3d..7a587ff 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -21,6 +21,9 @@ import { import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { ElicitResultSchema, + ResultSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, ListToolsRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, @@ -30,6 +33,8 @@ import { } from '@modelcontextprotocol/sdk/types.js'; 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, createHmac } from 'crypto'; @@ -72,6 +77,49 @@ function getMrtInputText(inputResponse: unknown, field: string): string { 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, @@ -1307,97 +1355,119 @@ app.post('/mcp', async (req, res) => { } if (method === 'tools/list') { - return res.json({ - jsonrpc: '2.0', - id, - result: { - 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' - } - }); + 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 res.json({ + 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' + } + }); + } finally { + await dispatch.close(); + } } // Mock fallbacks to answer prompts capability matches safely if (method === 'prompts/list') { - return res.json({ - jsonrpc: '2.0', - id, - result: { - prompts: [ - { - name: 'test_input_required_result_prompt', - description: 'MRTR: prompt that requires elicitation input' - } - ], - ttlMs: 300000, - cacheScope: 'public' - } - }); + 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 res.json({ + 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' + } + }); + } finally { + await dispatch.close(); + } } // SEP-2322 MRTR: prompts/get handler @@ -1450,36 +1520,59 @@ app.post('/mcp', async (req, res) => { } } - // Resources on the stateless path (SEP-2575): SEP-2549 hints + SEP-2164 errors. + // 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') { - return res.json({ - jsonrpc: '2.0', - id, - result: { - 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' - } - }); + 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 res.json({ + 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' + } + }); + } finally { + await dispatch.close(); + } } if (method === 'resources/templates/list') { - return res.json({ - jsonrpc: '2.0', - id, - result: { - resourceTemplates: [], - ttlMs: 300000, - cacheScope: 'public' - } - }); + 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 res.json({ + jsonrpc: '2.0', + id, + result: { + ...fromServer, + ttlMs: 300000, + cacheScope: 'public' + } + }); + } finally { + await dispatch.close(); + } } if (method === 'resources/read') { @@ -1501,16 +1594,8 @@ app.post('/mcp', async (req, res) => { } }); } - // SEP-2164: unknown resources get -32602 with the requested uri in data. - return res.status(200).json({ - jsonrpc: '2.0', - id, - error: { - code: -32602, - message: 'Resource not found', - data: { uri } - } - }); + // Other URIs (including unknown ones) fall through to the carry-forward + // dispatch below, which serves the McpServer-registered resources. } if (method === 'tools/call') { @@ -2065,6 +2150,73 @@ app.post('/mcp', async (req, res) => { } } + // 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({ 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 res.json({ 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 res.json({ + 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 ( [ diff --git a/package.json b/package.json index 90b53c0..4fe45a4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "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", diff --git a/scripts/sync-schema.ts b/scripts/sync-schema.ts new file mode 100644 index 0000000..5aa9e7f --- /dev/null +++ b/scripts/sync-schema.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env -S npx tsx +/** + * Vendor schema/{version}/schema.ts from the modelcontextprotocol spec repo + * into src/spec-types/{version}.ts 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`); + } + + 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/connection/connection.test.ts b/src/connection/connection.test.ts new file mode 100644 index 0000000..b308284 --- /dev/null +++ b/src/connection/connection.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { connectFor } from './select'; +import { connectStateful } from './stateful'; +import { connectStateless } from './stateless'; +import { JsonRpcError } from './index'; + +describe('connectFor', () => { + it('returns stateful for dated 2025-x versions', () => { + expect(connectFor('2025-03-26')).toBe(connectStateful); + expect(connectFor('2025-06-18')).toBe(connectStateful); + expect(connectFor('2025-11-25')).toBe(connectStateful); + }); + it('returns stateless for the draft version', () => { + // connectFor wraps connectStateless in a closure (to pass the spec + // version through), so identity with connectStateless no longer holds; + // assert it did not select the stateful implementation. The wire-level + // behaviour of the wrapper is covered in stateless.test.ts. + expect(connectFor('DRAFT-2026-v1')).not.toBe(connectStateful); + expect(connectFor('DRAFT-2026-v1')).not.toBe(connectStateless); + }); +}); + +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: { ok: true } }) + ); + 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('DRAFT-2026-v1'); + const sent = JSON.parse(init.body); + expect(sent.params._meta['io.modelcontextprotocol/protocolVersion']).toBe( + 'DRAFT-2026-v1' + ); + 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/); + }); + + 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":{"progress":1}}\n\n', + 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"done":true}}\n\n' + ]) + ); + const conn = await connectStateless('http://test/mcp'); + const result = await conn.request<{ done: boolean }>('tools/call', {}); + 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":{"ok":true}}\r\n\r\n' + ]) + ); + const conn = await connectStateless('http://test/mcp'); + const result = await conn.request<{ ok: boolean }>('tools/call', {}); + 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":{}}\n\n' + ]) + ); + const conn = await connectStateless('http://test/mcp'); + await expect(conn.request('tools/call', {})).rejects.toThrow(/MRTR/); + }); +}); diff --git a/src/connection/index.ts b/src/connection/index.ts new file mode 100644 index 0000000..e20830b --- /dev/null +++ b/src/connection/index.ts @@ -0,0 +1,78 @@ +/** + * 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'; + +export interface Connection { + /** + * Send a JSON-RPC request and return its result. + * Throws `JsonRpcError` on JSON-RPC error responses. + */ + request( + method: string, + params?: 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[]; + + 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(): 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 } from './select'; diff --git a/src/scenarios/server/client-helper.ts b/src/connection/sdk-client.ts similarity index 100% rename from src/scenarios/server/client-helper.ts rename to src/connection/sdk-client.ts diff --git a/src/connection/select.ts b/src/connection/select.ts new file mode 100644 index 0000000..ca33b4e --- /dev/null +++ b/src/connection/select.ts @@ -0,0 +1,25 @@ +import type { SpecVersion } from '../types'; +import type { Connection } 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. + */ +const STATEFUL_VERSIONS: ReadonlySet = new Set([ + '2024-11-05', + '2025-03-26', + '2025-06-18', + '2025-11-25' +]); + +export function connectFor( + specVersion: SpecVersion +): (serverUrl: string) => Promise { + return STATEFUL_VERSIONS.has(specVersion) + ? connectStateful + : // Pass the version through so stateless requests declare the spec + // version the run was invoked with (matters under --force). + (serverUrl) => connectStateless(serverUrl, specVersion); +} diff --git a/src/connection/stateful.ts b/src/connection/stateful.ts new file mode 100644 index 0000000..2602ddd --- /dev/null +++ b/src/connection/stateful.ts @@ -0,0 +1,71 @@ +/** + * 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 { JsonRpcError, type Connection } from './index'; + +export async function connectStateful(serverUrl: string): Promise { + const { client, close } = await connectToServer(serverUrl); + + const notifications: JSONRPCNotification[] = []; + const collect = (n: unknown) => { + // The SDK's Zod parsing strips the jsonrpc field; restore it so collected + // notifications match the JSONRPCNotification wire shape, as + // connectStateless provides. + notifications.push({ + jsonrpc: '2.0', + ...(n as object) + } as JSONRPCNotification); + }; + // 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, + + async request( + method: string, + params: Record = {} + ): Promise { + try { + return (await client.request({ method, params }, ResultSchema)) 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/scenarios/server/stateless-client.test.ts b/src/connection/stateless.test.ts similarity index 64% rename from src/scenarios/server/stateless-client.test.ts rename to src/connection/stateless.test.ts index 76e21cf..43b935e 100644 --- a/src/scenarios/server/stateless-client.test.ts +++ b/src/connection/stateless.test.ts @@ -8,10 +8,11 @@ import { buildStandardHeaders, withRequestMeta, sendStatelessRequest, + connectStateless, CONFORMANCE_CLIENT_INFO, DEFAULT_CLIENT_CAPABILITIES -} from './stateless-client'; -import { DRAFT_PROTOCOL_VERSION } from '../../types'; +} from './stateless'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; describe('buildStandardHeaders', () => { test('sets the standard headers pinned to the draft protocol version', () => { @@ -105,3 +106,53 @@ describe('sendStatelessRequest', () => { } }); }); + +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/ + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/src/scenarios/server/stateless-client.ts b/src/connection/stateless.ts similarity index 63% rename from src/scenarios/server/stateless-client.ts rename to src/connection/stateless.ts index 4a66a6b..39d643d 100644 --- a/src/scenarios/server/stateless-client.ts +++ b/src/connection/stateless.ts @@ -1,17 +1,28 @@ /** - * Stateless request helpers for server scenarios (SEP-2575 + SEP-2243). + * Stateless connection: 2026-x lifecycle (SEP-2575). * - * Every request the harness sends to a server under test carries the - * cross-cutting obligations regardless of what a scenario actually tests: - * the standard headers (MCP-Protocol-Version, Mcp-Method, Mcp-Name, Accept) - * and the `_meta` fields (protocolVersion, clientInfo, clientCapabilities), - * pinned to `DRAFT_PROTOCOL_VERSION`. Scenarios exercising these SEPs MUST - * build their requests through these helpers so a strictly-conformant server - * never rejects harness traffic for reasons unrelated to the behaviour under - * test (issues #311, #312, #315). + * 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 } from '../../types'; +import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types'; +import type { JSONRPCNotification } from '../spec-types/2025-11-25'; +import { JsonRpcError, type Connection } from './index'; export interface JsonRpcResponse { jsonrpc: '2.0'; @@ -67,16 +78,18 @@ export function mcpNameForRequest( * 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 } = {} + options: { headers?: Record; specVersion?: SpecVersion } = {} ): Record { const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', - 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION, + 'MCP-Protocol-Version': options.specVersion ?? DRAFT_PROTOCOL_VERSION, 'Mcp-Method': method }; const name = mcpNameForRequest(method, params); @@ -102,14 +115,16 @@ export function buildStandardHeaders( /** * 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 + params?: Record, + specVersion: SpecVersion = DRAFT_PROTOCOL_VERSION ): Record { return { ...params, _meta: { - 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/protocolVersion': specVersion, 'io.modelcontextprotocol/clientInfo': CONFORMANCE_CLIENT_INFO, 'io.modelcontextprotocol/clientCapabilities': DEFAULT_CLIENT_CAPABILITIES, ...(params?._meta as Record | undefined) @@ -214,17 +229,22 @@ export async function sendStatelessRequest( serverUrl: string, method: string, params?: Record, - options: { headers?: Record; timeoutMs?: number } = {} + options: { + headers?: Record; + timeoutMs?: number; + specVersion?: SpecVersion; + } = {} ): Promise { const id = nextRequestId++; const headers = buildStandardHeaders(method, params, { - headers: options.headers + headers: options.headers, + specVersion: options.specVersion }); const body = JSON.stringify({ jsonrpc: '2.0', id, method, - params: withRequestMeta(params) + params: withRequestMeta(params, options.specVersion) }); const controller = new AbortController(); @@ -272,3 +292,67 @@ export async function sendStatelessRequest( 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. + */ +export async function connectStateless( + serverUrl: string, + specVersion: SpecVersion = DRAFT_PROTOCOL_VERSION +): Promise { + const notifications: JSONRPCNotification[] = []; + + async function request( + method: string, + params?: Record + ): Promise { + const response = await sendStatelessRequest(serverUrl, method, params, { + specVersion + }); + + 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)` + ); + } + } + + 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; + } + + return { + notifications, + request, + close: async () => {} + }; +} diff --git a/src/connection/testing.ts b/src/connection/testing.ts new file mode 100644 index 0000000..92468f6 --- /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 { 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: () => connectFor(specVersion)(serverUrl) + }; +} diff --git a/src/index.ts b/src/index.ts index 4fd84a5..637a357 100644 --- a/src/index.ts +++ b/src/index.ts @@ -327,6 +327,10 @@ 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('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { @@ -344,9 +348,17 @@ program const result = await runServerConformanceTest( validated.url, validated.scenario, - 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 { failed } = printServerResults( result.checks, result.scenarioDescription, @@ -410,7 +422,8 @@ program const result = await runServerConformanceTest( validated.url, scenarioName, - outputDir + outputDir, + specVersionFilter ); allResults.push({ scenario: scenarioName, checks: result.checks }); } catch (error) { diff --git a/src/runner/server.test.ts b/src/runner/server.test.ts new file mode 100644 index 0000000..9351872 --- /dev/null +++ b/src/runner/server.test.ts @@ -0,0 +1,52 @@ +/** + * 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 { 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); +}); diff --git a/src/runner/server.ts b/src/runner/server.ts index a7d8449..a6c66d4 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -1,7 +1,13 @@ 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 { createResultDir, formatPrettyChecks } from './utils'; /** @@ -20,11 +26,14 @@ function formatMarkdown(text: string): string { export async function runServerConformanceTest( serverUrl: string, scenarioName: string, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion, + force = false ): Promise<{ checks: ConformanceCheck[]; resultDir?: string; scenarioDescription: string; + skipped?: boolean; }> { let resultDir: string | undefined; @@ -36,11 +45,53 @@ 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. + const resolvedSpecVersion = + specVersion ?? + ('introducedIn' 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: () => connectFor(resolvedSpecVersion)(serverUrl) + }; + const checks = await scenario.run(ctx); if (resultDir) { await fs.writeFile( diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 1422c90..b7230ab 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -232,15 +232,16 @@ export const clientScenarios = new Map( ); // All client scenarios for authorization server -const allClientScenariosListForAuthorizationServer: ClientScenario[] = [ - // Authorization server scenarios - new AuthorizationServerMetadataEndpointScenario() -]; +const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizationServer[] = + [ + // Authorization server scenarios + new AuthorizationServerMetadataEndpointScenario() + ]; // Client scenarios map for authorization server - built from list export const clientScenariosForAuthorizationServer = new Map< string, - ClientScenario + ClientScenarioForAuthorizationServer >( allClientScenariosListForAuthorizationServer.map((scenario) => [ scenario.name, @@ -398,6 +399,18 @@ function matchesSpecVersion( ); } +/** + * 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) => matchesSpecVersion(s.source, version)) diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index bc697a3..68387d7 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -1,3 +1,4 @@ +import { testContext } from '../../connection/testing'; import { spawn, ChildProcess } from 'child_process'; import { createServer } from 'net'; import { @@ -6,6 +7,7 @@ import { listDraftClientScenarios, listPendingClientScenarios } from '../index'; +import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; import path from 'path'; function getFreePort(): Promise { @@ -139,7 +141,15 @@ describe('Server Scenarios', () => { throw new Error(`Scenario ${scenarioName} not found`); } - const checks = await scenario.run(serverUrl); + // Draft-only scenarios expect the draft (stateless) connection, so + // derive the spec version from the scenario's declared source. + const specVersion = + 'introducedIn' in scenario.source && + scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION + ? DRAFT_PROTOCOL_VERSION + : LATEST_SPEC_VERSION; + + const checks = await scenario.run(testContext(serverUrl, specVersion)); // Verify checks were returned expect(checks.length).toBeGreaterThan(0); diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts index 7b1bd22..1c96a85 100644 --- a/src/scenarios/server/caching.ts +++ b/src/scenarios/server/caching.ts @@ -10,7 +10,12 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { sendStatelessRequest } from './stateless-client'; +import { + JsonRpcError, + type Connection, + type RunContext +} from '../../connection'; +import type { CacheableResult } from '../../spec-types/draft'; const SPEC_REFS = [ { @@ -30,12 +35,12 @@ interface CachingFields { hasCacheScope: boolean; } -function extractCachingFields(result: Record): CachingFields { +function extractCachingFields(result: Partial): CachingFields { const hasTtlMs = 'ttlMs' in result; const hasCacheScope = 'cacheScope' in result; return { - ttlMs: hasTtlMs ? result.ttlMs : undefined, - cacheScope: hasCacheScope ? result.cacheScope : undefined, + ttlMs: result.ttlMs, + cacheScope: result.cacheScope, hasTtlMs, hasCacheScope }; @@ -87,56 +92,61 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p - \`resources/templates/list\` - \`resources/read\``; - async run(serverUrl: string): Promise { + 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 stateless path (SEP-2575): protocolVersion DRAFT-2026-v1 - // plus the cross-cutting _meta and standard headers (issue #315). + // queried over the version-appropriate connection. Under --spec-version + // draft that resolves to the stateless impl (SEP-2575): protocolVersion + // DRAFT-2026-v1 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 | undefined> => { + ): Promise<(CacheableResult & Record) | undefined> => { const description = `${endpoint} response includes ttlMs and cacheScope caching hints`; try { - const response = await sendStatelessRequest( - serverUrl, - endpoint, - params - ); - const result = response.body?.result; - if (!result) { - const error = response.body?.error; - checks.push({ - id: checkId, - name: checkName, - description, - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: error - ? `${endpoint} returned JSON-RPC error ${error.code}: ${error.message}` - : `${endpoint} returned HTTP ${response.status} with no result`, - specReferences: SPEC_REFS, - details: { httpStatus: response.status, error } - }); - return undefined; - } + 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: `${endpoint} request failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: SPEC_REFS + 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; } @@ -198,6 +208,8 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p }); } + await conn.close(); + // 6. Aggregate: ttlMs must be a non-negative integer const ttlErrors: string[] = []; const endpointsWithTtl = allFields.filter((f) => f.fields.hasTtlMs); diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index 67c98d9..e93f810 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 } from '../../types'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import { buildStandardHeaders, type RunContext } from '../../connection'; import { request } from 'undici'; const SPEC_REFERENCES = [ @@ -42,32 +47,71 @@ 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 + } + }; +} + +/** + * 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 + hostOrOrigin: string, + specVersion: string ): Promise<{ statusCode: number; body: unknown }> { + // 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' - }, - 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' } + headers: buildStandardHeaders(probe.method, probe.params, { + headers: { + Host: hostOrOrigin, + Origin: `http://${hostOrOrigin}`, + 'MCP-Protocol-Version': specVersion } - }) + }), + body: JSON.stringify(probe) }); let body: unknown; @@ -109,7 +153,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 +205,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,7 +246,11 @@ 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; diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index 61381c0..f515cd7 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 } 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'; - readonly source = { introducedIn: '2025-11-25' } as const; + 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,7 +41,8 @@ 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 { diff --git a/src/scenarios/server/elicitation-enums.ts b/src/scenarios/server/elicitation-enums.ts index 3a96451..8b15475 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 } 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'; - readonly source = { introducedIn: '2025-11-25' } as const; + 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,7 +42,8 @@ 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 { diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index fd8135f..ca84e9a 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -20,7 +20,11 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { withRequestMeta, sendStatelessRequest } from './stateless-client'; +import { + withRequestMeta, + sendStatelessRequest, + type RunContext +} from '../../connection'; const SPEC_REFERENCE = { id: 'SEP-2243-Server-Validation', @@ -262,7 +266,8 @@ export class HttpHeaderValidationScenario implements ClientScenario { - Server MUST return HTTP 400 Bad Request for validation failures - Server MUST return JSON-RPC error with code -32001 (HeaderMismatch)`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -557,7 +562,8 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario - 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index 689807c..a89e847 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -7,11 +7,11 @@ // ─── JSON-RPC Types ────────────────────────────────────────────────────────── -export type { JsonRpcResponse } from './stateless-client'; +export type { JsonRpcResponse } from '../../connection'; // ─── Stateless RPC Helper ──────────────────────────────────────────────────── -import { sendStatelessRequest, JsonRpcResponse } from './stateless-client'; +import { sendStatelessRequest, JsonRpcResponse } from '../../connection'; /** * Send a stateless JSON-RPC request (SEP-2575 pattern). diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index f471f58..566b182 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -12,6 +12,7 @@ import { DRAFT_PROTOCOL_VERSION, SpecVersion } from '../../types'; +import type { RunContext } from '../../connection'; import { sendRpc, isInputRequiredResult, @@ -65,7 +66,8 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments } \`\`\``; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -210,7 +212,8 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req **Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"capital_question"\`, return a complete result with the sampling response text.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -345,7 +348,8 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r **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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -488,7 +492,8 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument **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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -630,7 +635,8 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume **Behavior (Round 2):** When called with \`inputResponses\` containing ALL keys and the echoed \`requestState\`, return a complete result.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -815,7 +821,8 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments **Behavior (Round 3):** When called with \`inputResponses\` for step2 + updated requestState, return a complete result.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -955,7 +962,8 @@ 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -1049,7 +1057,8 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli **Behavior (Round 2):** When called with \`inputResponses\`, return a complete \`GetPromptResult\`.`; - async run(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -1152,7 +1161,8 @@ 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -1216,7 +1226,8 @@ export class InputRequiredResultUnsupportedMethodsScenario implements ClientScen 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; const errors: string[] = []; @@ -1281,7 +1292,8 @@ 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -1393,7 +1405,8 @@ Implement a tool named \`test_input_required_result_capabilities\` (no arguments 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -1475,7 +1488,8 @@ 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { @@ -1548,7 +1562,8 @@ 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl } = ctx; const checks: ConformanceCheck[] = []; try { diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts index b5693f7..23c4e22 100644 --- a/src/scenarios/server/json-schema-2020-12.ts +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -17,7 +17,8 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types.js'; -import { connectToServer } from './client-helper.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'; @@ -27,19 +28,19 @@ const EXPECTED_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; * * 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 of what it negotiated. Stripping them is only a conformance - * FAILURE once the server claims a protocol version that includes SEP-2106 - * (the draft); a server that only negotiated an earlier dated version is not - * yet subject to the requirement, so the check is SKIPPED rather than failed. + * 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, - negotiatedProtocolVersion: string | undefined + targetProtocolVersion: string ): CheckStatus { if (preserved) { return 'SUCCESS'; } - return negotiatedProtocolVersion === DRAFT_PROTOCOL_VERSION + return targetProtocolVersion === DRAFT_PROTOCOL_VERSION ? 'FAILURE' : 'SKIPPED'; } @@ -93,7 +94,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema **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 = [ { @@ -109,13 +110,11 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema ]; try { - const connection = await connectToServer(serverUrl); - // Negotiated wire protocolVersion from the initialize handshake; used to - // soft-gate the SEP-2106 checks below. - const negotiatedVersion = ( - connection.client.transport as { protocolVersion?: string } | undefined - )?.protocolVersion; - 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); @@ -138,7 +137,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema }); if (!tool) { - await connection.close(); + await conn.close(); return checks; } @@ -222,11 +221,11 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema // 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 negotiated - // protocol version (see sep2106KeywordCheckStatus): stripping the - // keywords is only a FAILURE for servers that negotiated the draft - // protocol version, and SKIPPED otherwise. - const skippedSuffix = ` (server negotiated protocol version ${negotiatedVersion ?? 'unknown'}; SEP-2106 applies from ${DRAFT_PROTOCOL_VERSION})`; + // 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']; @@ -242,7 +241,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema const compositionPreserved = hasAllOf && hasNestedAnyOf; const compositionStatus = sep2106KeywordCheckStatus( compositionPreserved, - negotiatedVersion + targetVersion ); checks.push({ @@ -262,7 +261,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema details: { hasAllOf, hasNestedAnyOf, - negotiatedProtocolVersion: negotiatedVersion + targetProtocolVersion: targetVersion } }); @@ -273,7 +272,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema const conditionalPreserved = hasIf && hasThen && hasElse; const conditionalStatus = sep2106KeywordCheckStatus( conditionalPreserved, - negotiatedVersion + targetVersion ); checks.push({ @@ -292,7 +291,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema hasIf, hasThen, hasElse, - negotiatedProtocolVersion: negotiatedVersion + targetProtocolVersion: targetVersion } }); @@ -301,10 +300,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema | Record | undefined; const hasAnchor = !!addressDef && '$anchor' in addressDef; - const anchorStatus = sep2106KeywordCheckStatus( - hasAnchor, - negotiatedVersion - ); + const anchorStatus = sep2106KeywordCheckStatus(hasAnchor, targetVersion); checks.push({ id: 'sep-2106-anchor-keyword-preserved', @@ -324,7 +320,7 @@ Implement tool \`${EXPECTED_TOOL_NAME}\` with inputSchema containing JSON Schema } }); - await connection.close(); + 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 index 15ee6d8..d787fe8 100644 --- a/src/scenarios/server/lifecycle.test.ts +++ b/src/scenarios/server/lifecycle.test.ts @@ -1,7 +1,8 @@ +import { testContext } from '../../connection/testing'; import { ServerInitializeScenario } from './lifecycle'; -import { connectToServer } from './client-helper'; +import { connectToServer } from '../../connection/sdk-client'; -vi.mock('./client-helper', () => ({ +vi.mock('../../connection/sdk-client', () => ({ connectToServer: vi.fn() })); @@ -26,7 +27,9 @@ describe('ServerInitializeScenario', () => { 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(serverUrl); + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); expect(connectToServer).toHaveBeenCalledWith(serverUrl); expect(closeMock).toHaveBeenCalled(); @@ -59,7 +62,9 @@ describe('ServerInitializeScenario', () => { }) ); - const checks = await new ServerInitializeScenario().run(serverUrl); + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); expect(checks[1]).toMatchObject({ id: 'server-session-id-visible-ascii', @@ -79,7 +84,9 @@ describe('ServerInitializeScenario', () => { }) ); - const checks = await new ServerInitializeScenario().run(serverUrl); + const checks = await new ServerInitializeScenario().run( + testContext(serverUrl) + ); expect(checks[1]).toMatchObject({ id: 'server-session-id-visible-ascii', diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index bb55869..61fac0b 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -2,8 +2,13 @@ * Lifecycle test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } 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'; const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/; @@ -16,7 +21,10 @@ const SESSION_SPEC_REFERENCES = [ export class ServerInitializeScenario implements ClientScenario { name = 'server-initialize'; - readonly source = { introducedIn: '2025-06-18' } as const; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test basic server initialization handshake. **Server Implementation Requirements:** @@ -32,7 +40,8 @@ export class ServerInitializeScenario implements ClientScenario { 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 { diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts index e12dbe1..57ca059 100644 --- a/src/scenarios/server/negative-mrtr.test.ts +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -1,3 +1,4 @@ +import { testContext } from '../../connection/testing'; /** * SEP-2322 MRTR negative tests. * @@ -92,7 +93,7 @@ describe('SEP-2322 MRTR negative tests', () => { it('emits FAILURE for sep-2322-result-type-included against server that omits resultType', async () => { const scenario = new InputRequiredResultResultTypeScenario(); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(testContext(SERVER_URL)); const resultTypeCheck = checks.find( (c) => c.id === 'sep-2322-result-type-included' @@ -103,7 +104,7 @@ describe('SEP-2322 MRTR negative tests', () => { 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(SERVER_URL); + const checks = await scenario.run(testContext(SERVER_URL)); const unsupportedCheck = checks.find( (c) => c.id === 'sep-2322-not-on-unsupported-requests' @@ -114,7 +115,7 @@ describe('SEP-2322 MRTR negative tests', () => { 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(SERVER_URL); + const checks = await scenario.run(testContext(SERVER_URL)); const tamperedCheck = checks.find( (c) => c.id === 'sep-2322-reject-tampered-state' diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index d602d64..583c5eb 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -1,3 +1,4 @@ +import { testContext } from '../../connection/testing'; import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { DNSRebindingProtectionScenario } from './dns-rebinding'; @@ -74,7 +75,9 @@ describe('Server scenario negative tests', () => { it('emits FAILURE against a server without rebinding protection', async () => { const scenario = new DNSRebindingProtectionScenario(); - const checks = await scenario.run(`http://localhost:${PORT}/mcp`); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`) + ); const rebindingCheck = checks.find( (c) => c.id === 'localhost-host-rebinding-rejected' @@ -103,7 +106,9 @@ describe('Server scenario negative tests', () => { 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(`http://localhost:${PORT}/mcp`); + 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'); @@ -133,7 +138,9 @@ describe('Server scenario negative tests', () => { it('emits FAILURE for presence checks against a server without caching hints', async () => { const scenario = new CachingScenario(); - const checks = await scenario.run(`http://localhost:${PORT}/mcp`); + 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); @@ -174,7 +181,9 @@ describe('Server scenario negative tests', () => { 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(`http://localhost:${PORT}/mcp`); + const checks = await scenario.run( + testContext(`http://localhost:${PORT}/mcp`) + ); // The tool is still advertised, so it must be found... const found = checks.find( @@ -182,10 +191,9 @@ describe('Server scenario negative tests', () => { ); expect(found?.status).toBe('SUCCESS'); - // ...but the stripped 2020-12 keywords must be flagged. The stripped - // server negotiates 2025-11-25 (the published SDK does not advertise the - // draft protocol version), so the soft version gate reports SKIPPED - // rather than FAILURE — see sep2106KeywordCheckStatus. + // ...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' ); @@ -204,24 +212,22 @@ describe('Server scenario negative tests', () => { }); describe('sep2106KeywordCheckStatus (soft version gate)', () => { - it('passes preserved keywords at any negotiated version', () => { + it('passes preserved keywords at any target version', () => { expect(sep2106KeywordCheckStatus(true, DRAFT_PROTOCOL_VERSION)).toBe( 'SUCCESS' ); expect(sep2106KeywordCheckStatus(true, LATEST_SPEC_VERSION)).toBe( 'SUCCESS' ); - expect(sep2106KeywordCheckStatus(true, undefined)).toBe('SUCCESS'); }); - it('fails stripped keywords only when the server negotiated the draft version', () => { + 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' ); - expect(sep2106KeywordCheckStatus(false, undefined)).toBe('SKIPPED'); }); }); }); diff --git a/src/scenarios/server/prompts.ts b/src/scenarios/server/prompts.ts index 2f65723..c5a5f38 100644 --- a/src/scenarios/server/prompts.ts +++ b/src/scenarios/server/prompts.ts @@ -3,7 +3,11 @@ */ import { ClientScenario, ConformanceCheck } from '../../types'; -import { connectToServer } from './client-helper'; +import type { RunContext } from '../../connection'; +import type { + ListPromptsResult, + GetPromptResult +} from '../../spec-types/2025-06-18'; export class PromptsListScenario implements ClientScenario { name = 'prompts-list'; @@ -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', @@ -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', @@ -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', @@ -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', @@ -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 36f5660..382b0db 100644 --- a/src/scenarios/server/resources.ts +++ b/src/scenarios/server/resources.ts @@ -7,12 +7,14 @@ import { ConformanceCheck, DRAFT_PROTOCOL_VERSION } from '../../types'; -import { connectToServer } from './client-helper'; -import { sendStatelessRequest } from './stateless-client'; -import { +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'; @@ -31,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[] = []; @@ -73,7 +75,7 @@ export class ResourcesListScenario implements ClientScenario { } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-list', @@ -116,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' }); @@ -160,7 +162,7 @@ Implement resource \`test://static-text\` that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-read-text', @@ -203,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' }); @@ -245,7 +247,7 @@ Implement resource \`test://static-binary\` that returns: } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-read-binary', @@ -292,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' }); @@ -347,7 +349,7 @@ Returns (for \`uri: "test://template/123/data"\`): } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-templates-read', @@ -371,7 +373,10 @@ Returns (for \`uri: "test://template/123/data"\`): export class ResourcesSubscribeScenario implements ClientScenario { name = 'resources-subscribe'; - readonly source = { introducedIn: '2025-06-18' } as const; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test subscribing to resource updates. **Server Implementation Requirements:** @@ -394,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' }); @@ -418,7 +423,7 @@ Example request: ] }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'resources-subscribe', @@ -473,7 +478,7 @@ Example error response: 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; const nonexistentUri = 'test://nonexistent-resource-for-conformance-testing'; @@ -484,13 +489,12 @@ This scenario does not require the server to register any specific resource — } ]; - // SEP-2164 is a draft-spec requirement, so the request is sent statelessly - // with the draft protocol version and the cross-cutting _meta/headers. - let response; + // 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 { - response = await sendStatelessRequest(serverUrl, 'resources/read', { - uri: nonexistentUri - }); + conn = await ctx.connect(); } catch (error) { checks.push({ id: 'sep-2164-error-code', @@ -505,10 +509,15 @@ This scenario does not require the server to register any specific resource — return checks; } - const result = response.body?.result as - | { contents?: unknown[] } - | undefined; - const rpcError = response.body?.error; + 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 = @@ -532,12 +541,14 @@ This scenario does not require the server to register any specific resource — }); // 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) { - errorCodeMessage = `Expected a JSON-RPC error, got HTTP ${response.status} with no JSON-RPC error in the body.`; + } 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}. ` + @@ -585,13 +596,17 @@ This scenario does not require the server to register any specific resource — } }); + await conn.close(); return checks; } } export class ResourcesUnsubscribeScenario implements ClientScenario { name = 'resources-unsubscribe'; - readonly source = { introducedIn: '2025-06-18' } as const; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test unsubscribing from resource. **Server Implementation Requirements:** @@ -604,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' }); @@ -634,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/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index 7090f81..23f7e79 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -9,7 +9,12 @@ * Multiple concurrent streams are achieved via POST requests, each getting their own stream. */ -import { ClientScenario, ConformanceCheck } 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'; @@ -20,55 +25,92 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { 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 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 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' + } + ], + 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': '2025-03-26' + }; + 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 +122,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); diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 79e5986..0f52f40 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -8,7 +8,12 @@ * - Replaying events when client reconnects with Last-Event-ID */ -import { ClientScenario, ConformanceCheck } 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'; @@ -67,11 +72,15 @@ function createLoggingFetch(checks: ConformanceCheck[]) { export class ServerSSEPollingScenario implements ClientScenario { name = 'server-sse-polling'; - readonly source = { introducedIn: '2025-11-25' } as const; + 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 } = ctx; const checks: ConformanceCheck[] = []; let sessionId: string | undefined; diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 99b376a..0870af9 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -1,3 +1,4 @@ +import { testContext } from '../../connection/testing'; import { ServerStatelessScenario } from './stateless'; import { describe, test, expect } from 'vitest'; import { ConformanceCheck } from '../../types'; @@ -110,7 +111,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); // The test scenario should flag this server as a FAILURE for skipping meta validation const missingMetaCheck = findCheck( @@ -153,7 +154,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const pingRouteCheck = findCheck( checks, @@ -189,7 +190,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const negotiationMatchCheck = findCheck( checks, @@ -216,7 +217,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const ackCheck = findCheck( checks, @@ -250,7 +251,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const independentRequestCheck = findCheck( checks, @@ -280,7 +281,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const logWithoutLevelCheck = findCheck( checks, @@ -321,7 +322,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const filterCheck = findCheck( checks, @@ -393,7 +394,7 @@ describe('Stateless Server Scenario Negative Tests', () => { }); const scenario = new ServerStatelessScenario(); - const checks = await scenario.run(mockUrl); + const checks = await scenario.run(testContext(mockUrl)); const promptsCheck = findCheck( checks, diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index ed97928..f6dfc78 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -9,8 +9,9 @@ import { } from '../../types'; import { buildStandardHeaders, - readSseJsonRpcResponse -} from './stateless-client'; + readSseJsonRpcResponse, + type RunContext +} from '../../connection'; const SPEC_REF = [ { @@ -51,7 +52,8 @@ export class ServerStatelessScenario implements ClientScenario { 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(serverUrl: string): Promise { + async run(ctx: RunContext): Promise { + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; const timestamp = new Date().toISOString(); @@ -124,7 +126,8 @@ export class ServerStatelessScenario implements ClientScenario { // are always sent conformantly; overrides only alter the dimension a // test case is about (issue #312). const headers = buildStandardHeaders(method, params, { - headers: headersOverrides + headers: headersOverrides, + specVersion }); const body = JSON.stringify({ @@ -167,7 +170,7 @@ export class ServerStatelessScenario implements ClientScenario { timeoutMs = 1000, onFirstFrame?: () => Promise ): Promise => { - const headers = buildStandardHeaders(method, params); + const headers = buildStandardHeaders(method, params, { specVersion }); const body = JSON.stringify({ jsonrpc: '2.0', @@ -273,7 +276,7 @@ export class ServerStatelessScenario implements ClientScenario { }; const validMeta = { - 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/protocolVersion': specVersion, 'io.modelcontextprotocol/clientInfo': { name: 'conformance-client', version: '1.0.0' @@ -556,7 +559,7 @@ export class ServerStatelessScenario implements ClientScenario { const responseAbsent = await sendRpc( 'server/discover', { _meta: headerMismatchMeta }, - { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }, + { 'MCP-Protocol-Version': specVersion }, 302 ).catch(() => null); const resAbsent: any = responseAbsent?.res ?? null; diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index 5875e72..8846485 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -2,13 +2,23 @@ * Tools test scenarios for MCP servers */ -import { ClientScenario, ConformanceCheck } from '../../types'; -import { connectToServer, NotificationCollector } from './client-helper'; import { - CallToolResultSchema, + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types'; +import type { RunContext } from '../../connection'; +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'; const TOOL_NAME_PATTERN = /^[A-Za-z0-9_./-]+$/; @@ -104,13 +114,13 @@ export class ToolsListScenario implements ClientScenario { - \`description\` (string) - \`inputSchema\` (valid JSON Schema 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(); - const result = await connection.client.listTools(); + const result = await conn.request('tools/list'); // Validate response structure const errors: string[] = []; @@ -153,7 +163,7 @@ export class ToolsListScenario implements ClientScenario { // names MUST be 1-64 chars matching ^[A-Za-z0-9_./-]+$ checks.push(buildToolsNameFormatCheck(result.tools)); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-list', @@ -195,13 +205,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 */ }); @@ -238,7 +248,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', @@ -283,13 +293,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: {} }); @@ -326,7 +336,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', @@ -381,13 +391,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: {} }); @@ -427,7 +437,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', @@ -451,7 +461,10 @@ Implement tool \`test_multiple_content_types\` with no arguments that returns: export class ToolsCallWithLoggingScenario implements ClientScenario { name = 'tools-call-with-logging'; - readonly source = { introducedIn: '2025-06-18' } as const; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test tool that sends log messages during execution. **Server Implementation Requirements:** @@ -467,7 +480,8 @@ 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 { @@ -563,13 +577,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: {} }); @@ -601,7 +615,7 @@ Implement tool \`test_error_handling\` with no arguments. } }); - await connection.close(); + await conn.close(); } catch (error) { checks.push({ id: 'tools-call-error', @@ -656,31 +670,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) { @@ -716,12 +731,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', @@ -745,7 +760,10 @@ If no progress token provided, just execute with delays. export class ToolsCallSamplingScenario implements ClientScenario { name = 'tools-call-sampling'; - readonly source = { introducedIn: '2025-06-18' } as const; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = `Test tool that requests LLM sampling from client. **Server Implementation Requirements:** @@ -790,7 +808,8 @@ 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 { @@ -873,7 +892,10 @@ Implement tool \`test_sampling\` with argument: export class ToolsCallElicitationScenario implements ClientScenario { name = 'tools-call-elicitation'; - readonly source = { introducedIn: '2025-06-18' } as const; + 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:** @@ -923,7 +945,8 @@ 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 { @@ -1025,13 +1048,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: {} }); @@ -1075,7 +1098,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', @@ -1121,13 +1144,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: {} }); @@ -1174,7 +1197,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 54b6d94..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 } 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'; - readonly source = { introducedIn: '2025-06-18' } as const; + 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'; - readonly source = { introducedIn: '2025-06-18' } as const; + 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', @@ -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/spec-types/2025-03-26.ts b/src/spec-types/2025-03-26.ts new file mode 100644 index 0000000..a4b3a9d --- /dev/null +++ b/src/spec-types/2025-03-26.ts @@ -0,0 +1,1260 @@ +/* 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.ts b/src/spec-types/2025-06-18.ts new file mode 100644 index 0000000..8778ec0 --- /dev/null +++ b/src/spec-types/2025-06-18.ts @@ -0,0 +1,1623 @@ +/* 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.ts b/src/spec-types/2025-11-25.ts new file mode 100644 index 0000000..332c3f0 --- /dev/null +++ b/src/spec-types/2025-11-25.ts @@ -0,0 +1,2587 @@ +/* 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..77580c0 --- /dev/null +++ b/src/spec-types/README.md @@ -0,0 +1,29 @@ +# spec-types + +Vendored copies of `schema/{version}/schema.ts` from the +[modelcontextprotocol](https://github.com/modelcontextprotocol/modelcontextprotocol) +spec repository. + +These 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. + +**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..201d600 --- /dev/null +++ b/src/spec-types/SOURCE @@ -0,0 +1 @@ +modelcontextprotocol@33c3724972e6a87a8ad153e3f8a0d4714b0b9536 diff --git a/src/spec-types/draft.ts b/src/spec-types/draft.ts new file mode 100644 index 0000000..3d7cd42 --- /dev/null +++ b/src/spec-types/draft.ts @@ -0,0 +1,2983 @@ +/* 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 = "DRAFT-2026-v1"; +/** @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. Required. + * + * The {@link Implementation} schema requires `name` and `version`; other + * fields are optional. + */ + "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. + */ + "io.modelcontextprotocol/logLevel"?: LoggingLevel; +} + +/** + * 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?: MetaObject; +} + +/** @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"; + +/** + * Common result fields. + * + * @category Common Types + */ +export interface Result { + _meta?: MetaObject; + /** + * 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, this error is returned when a request is made for a method that requires a capability that has not been declared. This can occur in either direction: + * + * - A server returning this error when the client requests a capability it doesn't support (e.g., requesting completions when the `completions` capability was not advertised) + * - A client returning this error when the server requests a capability it doesn't support (e.g., requesting roots when the client did not declare the `roots` capability) + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Roots not supported + * {@includeCode ./examples/MethodNotFoundError/roots-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; +} + +/** + * 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 = -32003; + +/** + * Error code returned when the request's protocol version is not supported + * by the server. + * + * @category Errors + */ +export const UNSUPPORTED_PROTOCOL_VERSION = -32004; + +/** + * 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 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; +} + +/** + * 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. + * + * @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 Result { + /** + * 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; + /** + * Information about the server software implementation. + */ + serverInfo: Implementation; + /** + * 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. + * + * @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. + * + * @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 (soft-deprecated) + * {@includeCode ./examples/ClientCapabilities/sampling-context-inclusion-support-soft-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. + * + * @example Extensions — 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. + * + * @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/apps"), and values are per-extension settings + * objects. An empty object indicates support with no settings. + * + * @example Extensions — UI extension support + * {@includeCode ./examples/ServerCapabilities/extensions-ui.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"`: Any client or intermediary (e.g., shared gateway, proxy) + * MAY cache the response and serve it to any user. + * - `"private"`: Only the requesting user's client MAY cache the response. + * Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached + * copy to a different user. + * + */ + 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 may be issued by servers without any previous subscription from the client. + * + * @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; +} + +/** + * 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 as the first message on a + * {@link SubscriptionsListenRequest | subscriptions/listen} stream to acknowledge + * that the subscription has been established and to report which notification + * types it agreed to honor. + * + * @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 may be issued by servers without any previous subscription from the client. + * + * @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 may be issued by servers without any previous subscription from the client. + * + * @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. + * + * 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. + * + * @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`. + * + * @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 + * + * @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} + * + * @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"`. Values `"thisServer"` and `"allServers"` are soft-deprecated. Servers SHOULD only use these values if the client + * declares {@link 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?: 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. + * + * @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} + * + * @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} + * + * @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} + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + _meta?: MetaObject; +} + +/** + * @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} + * + * @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} + * + * @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} + * + * @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` + * + * @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} + * + * @category `roots/list` + */ +export interface ListRootsRequest { + method: "roots/list"; + params?: RequestParams; +} + +/** + * 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} + * + * @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} + * + * @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 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. + * + * @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; + minimum?: number; + maximum?: 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[] }; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @example Elicitation complete + * {@includeCode ./examples/ElicitationCompleteNotification/elicitation-complete.json} + * + * @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 = + | DiscoverRequest + | CompleteRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscriptionsListenRequest + | CallToolRequest + | ListToolsRequest; + +/** @internal */ +export type ClientNotification = CancelledNotification | ProgressNotification; + +/** @internal */ +export type ClientResult = EmptyResult; + +/* Server messages */ + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitationCompleteNotification + | SubscriptionsAcknowledgedNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | DiscoverResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + | InputRequiredResult; diff --git a/src/types.ts b/src/types.ts index 8052eb1..6c6376e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { RunContext } from './connection'; + export type CheckStatus = | 'SUCCESS' | 'FAILURE' @@ -109,7 +111,7 @@ export interface ClientScenario { name: string; description: string; source: ScenarioSource; - run(serverUrl: string): Promise; + run(ctx: RunContext): Promise; } export interface ClientScenarioForAuthorizationServer { From 82c1195f8a77fff516e43afb47c1b4faafea7707 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:09:36 +0100 Subject: [PATCH 152/226] feat: version-aware MockServer abstraction for client scenarios (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: vendor spec schema types per-version Copies schema/{version}/schema.ts from the modelcontextprotocol spec repo into src/spec-types/{version}.ts so the conformance suite can type against draft spec versions before any SDK ships them. npm run sync-schema -- refreshes the copies and records the spec commit in src/spec-types/SOURCE. * feat: add Connection abstraction and RunContext Connection encapsulates how the conformance suite talks to a server-under-test for a given spec version: - connectStateful: 2025-x lifecycle. Thin adapter over the SDK Client (initialize handshake, session id, SSE handled by the SDK). - connectStateless: 2026-x lifecycle (SEP-2575). Raw fetch with per-request _meta + MCP-Protocol-Version header. Decoupled from the SDK so the suite can test draft spec versions before the SDK implements them. connectFor(specVersion) picks the implementation. RunContext bundles serverUrl, specVersion and a bound connect() for the runner to hand to each scenario. Nothing uses this yet; wiring follows in the next commit. * refactor: thread RunContext through ClientScenario.run ClientScenario.run(serverUrl) becomes run(ctx: RunContext). The runner builds the context from --spec-version and the server URL; scenarios destructure ctx.serverUrl and otherwise behave identically. No scenario uses ctx.connect() yet, so behaviour is unchanged: 214/214 tests pass, all-scenarios.test.ts still drives the everything-server fixture exactly as before. Test files use a testContext(url) helper to construct a RunContext. The authorization-server scenario list is retyped to ClientScenarioForAuthorizationServer since those scenarios test an OAuth server, not an MCP server, and keep run(serverUrl). * refactor: migrate server scenarios to ctx.connect() + conn.request() 22 carry-forward and lifecycle scenarios now go through the Connection abstraction instead of connectToServer + SDK Client: - tools.ts (8): list, call x6, with-progress - prompts.ts (5) - resources.ts (7): list, read x3, subscribe x2, not-found - utils.ts (3): completion, ping, set-level - json-schema-2020-12.ts, caching.ts, http-standard-headers.ts Result types come from spec-types/{introducedIn}. Same scenario code now passes under both --spec-version 2025-11-25 (SDK-backed stateful) and --spec-version draft (raw stateless). ToolsCallSampling/Elicitation/WithLogging and elicitation-* keep connectToServer (need setRequestHandler/setLoggingLevel SDK surface); they are tagged removedIn: DRAFT in the next commit. stateless.ts and input-required-result.ts keep their sendRpc helper; migrating those is deferred to the DRAFT-scenario coherence pass. * fix: normalize Connection error to JsonRpcError; clean up RunContext import connectStateful now catches the SDK's McpError and rethrows as JsonRpcError so scenarios always see the same error class regardless of which Connection impl ran. ResourcesNotFoundError uses instanceof JsonRpcError instead of duck-typing. types.ts uses a normal top-level import for RunContext instead of an inline import() type. * fix(dns-rebinding): use version-appropriate probe body The 'valid Host accepted' check was sending an initialize body, which a 2026 server returns 404 for. Probe body is now picked from ctx.specVersion: initialize for the stateful lifecycle, server/discover with _meta for the stateless lifecycle. The Host/Origin rejection check is unchanged since rejection happens before body parsing. * feat(everything-server): route stateless carry-forward methods to McpServer Adds an in-memory dispatch client connected to the same McpServer the stateful path uses. Stateless requests for tools/call, resources/*, prompts/get and completion/complete that fall through the MRTR-specific handlers are routed to it, so the fixture serves the carry-forward scenarios under --spec-version draft without duplicating ~500 lines of tool/resource/prompt registrations. tools/list now merges the McpServer's tool list with the MRTR-only stubs so json-schema-2020-12 finds its tool. draft suite against the fixture: 36/39 (was 13/39). Remaining 3 are fixture-side SEP gaps (no SSE forwarding for progress, no SEP-2549 ttlMs, no SEP-2243 Mcp-Method validation in the stateless path). * refactor(connection): drop unused RequestOptions; move sdk-client; add tests Addresses self-review findings on the new connection module: - RequestOptions.handlers/.meta and ServerRequestHandler removed: zero callers (the scenarios that motivated them are deferred). They can be reintroduced when something actually uses them. - scenarios/server/client-helper.ts moved to connection/sdk-client.ts so the connection module no longer depends on the scenarios module. - connectStateless: handle CRLF SSE event separators; throw a useful error for non-JSON/non-SSE responses instead of a JSON parse error. - JSONRPCNotification consistently imported from spec-types/2025-11-25. - New connection.test.ts (8 tests) covering connectFor selection, _meta injection, error mapping, SSE LF/CRLF parsing, and the server-request-on-stream rejection. * fix: address bughunt findings (response.ok check; targetVersion naming) - connectStateless: throw on non-2xx responses that lack a JSON-RPC error envelope (e.g. gateway/framework error JSON), matching the stateful path's behavior. Previously such a response would return undefined as the result. - json-schema-2020-12: rename negotiatedVersion to targetVersion and reword the skip message and details field. The value is ctx.specVersion (the run's --spec-version), not a negotiated version; the old wording was misleading. Drop dead 'unknown' fallback (specVersion is required) and the corresponding undefined test cases. * fix(sse-multiple-streams): keep scenario in draft; version-aware request scaffolding Concurrent POSTs each answered with JSON or their own SSE stream are core transport behavior in the draft spec too, so removedIn was wrong for this scenario. Only the request scaffolding is version-specific: stateful runs keep the initialize/session-id setup, draft runs send _meta + the MCP-Protocol-Version header on each request instead. The two checks are unchanged. * feat: add MockServer abstraction and ScenarioContext MockServer encapsulates the lifecycle scaffold a client-conformance scenario presents to the client-under-test: - createServerStateful: 2025-x lifecycle. SDK Server + StreamableHTTPServerTransport (sessionless mode); the SDK handles the initialize handshake. - createServerStateless: 2026-x lifecycle (SEP-2575). Raw express app that validates _meta + MCP-Protocol-Version on every request, serves server/discover, routes other methods to the supplied handlers. createServerFor(specVersion) picks the implementation. ScenarioContext bundles specVersion and a bound createServer() for the runner to hand to each scenario. This is the client-conformance mirror of src/connection (PR #318). Nothing uses it yet; wiring follows in the next commit. * refactor: thread ScenarioContext through Scenario.start() Scenario.start() becomes start(ctx: ScenarioContext). The runner builds the context from --spec-version (defaulting to LATEST_SPEC_VERSION) and passes it through; scenarios receive it as _ctx and otherwise behave identically. No scenario uses ctx.createServer() yet, so behaviour is unchanged: 231/231 tests pass. Test files use a testScenarioContext() helper. The runner already threads MCP_CONFORMANCE_PROTOCOL_VERSION to the spawned client process, so the fixture-side env wiring is unchanged. * refactor: migrate tools_call to ctx.createServer; tag 2025-only client scenarios ToolsCallScenario now goes through ctx.createServer() instead of an inline express + SDK Server build. Same handlers, same checks; the assertion now reads from srv.recorded so it works regardless of which lifecycle scaffold the runner picked. initialize, sse-retry, and elicitation-defaults are tagged removedIn: DRAFT (initialize/GET-SSE/SSE-embedded-elicitation are gone in the 2026 lifecycle; the MRTR sibling for elicitation-defaults is a follow-up). spec-version.test.ts: the 'draft is a superset of latest' invariant no longer holds once removedIn: DRAFT exists; the test now asserts that any scenario in latest-but-not-draft is explicitly removedIn. * feat(auth): make createServer helper version-aware via ScenarioContext The auth helper now takes ctx: ScenarioContext as its first argument and branches on ctx.specVersion inside the /mcp route: the stateful path (SDK Server + StreamableHTTPServerTransport) is unchanged; under the draft version a raw stateless handler validates _meta + the MCP-Protocol-Version header, serves server/discover, and routes the same tools/list and tools/call responses. The PRM endpoint, bearer-auth middleware, and request logger sit above the branch and are version-independent. All 25 call sites across the 12 auth scenario files pass ctx through; ServerLifecycle and the express.Application return type are unchanged so stop()/getChecks() are untouched. Deviation from the MockServer wrapper approach: keeping the helper's return type as express.Application avoids restructuring 25 call sites' ServerLifecycle handling in this PR. Folding the auth seam onto ctx.createServer() fully is a follow-up once the lifecycle ownership moves into MockServer. * feat(everything-client): pick stateless requester by MCP_CONFORMANCE_PROTOCOL_VERSION Adds a statelessRequest(serverUrl, method, params) helper that POSTs with _meta + MCP-Protocol-Version (the SEP-2575 lifecycle), shimming around the SDK Client not yet supporting stateless mode. The runRequestMetadataClient handler's meta constants are extracted to share with the helper. runBasicClient (initialize, tools_call, json-schema-ref-no-deref) now branches on MCP_CONFORMANCE_PROTOCOL_VERSION: for the draft version it uses statelessRequest to call tools/list then tools/call; for dated versions it keeps the SDK Client path. The runner already passes MCP_CONFORMANCE_PROTOCOL_VERSION to the spawned client, so no runner change is needed. * fix: address review findings on MockServer (dead opts, shared validator, capability derivation, recorded parity, specVersion threading) - MockServerOptions removed (capabilities/configure had zero callers); opts param dropped from createServerStateful/Stateless/For and ScenarioContext. - validateStatelessRequest extracted from mock-server/stateless and exported; both the stateless MockServer and auth/helpers/createServer.ts call it so _meta/header/version validation cannot drift. - isStatefulVersion exported from connection/select; mock-server/select uses it instead of duplicating the version set. - runner/client.ts: env MCP_CONFORMANCE_PROTOCOL_VERSION set unconditionally to the resolved version; runInteractiveMode now takes specVersion and the CLI passes it. - createServerStateful: capabilities derived from handler method prefixes; newServer() moved inside the try so a capability mismatch surfaces as JSON-RPC -32603 instead of an HTML 500. Recording moved to the express layer so unregistered methods are captured (parity with stateless). - readFinalSseMessage return type now declares error.data. - Tests added for the capability derivation and unregistered-method recording. * fix(mock-server): record stateless requests before validation; document preamble exclusions The stateless impl pushed to recorded[] only after validateStatelessRequest passed, so requests rejected for a missing header or _meta never appeared, diverging from the stateful impl which records at the HTTP layer before SDK dispatch. Record before validation, excluding only the server/discover lifecycle preamble (mirroring the stateful initialize/notifications-initialized exclusion), and fix the MockServer.recorded docstring which wrongly claimed stateless excludes nothing. * refactor(mock-server): tri-state result for validateStatelessRequest (reject/handled/route) A successful server/discover was previously modeled as {ok: false}, which read as a validation failure but actually meant "already answered, do not route". Replace the boolean discriminant with an explicit kind: 'reject' (validation failed), 'handled' (valid and already answered), or 'route' (dispatch to handlers). Both consumers (the stateless /mcp route and the auth helper's handleStateless) still write status/body for reject and handled and route only on 'route', so responses are unchanged. Add direct unit tests covering each branch of the new union. * feat(runner): single-source the version→lifecycle mapping; export MCP_CONFORMANCE_LIFECYCLE The stateless mock and the example client both hardcoded the draft protocol version, so dating the draft (or adding a second stateless version) would silently break them: the mock would keep rejecting everything but the old constant, and the client would fall back to the stateful path. - Derive STATELESS_SPEC_VERSIONS and a lifecycleFor() helper from isStatefulVersion() in connection/select.ts, making it the single source of truth for the version→lifecycle mapping. - Thread the resolved spec version into the stateless mock: createServerFor() binds it into createServerStateless(), and validateStatelessRequest() now takes the supported-versions list as a parameter. A mock bound to a version accepts exactly that version and echoes it in -32004 rejections and server/discover; unbound mocks accept every known stateless version. - Export MCP_CONFORMANCE_LIFECYCLE (stateful|stateless) to client processes alongside MCP_CONFORMANCE_PROTOCOL_VERSION, in both the runner and the in-process test helper, so clients in any language can pick the lifecycle without keeping their own version map. - everything-client: import DRAFT_PROTOCOL_VERSION from src/types instead of duplicating the string, branch on MCP_CONFORMANCE_LIFECYCLE (falling back to the version comparison for older runners), and send the runner-resolved version on stateless requests. - Document MCP_CONFORMANCE_LIFECYCLE in the README and ignore the local .claude/ workspace dir in .prettierignore so prettier --check . is not tripped by untracked worktree checkouts. * fix(everything-client): list-only flow for json-schema-ref-no-deref runBasicClient now follows tools/list with a tools/call on the first listed tool, but the json-schema-ref-no-deref scenario mock only serves tools/list, so the call got -32601 and the client exited non-zero. Register the scenario with a dedicated handler that stops after listing tools, keeping the existing lifecycle branch (raw stateless request vs SDK client). Listing is the whole flow for SEP-2106: the check is that the client never fetches the network $ref canary while processing the advertised schema. * fix(tools_call): make getChecks() idempotent getChecks() pushed into an instance array on every call, so calling it more than once accumulated duplicate check entries. The runner is allowed to call getChecks() repeatedly, and other scenarios already build their result fresh per call for this reason. Return a freshly built array instead and drop the now-unused instance field. Add a unit test covering the not-called FAILURE case and asserting repeated getChecks() calls do not grow the result. * fix: ensure dispatch and transport cleanup runs when handlers throw The stateless tools/list and prompts/list handlers in the everything server awaited dispatch.close() after the in-memory request, so a throw from dispatch.client.request() leaked the Client+Server+Transport pair. Wrap both in try/finally, matching the tools/call and catch-all handlers in the same file. In the stateful mock server, the res 'close' listener that tears down the per-request SDK transport and server was registered after the handleRequest await; a throw there skipped registration and leaked the pair. Register the listener before handing the request to the transport. * fix(everything-client): send Accept header on stateless requests The draft transport spec requires POST requests to accept both application/json and text/event-stream. Servers built on the SDK's StreamableHTTPServerTransport already enforce this and reject requests without it. Does not yet make json-schema-ref-no-deref pass under --spec-version draft: the SDK transport also rejects the draft protocol version itself, which is the known fixture gap until the 2026-native scenarios migrate onto MockServer. * chore: exclude .claude/ from vitest globs Local agent worktree checkouts under .claude/worktrees/ carry their own copies of the test suite, which the bare **/*.test.ts include was adding to every run (and to the pre-push hook). Mirrors the existing .prettierignore entry. * fix(request-metadata): use spec error code -32004 for unsupported protocol version The draft spec defines -32004 (UnsupportedProtocolVersionError) with data { supported, requested } and HTTP 400 for protocol version rejections; -32001 (HeaderMismatch) is reserved for HTTP header validation failures. - request-metadata scenario: return -32004 with spec-shaped data for the simulated version-negotiation rejection (was -32001) - everything-client: trigger negotiation on -32004 (was -32001) - stateless mock server: include the requested version in the -32004 error data alongside the supported list - update tests to match and assert the new requested field * fix(auth): register transport cleanup before handleRequest in createServer The res 'close' listener that tears down the transport/server pair was registered after awaiting handleRequest, so a throw mid-request skipped cleanup registration entirely. Register it first, matching the stateful mock server. * fix(everything-server): return JSON-RPC errors from stateless list dispatch on failure The stateless tools/list and prompts/list dispatch handlers had try/finally but no catch, so a dispatch failure produced an unhandled rejection instead of a response. Add the same catch-and-respond block the carry-forward dispatch handler already uses. * refactor(runner): drop MCP_CONFORMANCE_LIFECYCLE; derive lifecycle from protocol version The protocol version fully determines the lifecycle, and deriving it is part of being a conformant client. The in-repo example client imports the stateless version set from src/, so there is no drift risk, and a single env var cannot disagree with itself. README documents the version→lifecycle mapping for clients in other languages. * fix(everything-server): return JSON-RPC errors from stateless resource list dispatch on failure resources/list and resources/templates/list arrived from main with try/finally but no catch, unlike the sibling tools/list and prompts/list handlers. A throwing dispatch would escape to Express's HTML 500 instead of a JSON-RPC error. Mirrors the existing pattern. * fix(everything-server): include requested field in -32004 error data The spec's UnsupportedProtocolVersionError requires data to carry both supported and requested. The mock-server and request-metadata -32004 producers already include it; this was the remaining producer. * feat(runner): client-side spec-version inference, skip, and --force Mirrors the server runner's policy from #318: an explicitly requested --spec-version outside a scenario's applicability window skips it (exit 0) unless --force; when --spec-version is omitted the version is inferred from the scenario's applicability instead of defaulting to the latest dated version. * fix(auth): gate SEP-837 application_type check to spec versions that include it The sep-837-application-type-present check fired on every Dynamic Client Registration regardless of the targeted spec version, but SEP-837 only exists in the draft spec. Clients conforming to 2025-11-25 or earlier were failed on a requirement that is not part of the version under test. Thread the ScenarioContext into createAuthServer (mirroring createServer) and only emit the check when the resolved spec version includes SEP-837, i.e. the stateless draft lifecycle. At dated versions the check is not emitted at all. Audited the other auth helper checks (PKCE, metadata discovery, token verification); all of them enforce requirements that are already in the dated specs, so SEP-837 was the only draft-only check. Unit tests cover both sides: the check fails an omitting client at the draft version and is absent at 2025-11-25. * feat(sdk-runner): --spec-version passthrough with per-SDK default The sdk command forwarded --scenario/--suite/--timeout to the underlying client/server invocation but had no way to set the spec version, so SDK runs always used the suite default. Add a --spec-version flag that is passed through to the inner run. Also add an optional specVersion field to the per-SDK config, used as the default when the flag isn't given; an explicit --spec-version overrides it. Set typescript-sdk-v1 to 2025-11-25: the v1 line targets the latest dated spec, which keeps draft-only scenarios out of its runs by default. --------- Co-authored-by: Paul Carleton --- README.md | 10 +- .../clients/typescript/everything-client.ts | 156 ++++++- .../servers/typescript/everything-server.ts | 29 +- src/connection/connection.test.ts | 19 +- src/connection/select.ts | 28 +- src/index.ts | 38 +- src/mock-server/index.ts | 60 +++ src/mock-server/mock-server.test.ts | 395 ++++++++++++++++++ src/mock-server/select.ts | 13 + src/mock-server/stateful.ts | 145 +++++++ src/mock-server/stateless.ts | 194 +++++++++ src/mock-server/testing.ts | 17 + src/runner/client.test.ts | 67 +++ src/runner/client.ts | 106 ++++- .../auth/authorization-server-migration.ts | 8 +- src/scenarios/client/auth/basic-cimd.ts | 6 +- .../client/auth/client-credentials.ts | 11 +- .../client/auth/discovery-metadata.ts | 7 +- .../auth/enterprise-managed-authorization.ts | 6 +- .../client/auth/helpers/createAuthServer.ts | 39 +- .../client/auth/helpers/createServer.ts | 47 ++- src/scenarios/client/auth/index.test.ts | 15 +- src/scenarios/client/auth/issuer-parameter.ts | 36 +- .../client/auth/march-spec-backcompat.ts | 9 +- src/scenarios/client/auth/offline-access.ts | 11 +- src/scenarios/client/auth/pre-registration.ts | 6 +- .../client/auth/resource-mismatch.ts | 6 +- src/scenarios/client/auth/scope-handling.ts | 26 +- .../client/auth/test_helpers/testClient.ts | 19 +- .../client/auth/token-endpoint-auth.ts | 6 +- src/scenarios/client/elicitation-defaults.ts | 10 +- src/scenarios/client/http-base.ts | 3 +- .../client/http-custom-headers.test.ts | 11 +- src/scenarios/client/http-custom-headers.ts | 5 +- .../client/http-standard-headers.test.ts | 7 +- src/scenarios/client/initialize.ts | 11 +- .../client/json-schema-ref-deref.test.ts | 3 +- src/scenarios/client/json-schema-ref-deref.ts | 3 +- src/scenarios/client/mrtr-client.ts | 3 +- src/scenarios/client/request-metadata.test.ts | 6 +- src/scenarios/client/request-metadata.ts | 6 +- src/scenarios/client/sse-retry.ts | 15 +- src/scenarios/client/tools_call.test.ts | 48 +++ src/scenarios/client/tools_call.ts | 213 +++------- src/scenarios/spec-version.test.ts | 11 +- src/sdk-runner/config.ts | 6 +- src/sdk-runner/index.ts | 15 +- src/sdk-runner/known-sdks.ts | 4 +- src/sdk-runner/sdk-runner.test.ts | 17 +- src/types.ts | 3 +- 50 files changed, 1638 insertions(+), 297 deletions(-) create mode 100644 src/mock-server/index.ts create mode 100644 src/mock-server/mock-server.test.ts create mode 100644 src/mock-server/select.ts create mode 100644 src/mock-server/stateful.ts create mode 100644 src/mock-server/stateless.ts create mode 100644 src/mock-server/testing.ts create mode 100644 src/runner/client.test.ts create mode 100644 src/scenarios/client/tools_call.test.ts diff --git a/README.md b/README.md index 71a8cb2..41f5e58 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,13 @@ 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: `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`, `DRAFT-2026-v1`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios +- `--spec-version ` - Filter scenarios by spec version (e.g., `2025-11-25`, `DRAFT-2026-v1`; `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` - `--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. 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. +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 (`DRAFT-2026-v1`) uses the stateless lifecycle (per-request `_meta`). ### Server Testing @@ -237,6 +238,11 @@ 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. diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 63ca051..ef272e4 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -21,6 +21,8 @@ import { } from '@modelcontextprotocol/sdk/client/auth-extensions.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 @@ -70,10 +72,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: {} } @@ -91,13 +218,7 @@ async function runBasicClient(serverUrl: string): Promise { logger.debug('Connection closed successfully'); } -registerScenarios(['initialize', '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, so the basic connect+listTools flow is the -// correct behavior here. -registerScenario('json-schema-ref-no-deref', runBasicClient); +registerScenario('json-schema-ref-no-deref', runListToolsOnlyClient); // ============================================================================ // request-metadata scenario (SEP-2575) @@ -106,20 +227,9 @@ registerScenario('json-schema-ref-no-deref', runBasicClient); async function runRequestMetadataClient(serverUrl: string): Promise { logger.debug('Starting request-metadata client flow...'); - const meta = { - 'io.modelcontextprotocol/clientInfo': { - name: 'conformance-test-client', - version: '1.0.0' - }, - 'io.modelcontextprotocol/clientCapabilities': { - tools: {}, - roots: {}, - sampling: {}, - elicitation: {} - } - }; + const meta = STATELESS_META_BASE; - let activeVersion = 'DRAFT-2026-v1'; + let activeVersion = STATELESS_PROTOCOL_VERSION; const sendRequestWithNegotiation = async ( method: string, @@ -166,7 +276,9 @@ async function runRequestMetadataClient(serverUrl: string): Promise { ); const serverSupported: string[] = errorResult.error.data?.supported || []; - const clientSupported = ['DRAFT-2026-v1']; + const clientSupported = [ + ...new Set([STATELESS_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION]) + ]; const mutuallySupported = clientSupported.filter((v) => serverSupported.includes(v) ); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 7a587ff..ce43945 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1283,7 +1283,10 @@ app.post('/mcp', async (req, res) => { error: { code: -32004, message: 'UnsupportedProtocolVersionError', - data: { supported: ['DRAFT-2026-v1'] } + data: { + supported: ['DRAFT-2026-v1'], + requested: String(metaVersion) + } } }); } @@ -1436,6 +1439,12 @@ app.post('/mcp', async (req, res) => { cacheScope: 'public' } }); + } catch (e: any) { + return res.json({ + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); } finally { await dispatch.close(); } @@ -1465,6 +1474,12 @@ app.post('/mcp', async (req, res) => { cacheScope: 'public' } }); + } catch (e: any) { + return res.json({ + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); } finally { await dispatch.close(); } @@ -1549,6 +1564,12 @@ app.post('/mcp', async (req, res) => { cacheScope: 'public' } }); + } catch (e: any) { + return res.json({ + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); } finally { await dispatch.close(); } @@ -1570,6 +1591,12 @@ app.post('/mcp', async (req, res) => { cacheScope: 'public' } }); + } catch (e: any) { + return res.json({ + jsonrpc: '2.0', + id, + error: { code: e.code ?? -32603, message: e.message, data: e.data } + }); } finally { await dispatch.close(); } diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index b308284..be09341 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { connectFor } from './select'; +import { + connectFor, + isStatefulVersion, + STATELESS_SPEC_VERSIONS +} from './select'; import { connectStateful } from './stateful'; import { connectStateless } from './stateless'; import { JsonRpcError } from './index'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; describe('connectFor', () => { it('returns stateful for dated 2025-x versions', () => { @@ -20,6 +25,18 @@ describe('connectFor', () => { }); }); +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); diff --git a/src/connection/select.ts b/src/connection/select.ts index ca33b4e..aa6fe17 100644 --- a/src/connection/select.ts +++ b/src/connection/select.ts @@ -1,4 +1,8 @@ -import type { SpecVersion } from '../types'; +import { + DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION, + type SpecVersion +} from '../types'; import type { Connection } from './index'; import { connectStateful } from './stateful'; import { connectStateless } from './stateless'; @@ -14,10 +18,30 @@ const STATEFUL_VERSIONS: ReadonlySet = new Set([ '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) => Promise { - return STATEFUL_VERSIONS.has(specVersion) + return isStatefulVersion(specVersion) ? connectStateful : // Pass the version through so stateless requests declare the spec // version the run was invoked with (matters under --force). diff --git a/src/index.ts b/src/index.ts index 637a357..f122ccb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -101,6 +101,10 @@ 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('--verbose', 'Show verbose output') .action(async (options) => { try { @@ -157,11 +161,13 @@ program scenarioName, timeout, outputDir, - specVersionFilter + specVersionFilter, + options.force ?? false ); return { scenario: scenarioName, checks: result.checks, + skipped: result.skipped, error: null }; } catch (error) { @@ -178,6 +184,7 @@ program error instanceof Error ? error.message : String(error) } ], + skipped: undefined, error }; } @@ -189,8 +196,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; @@ -222,8 +238,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) { @@ -255,7 +272,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); } @@ -265,9 +288,16 @@ program validated.scenario, timeout, outputDir, - specVersionFilter + 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, diff --git a/src/mock-server/index.ts b/src/mock-server/index.ts new file mode 100644 index 0000000..7303cb8 --- /dev/null +++ b/src/mock-server/index.ts @@ -0,0 +1,60 @@ +/** + * 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 } 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..c0247f8 --- /dev/null +++ b/src/mock-server/mock-server.test.ts @@ -0,0 +1,395 @@ +import { describe, it, expect } from 'vitest'; +import { createServerFor } from './select'; +import { createServerStateful } from './stateful'; +import { createServerStateless, validateStatelessRequest } from './stateless'; +import { STATELESS_SPEC_VERSIONS } from '../connection/select'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; + +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('rejects versions outside the supported list with -32004 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: -32004, + data: { supported: [DRAFT_PROTOCOL_VERSION], requested: '2099-01-01' } + } + } + }); + }); +}); + +describe('createServerFor', () => { + it('returns stateful for dated 2025-x versions', () => { + expect(createServerFor('2025-06-18')).toBe(createServerStateful); + expect(createServerFor('2025-11-25')).toBe(createServerStateful); + }); + 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(-32001); + } 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); + } 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); + expect(body.result.serverInfo.name).toBe('conformance-mock-server'); + } finally { + await srv.close(); + } + }); + + it('accepts the version it was created for and rejects others with -32004', 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(-32004); + 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' }] }) + }); + 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']); + } 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('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: [] }) + }); + 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: [] }) + }); + 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: [] }) + }); + 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(); + } + }); +}); diff --git a/src/mock-server/select.ts b/src/mock-server/select.ts new file mode 100644 index 0000000..3458647 --- /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) + ? createServerStateful + : (handlers) => createServerStateless(handlers, specVersion); +} diff --git a/src/mock-server/stateful.ts b/src/mock-server/stateful.ts new file mode 100644 index 0000000..8dab541 --- /dev/null +++ b/src/mock-server/stateful.ts @@ -0,0 +1,145 @@ +/** + * 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 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 +): 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 { + return (await handler( + (request.params ?? {}) as Record, + request as JSONRPCRequest + )) as Record; + } 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; + 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..78fd638 --- /dev/null +++ b/src/mock-server/stateless.ts @@ -0,0 +1,194 @@ +/** + * Stateless mock server: 2026-x lifecycle (SEP-2575). + * + * No initialize handshake. Validates `_meta` (protocolVersion / clientInfo / + * clientCapabilities) 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 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 { capabilitiesFromHandlers } from './stateful'; + +const META_KEYS = [ + 'io.modelcontextprotocol/protocolVersion', + 'io.modelcontextprotocol/clientInfo', + 'io.modelcontextprotocol/clientCapabilities' +] as const; + +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 -32004 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, -32001, '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, + -32001, + '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: -32004, + 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: { + supportedVersions, + capabilities, + 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 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; + if (body?.method && body.method !== 'server/discover') { + recorded.push(req.body as JSONRPCRequest); + } + const v = validateStatelessRequest(req, capabilities, supportedVersions); + if (v.kind !== 'route') { + return res.status(v.status).json(v.body); + } + const { id, method, params } = v; + const error = (status: number, code: number, message: string) => + res.status(status).json({ 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 res.json({ jsonrpc: '2.0', id, result }); + } 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/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 1bf8c9f..2f9898c 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -1,8 +1,15 @@ import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; -import { ConformanceCheck, SpecVersion } 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 { createResultDir, formatPrettyChecks } from './utils'; export interface ClientExecutionResult { @@ -35,9 +42,7 @@ async function executeClient( // 3. Semantic separation: scenario identifies "which test", context provides "test data" const env = { ...process.env }; env.MCP_CONFORMANCE_SCENARIO = scenarioName; - if (specVersion) { - env.MCP_CONFORMANCE_PROTOCOL_VERSION = specVersion; - } + 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({ @@ -92,17 +97,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, - specVersion?: SpecVersion + specVersion?: SpecVersion, + force = false ): Promise<{ checks: ConformanceCheck[]; - clientOutput: ClientExecutionResult; + clientOutput?: ClientExecutionResult; resultDir?: string; allowClientError?: boolean; + skipped?: boolean; }> { let resultDir: string | undefined; @@ -114,8 +166,23 @@ 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(); + const urls = await scenario.start(ctx); console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); if (urls.context) { @@ -129,7 +196,7 @@ export async function runConformanceTest( urls.serverUrl, timeout, urls.context, - specVersion + resolvedVersion ); // Print stdout/stderr if client exited with nonzero code @@ -269,7 +336,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; @@ -281,8 +350,23 @@ 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(); + const urls = await scenario.start(ctx); console.log(`Server URL: ${urls.serverUrl}`); console.log('Press Ctrl+C to stop...'); diff --git a/src/scenarios/client/auth/authorization-server-migration.ts b/src/scenarios/client/auth/authorization-server-migration.ts index 858bddb..6c7c989 100644 --- a/src/scenarios/client/auth/authorization-server-migration.ts +++ b/src/scenarios/client/auth/authorization-server-migration.ts @@ -6,6 +6,7 @@ * 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'; @@ -25,7 +26,7 @@ export class AuthorizationServerMigrationScenario 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, ['mcp:basic']); @@ -36,7 +37,7 @@ export class AuthorizationServerMigrationScenario implements Scenario { let as2SawAs1ClientIdAtToken = false; // ── AS₁ ──────────────────────────────────────────────────────────────── - const as1App = createAuthServer(this.checks, this.as1.getUrl, { + const as1App = createAuthServer(ctx, this.checks, this.as1.getUrl, { tokenVerifier, onRegistrationRequest: () => ({ clientId: AS1_CLIENT_ID, @@ -46,7 +47,7 @@ export class AuthorizationServerMigrationScenario implements Scenario { await this.as1.start(as1App); // ── AS₂ ──────────────────────────────────────────────────────────────── - const as2App = createAuthServer(this.checks, this.as2.getUrl, { + const as2App = createAuthServer(ctx, this.checks, this.as2.getUrl, { tokenVerifier, onRegistrationRequest: () => { as2SawRegister = true; @@ -125,6 +126,7 @@ export class AuthorizationServerMigrationScenario implements Scenario { }; const app = createServer( + ctx, this.checks, this.server.getUrl, currentAuthServerUrl, diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts index a99b4e3..1e4b37c 100644 --- a/src/scenarios/client/auth/basic-cimd.ts +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -29,10 +30,10 @@ export class AuthBasicCIMDScenario 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, { 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 eaebcb0..d633f7a 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; @@ -42,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'], @@ -201,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 @@ -263,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 ({ @@ -364,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 7387ead..3c53e08 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -6,6 +6,7 @@ * generated from them. */ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -94,10 +95,10 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { **OAuth metadata:** ${config.oauthMetadataLocation} `, - async start(): Promise { + async start(ctx: ScenarioContext): Promise { checks = []; - const authApp = createAuthServer(checks, authServer.getUrl, { + const authApp = createAuthServer(ctx, checks, authServer.getUrl, { metadataPath: config.oauthMetadataLocation, isOpenIdConfiguration, ...(routePrefix && { routePrefix }) @@ -131,7 +132,7 @@ 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 }); diff --git a/src/scenarios/client/auth/enterprise-managed-authorization.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts index ddadfbf..2310011 100644 --- a/src/scenarios/client/auth/enterprise-managed-authorization.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; @@ -69,7 +70,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { private idpPrivateKey?: CryptoKey; private grantKeypairs: Map = new Map(); - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Generate IDP keypair @@ -86,7 +87,7 @@ export class EnterpriseManagedAuthorizationScenario 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, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], tokenEndpointAuthMethodsSupported: ['client_secret_basic'], tokenVerifier, @@ -120,6 +121,7 @@ export class EnterpriseManagedAuthorizationScenario 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 d4acb7f..d7d1fc4 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -1,6 +1,8 @@ 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'; @@ -86,6 +88,7 @@ export interface AuthServerOptions { } export function createAuthServer( + ctx: ScenarioContext, checks: ConformanceCheck[], getAuthBaseUrl: () => string, options: AuthServerOptions = {} @@ -423,21 +426,27 @@ 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. - 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)' } - }); + // 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, diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index c836630..8cf04d9 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -10,6 +10,11 @@ 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, + type ScenarioContext +} from '../../../../mock-server'; +import { isStatefulVersion } from '../../../../connection/select'; import { createRequestLogger } from '../../../request-logger'; import { MockTokenVerifier } from './mockTokenVerifier'; import { SpecReferences } from '../spec-references'; @@ -27,6 +32,7 @@ export interface ServerOptions { } export function createServer( + ctx: ScenarioContext, checks: ConformanceCheck[], getBaseUrl: () => string, getAuthServerUrl: () => string, @@ -157,6 +163,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 +173,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 +196,38 @@ 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: { + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + } + }); + } + if (method === 'tools/call') { + return res.json({ + jsonrpc: '2.0', + id, + result: { 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/index.test.ts b/src/scenarios/client/auth/index.test.ts index 6089333..3d2d6ff 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -21,6 +21,7 @@ import { runClient as issNormalizeClient } from '../../../../examples/clients/ty import { runClient as echoScopeClient } from '../../../../examples/clients/typescript/auth-test-echo-scope'; import { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; beforeAll(() => { setLogLevel('error'); @@ -176,13 +177,25 @@ describe('Negative tests', () => { ); }); - test('client omits application_type during DCR (SEP-837)', async () => { + 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', { diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index ecb2bc2..b55e974 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -1,3 +1,4 @@ +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'; @@ -30,13 +31,13 @@ export class IssParameterSupportedScenario implements Scenario { private checks: ConformanceCheck[] = []; private tokenRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.tokenRequestMade = 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, issParameterSupported: true, issInRedirect: 'correct', @@ -48,6 +49,7 @@ export class IssParameterSupportedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -99,13 +101,13 @@ export class IssParameterNotAdvertisedScenario implements Scenario { private checks: ConformanceCheck[] = []; private tokenRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.tokenRequestMade = 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, issParameterSupported: null, issInRedirect: 'omit', @@ -117,6 +119,7 @@ export class IssParameterNotAdvertisedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -170,14 +173,14 @@ export class IssParameterSupportedMissingScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = 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, issParameterSupported: true, issInRedirect: 'omit', // advertise support but don't send iss @@ -192,6 +195,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -256,14 +260,14 @@ export class IssParameterWrongIssuerScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = 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, issParameterSupported: true, issInRedirect: 'wrong', // send iss that doesn't match metadata issuer @@ -278,6 +282,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -343,14 +348,14 @@ export class IssParameterUnexpectedScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = 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, issParameterSupported: null, issInRedirect: 'wrong', // send mismatched iss without advertising support @@ -365,6 +370,7 @@ export class IssParameterUnexpectedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -435,14 +441,14 @@ export class IssParameterNormalizedVariantScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = 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, issParameterSupported: true, issInRedirect: 'normalized', // correct issuer + trailing slash @@ -457,6 +463,7 @@ export class IssParameterNormalizedVariantScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -522,13 +529,13 @@ export class MetadataIssuerMismatchScenario implements Scenario { private checks: ConformanceCheck[] = []; private metadataEndpointsUsed = false; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.metadataEndpointsUsed = 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, // 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. @@ -555,6 +562,7 @@ export class MetadataIssuerMismatchScenario 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/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index b250209..3e54388 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -17,17 +18,18 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { 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' }); const app = createServer( + ctx, this.checks, this.server.getUrl, this.server.getUrl, @@ -81,10 +83,11 @@ export class Auth20250326OEndpointFallbackScenario implements Scenario { 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 index afcd593..77eccca 100644 --- a/src/scenarios/client/auth/offline-access.ts +++ b/src/scenarios/client/auth/offline-access.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -33,14 +34,14 @@ export class OfflineAccessScopeScenario implements Scenario { private grantTypesChecked = false; private capturedCimdUrl: string | undefined; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.grantTypesChecked = false; this.capturedCimdUrl = undefined; const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, scopesSupported: ['mcp:basic', 'offline_access'], clientIdMetadataDocumentSupported: true, @@ -101,6 +102,7 @@ export class OfflineAccessScopeScenario implements Scenario { // 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, @@ -235,7 +237,7 @@ export class OfflineAccessNotSupportedScenario 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, [ @@ -243,7 +245,7 @@ export class OfflineAccessNotSupportedScenario implements Scenario { 'mcp:read' ]); - const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { tokenVerifier, scopesSupported: ['mcp:basic', 'mcp:read'], onAuthorizationRequest: (data) => { @@ -268,6 +270,7 @@ export class OfflineAccessNotSupportedScenario 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/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index b482e4f..11f8269 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; @@ -27,11 +28,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'], @@ -105,6 +106,7 @@ export class PreRegistrationScenario 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/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index 9c15d4d..2e8b02f 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -1,3 +1,4 @@ +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'; @@ -37,13 +38,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 +61,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, diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index c24d51d..2134678 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -22,13 +23,13 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { 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 +54,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -108,13 +110,13 @@ export class ScopeFromScopesSupportedScenario implements Scenario { 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 +150,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -204,12 +207,12 @@ export class ScopeOmittedWhenUndefinedScenario 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, onAuthorizationRequest: (data) => { // Check if client omitted scope parameter @@ -232,6 +235,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -291,7 +295,7 @@ export class ScopeStepUpAuthScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const initialScope = 'mcp:basic'; @@ -303,7 +307,7 @@ export class ScopeStepUpAuthScenario implements Scenario { 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++; @@ -437,6 +441,7 @@ export class ScopeStepUpAuthScenario implements Scenario { }; const baseApp = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -528,14 +533,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++; @@ -613,6 +618,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/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 0dbc8d8..8790a7b 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 } from '../../../../types'; import { spawn } from 'child_process'; const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete @@ -86,6 +88,12 @@ export class InlineClientRunner implements ClientRunner { export interface RunClientOptions { expectedFailureSlugs?: 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( @@ -93,7 +101,11 @@ export async function runClientAgainstScenario( scenarioName: string, options: RunClientOptions = {} ): Promise { - const { expectedFailureSlugs = [], allowClientError = false } = options; + const { + expectedFailureSlugs = [], + allowClientError = false, + specVersion + } = options; const runner = clientRunner; @@ -103,13 +115,15 @@ 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; 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, @@ -179,6 +193,7 @@ 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(); diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 79d6296..53643fd 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types.js'; import { ScenarioUrls } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; @@ -62,13 +63,13 @@ 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; 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,6 +138,7 @@ class TokenEndpointAuthScenario 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/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index c78f349..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 @@ -13,7 +14,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; 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'; - readonly source = { introducedIn: '2025-11-25' } as const; + 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 index 06c2afa..9f6187d 100644 --- a/src/scenarios/client/http-base.ts +++ b/src/scenarios/client/http-base.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios. * @@ -27,7 +28,7 @@ export abstract class BaseHttpScenario implements Scenario { protected port: number = 0; protected sessionId: string = `session-${Date.now()}`; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); diff --git a/src/scenarios/client/http-custom-headers.test.ts b/src/scenarios/client/http-custom-headers.test.ts index 88714ee..8a68f22 100644 --- a/src/scenarios/client/http-custom-headers.test.ts +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, it, expect } from 'vitest'; import { HttpCustomHeadersScenario, @@ -43,7 +44,7 @@ function statusesFor( 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(); + await scenario.start(testScenarioContext()); try { const checks = scenario.getChecks(); expect(idsOf(checks)).toEqual(new Set(CUSTOM_HEADERS_DECLARED_CHECK_IDS)); @@ -57,7 +58,7 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { it('maps each parameter kind to its requirement ID on a conforming tool call', async () => { const scenario = new HttpCustomHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { const nonAscii = 'Hello, 世界'; const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64'); @@ -123,7 +124,7 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { it('FAILs client-mirrors-designated-params when an annotated header is missing', async () => { const scenario = new HttpCustomHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await post( serverUrl, @@ -154,7 +155,7 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { 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(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); await post(serverUrl, { @@ -176,7 +177,7 @@ describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => { it('FAILs the violated constraint ID when the client calls an invalid tool', async () => { const scenario = new HttpInvalidToolHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); await post(serverUrl, { diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index e91cf2c..25e3661 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * HTTP Custom Headers conformance test scenario for MCP clients (SEP-2243) * @@ -186,8 +187,8 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { private toolCallReceived: boolean = false; private nullToolCallReceived: boolean = false; - async start(): Promise { - const urls = await super.start(); + 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 = { diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts index 8c5ff6a..3478e06 100644 --- a/src/scenarios/client/http-standard-headers.test.ts +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, it, expect } from 'vitest'; import { HttpStandardHeadersScenario } from './http-standard-headers'; @@ -38,7 +39,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { it('FAILs the initialize Mcp-Method emission when Mcp-Method is missing', async () => { const scenario = new HttpStandardHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await postInitialize(serverUrl, {}); // no Mcp-Method header const checks = scenario.getChecks(); @@ -53,7 +54,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { it('SUCCEEDs the initialize Mcp-Method emission when Mcp-Method matches', async () => { const scenario = new HttpStandardHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); const checks = scenario.getChecks(); @@ -68,7 +69,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { it('getChecks() is idempotent', async () => { const scenario = new HttpStandardHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); const first = scenario.getChecks(); diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index b5e2aef..be8d708 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -1,23 +1,28 @@ +import type { ScenarioContext } from '../../mock-server'; import http from 'http'; import { Scenario, ScenarioUrls, ConformanceCheck, LATEST_SPEC_VERSION, - NEGOTIABLE_PROTOCOL_VERSIONS + NEGOTIABLE_PROTOCOL_VERSIONS, + DRAFT_PROTOCOL_VERSION } from '../../types'; import { clientChecks } from '../../checks/index'; export class InitializeScenario implements Scenario { name = 'initialize'; - readonly source = { introducedIn: '2025-06-18' } as const; + 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); diff --git a/src/scenarios/client/json-schema-ref-deref.test.ts b/src/scenarios/client/json-schema-ref-deref.test.ts index b11d2d2..a309193 100644 --- a/src/scenarios/client/json-schema-ref-deref.test.ts +++ b/src/scenarios/client/json-schema-ref-deref.test.ts @@ -1,3 +1,4 @@ +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'; @@ -92,7 +93,7 @@ describe('json-schema-ref-no-deref (SEP-2106)', () => { test('client that never lists tools fails: requirement cannot be evaluated', async () => { const scenario = new JsonSchemaRefDerefScenario(); - await scenario.start(); + await scenario.start(testScenarioContext()); try { const checks = scenario.getChecks(); const check = checks.find( diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts index 91ccc9e..a7b315e 100644 --- a/src/scenarios/client/json-schema-ref-deref.ts +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; @@ -82,7 +83,7 @@ The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at private canaryRequests: Array<{ method: string; userAgent?: string }> = []; private toolsListed = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.canaryRequests = []; this.toolsListed = false; diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index 431fafe..4521ca0 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * SEP-2322: MRTR Client Conformance Tests * @@ -440,7 +441,7 @@ export class MRTRClientScenario implements Scenario { private httpServer: ReturnType | null = null; private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.app = createMRTRServer(this.checks); this.httpServer = this.app.listen(0); diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index b2f9b63..d1ef675 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, test, expect } from 'vitest'; import { runClientAgainstScenario, @@ -104,10 +105,9 @@ async function incompatibleVersionClient(serverUrl: string) { if (response.status === 400) { const body = await response.json(); - if (body.error?.code === -32004 || body.error?.code === -32001) { + if (body.error?.code === -32004) { return body; // Abort cleanly } - return body; } return response.json(); } @@ -207,7 +207,7 @@ describe('request-metadata client scenario — client never connects', () => { throw new Error('Scenario not found'); } - await scenario.start(); + await scenario.start(testScenarioContext()); try { const checks = scenario.getChecks(); const byId = new Map(checks.map((c) => [c.id, c])); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 9d9fd69..38576b5 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; import http from 'http'; import { Scenario, @@ -46,7 +47,7 @@ export class RequestMetadataScenario implements Scenario { private hasSimulatedRejection = false; private requestsObserved = 0; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.hasSimulatedRejection = false; this.checks = []; this.requestsObserved = 0; @@ -281,7 +282,8 @@ export class RequestMetadataScenario implements Scenario { code: -32004, message: 'Unsupported protocol version', data: { - supported: [DRAFT_PROTOCOL_VERSION] + supported: [DRAFT_PROTOCOL_VERSION], + requested: String(headerVersion ?? metaVersion ?? '') } } }) diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index b90bf40..e89b23e 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) * @@ -8,11 +9,19 @@ */ import http from 'http'; -import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; export class SSERetryScenario implements Scenario { name = 'sse-retry'; - readonly source = { introducedIn: '2025-11-25' } as const; + 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,7 +47,7 @@ export class SSERetryScenario implements Scenario { 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); diff --git a/src/scenarios/client/tools_call.test.ts b/src/scenarios/client/tools_call.test.ts new file mode 100644 index 0000000..b9fc42d --- /dev/null +++ b/src/scenarios/client/tools_call.test.ts @@ -0,0 +1,48 @@ +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'; + +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('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 59470f3..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 } 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'; 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/spec-version.test.ts b/src/scenarios/spec-version.test.ts index 93b4662..dc84b0e 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -6,6 +6,7 @@ import { listDraftClientScenarios, listActiveClientScenarios, listExtensionScenarios, + getScenario, getScenarioSpecVersions, resolveSpecVersion, ALL_SPEC_VERSIONS, @@ -57,11 +58,17 @@ describe('specVersions helpers', () => { expect(current.length).toBeGreaterThan(overlap.length); }); - it('the draft spec version is a superset of the latest dated release', () => { + 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) { - expect(draft.has(name)).toBe(true); + 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); diff --git a/src/sdk-runner/config.ts b/src/sdk-runner/config.ts index 0945b99..1f8a9e5 100644 --- a/src/sdk-runner/config.ts +++ b/src/sdk-runner/config.ts @@ -19,7 +19,11 @@ export const SdkConfigSchema = z.object({ readyTimeoutMs: z.number().int().positive().optional() }) .optional(), - expectedFailures: z.string().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() }); export type SdkConfig = z.infer; diff --git a/src/sdk-runner/index.ts b/src/sdk-runner/index.ts index 932c51e..0797163 100644 --- a/src/sdk-runner/index.ts +++ b/src/sdk-runner/index.ts @@ -119,6 +119,7 @@ function passThrough(options: { timeout?: string; verbose?: boolean; output?: string; + specVersion?: string; }): string[] { const args: string[] = []; if (options.scenario) args.push('--scenario', options.scenario); @@ -126,6 +127,7 @@ function passThrough(options: { if (options.timeout) args.push('--timeout', options.timeout); if (options.verbose) args.push('--verbose'); if (options.output) args.push('-o', options.output); + if (options.specVersion) args.push('--spec-version', options.specVersion); return args; } @@ -166,6 +168,10 @@ export function createSdkCommand(): Command { ) .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('--verbose', 'Verbose output (passed through)') .action(async (sdkArg: string | undefined, options) => { try { @@ -217,6 +223,9 @@ export function createSdkCommand(): Command { const output = options.output ? path.resolve(options.output) : undefined; + // Explicit flag wins over the per-SDK default. + const specVersion: string | undefined = + options.specVersion ?? builtinConfig.specVersion; if (buildCmd && !options.skipBuild) { console.error(`[sdk] Building: ${buildCmd}`); @@ -244,7 +253,8 @@ export function createSdkCommand(): Command { suite: options.suite ?? 'all', timeout: options.timeout, verbose: options.verbose, - output + output, + specVersion }) ]; if (expectedFailures) @@ -265,7 +275,8 @@ export function createSdkCommand(): Command { scenario: options.scenario, suite: options.suite, verbose: options.verbose, - output + output, + specVersion }) ]; if (expectedFailures) diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts index b6550df..528bfd8 100644 --- a/src/sdk-runner/known-sdks.ts +++ b/src/sdk-runner/known-sdks.ts @@ -22,10 +22,12 @@ export const KNOWN_SDKS: Record = { }, // 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. + // 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' diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts index c58e993..fbfa7fb 100644 --- a/src/sdk-runner/sdk-runner.test.ts +++ b/src/sdk-runner/sdk-runner.test.ts @@ -44,6 +44,14 @@ describe('SdkConfigSchema', () => { 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' } }) @@ -75,8 +83,15 @@ describe('lookupBuiltinConfig', () => { expect(v1?.defaultRef).toBe('v1.x'); }); - it('bare typescript-sdk (v2) has no defaultRef', () => { + 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('every built-in entry validates against SdkConfigSchema', () => { diff --git a/src/types.ts b/src/types.ts index 6c6376e..2e9dd22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { RunContext } from './connection'; +import type { ScenarioContext } from './mock-server'; export type CheckStatus = | 'SUCCESS' @@ -102,7 +103,7 @@ export interface Scenario { * 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[]; } From 25fd44323ff3fe28967b95ea8105de47f674b7d8 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 3 Jun 2026 15:27:20 +0100 Subject: [PATCH 153/226] chore: bump version to 0.2.0-alpha.2 (#328) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5647308..3ddfc49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.1", + "version": "0.2.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.1", + "version": "0.2.0-alpha.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 4fe45a4..c26cc45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.1", + "version": "0.2.0-alpha.2", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 6f83abf786c6911c8858b03ee143195c67e7a42a Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:47:17 +0100 Subject: [PATCH 154/226] fix: update the draft wire version to 2026-07-28 (#331) * chore: sync spec-types to modelcontextprotocol@0168c57f Refreshes the vendored schema copies via npm run sync-schema. The draft schema now declares LATEST_PROTOCOL_VERSION = "2026-07-28" (previously the DRAFT-2026-v1 placeholder) and picks up the latest draft doc-comment and type updates. The dated schemas are unchanged. * fix: update the draft wire version to 2026-07-28 The spec repo's draft schema now declares LATEST_PROTOCOL_VERSION = "2026-07-28", but the harness still asserted the old DRAFT-2026-v1 placeholder, so an SDK that sends the version the draft spec actually declares cannot pass the draft suite. - bump DRAFT_PROTOCOL_VERSION in src/types.ts to 2026-07-28 - update the hard-coded literals in tests, example servers, and README - add src/types.test.ts pinning DRAFT_PROTOCOL_VERSION to the vendored draft schema's LATEST_PROTOCOL_VERSION so the two cannot drift, and asserting the draft identifier stays distinct from the dated versions --- README.md | 4 +- .../servers/typescript/everything-server.ts | 6 +- .../typescript/sep-2164-empty-contents.ts | 2 +- .../typescript/sep-2322-mrtr-broken-server.ts | 2 +- .../typescript/sep-2549-no-caching-hints.ts | 2 +- src/connection/connection.test.ts | 8 +- .../client/http-standard-headers.test.ts | 2 +- src/scenarios/client/request-metadata.test.ts | 6 +- src/scenarios/server/caching.ts | 2 +- src/scenarios/server/stateless.test.ts | 4 +- src/spec-types/SOURCE | 2 +- src/spec-types/draft.ts | 149 +++++++++++++++--- src/types.test.ts | 19 +++ src/types.ts | 2 +- 14 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 src/types.test.ts diff --git a/README.md b/README.md index 41f5e58..a10ce3d 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,13 @@ 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: `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`, `DRAFT-2026-v1`; `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 +- `--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` - `--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. 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 (`DRAFT-2026-v1`) uses the stateless lifecycle (per-request `_meta`). +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 diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index ce43945..fb0d6bb 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1276,7 +1276,7 @@ app.post('/mcp', async (req, res) => { } // Protocol Version Negotiation Matrix (-32004, HTTP 400) - if (metaVersion !== 'DRAFT-2026-v1') { + if (metaVersion !== '2026-07-28') { return res.status(400).json({ jsonrpc: '2.0', id, @@ -1284,7 +1284,7 @@ app.post('/mcp', async (req, res) => { code: -32004, message: 'UnsupportedProtocolVersionError', data: { - supported: ['DRAFT-2026-v1'], + supported: ['2026-07-28'], requested: String(metaVersion) } } @@ -1344,7 +1344,7 @@ app.post('/mcp', async (req, res) => { jsonrpc: '2.0', id, result: { - supportedVersions: ['DRAFT-2026-v1'], + supportedVersions: ['2026-07-28'], capabilities: { tools: { listChanged: true }, // Explicitly announce dynamic capabilities matching Section 7 expectations prompts: { listChanged: true }, diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts index 570b456..464b68e 100644 --- a/examples/servers/typescript/sep-2164-empty-contents.ts +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -25,7 +25,7 @@ app.post('/mcp', (req, res) => { jsonrpc: '2.0', id, result: { - supportedVersions: ['DRAFT-2026-v1'], + supportedVersions: ['2026-07-28'], capabilities: { resources: {} }, serverInfo: { name: 'sep-2164-empty-contents', version: '1.0.0' } } diff --git a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts index 232e794..49fcdb6 100644 --- a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -23,7 +23,7 @@ type Handler = (params: Record) => unknown; const handlers: Record = {}; handlers['server/discover'] = () => ({ - supportedVersions: ['DRAFT-2026-v1'], + supportedVersions: ['2026-07-28'], capabilities: { tools: {}, prompts: {}, diff --git a/examples/servers/typescript/sep-2549-no-caching-hints.ts b/examples/servers/typescript/sep-2549-no-caching-hints.ts index 318c9fd..efee1c2 100644 --- a/examples/servers/typescript/sep-2549-no-caching-hints.ts +++ b/examples/servers/typescript/sep-2549-no-caching-hints.ts @@ -26,7 +26,7 @@ app.post('/mcp', (req, res) => { jsonrpc: '2.0', id, result: { - supportedVersions: ['DRAFT-2026-v1'], + supportedVersions: ['2026-07-28'], capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'sep-2549-no-caching-hints', version: '1.0.0' } } diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index be09341..822d3bb 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -20,8 +20,8 @@ describe('connectFor', () => { // version through), so identity with connectStateless no longer holds; // assert it did not select the stateful implementation. The wire-level // behaviour of the wrapper is covered in stateless.test.ts. - expect(connectFor('DRAFT-2026-v1')).not.toBe(connectStateful); - expect(connectFor('DRAFT-2026-v1')).not.toBe(connectStateless); + expect(connectFor('2026-07-28')).not.toBe(connectStateful); + expect(connectFor('2026-07-28')).not.toBe(connectStateless); }); }); @@ -64,10 +64,10 @@ describe('connectStateless', () => { await conn.request('tools/list'); const [, init] = mockFetch.mock.calls[0]; - expect(init.headers['MCP-Protocol-Version']).toBe('DRAFT-2026-v1'); + expect(init.headers['MCP-Protocol-Version']).toBe('2026-07-28'); const sent = JSON.parse(init.body); expect(sent.params._meta['io.modelcontextprotocol/protocolVersion']).toBe( - 'DRAFT-2026-v1' + '2026-07-28' ); expect( sent.params._meta['io.modelcontextprotocol/clientInfo'] diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts index 3478e06..9467456 100644 --- a/src/scenarios/client/http-standard-headers.test.ts +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -24,7 +24,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { id: 1, method: 'initialize', params: { - protocolVersion: 'DRAFT-2026-v1', + protocolVersion: '2026-07-28', clientInfo: { name: 'neg-test', version: '0' }, capabilities: {} } diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index d1ef675..e2046fe 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -26,7 +26,7 @@ async function badClient(serverUrl: string) { } const goodMeta = { - 'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1', + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientInfo': { name: 'test', version: '1.0' }, 'io.modelcontextprotocol/clientCapabilities': {} }; @@ -70,7 +70,7 @@ async function nonRetryingClient(serverUrl: string) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'MCP-Protocol-Version': 'DRAFT-2026-v1' + 'MCP-Protocol-Version': '2026-07-28' }, body: JSON.stringify({ jsonrpc: '2.0', @@ -118,7 +118,7 @@ async function malformedCapabilitiesClient(serverUrl: string) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'MCP-Protocol-Version': 'DRAFT-2026-v1' + 'MCP-Protocol-Version': '2026-07-28' }, body: JSON.stringify({ jsonrpc: '2.0', diff --git a/src/scenarios/server/caching.ts b/src/scenarios/server/caching.ts index 1c96a85..c7f4a5b 100644 --- a/src/scenarios/server/caching.ts +++ b/src/scenarios/server/caching.ts @@ -99,7 +99,7 @@ Servers MUST include \`ttlMs\` (integer >= 0) and \`cacheScope\` ("public" or "p // 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 - // DRAFT-2026-v1 plus the cross-cutting _meta and standard headers + // 2026-07-28 plus the cross-cutting _meta and standard headers // (issue #315). let conn: Connection; try { diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 0870af9..133d932 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -101,7 +101,7 @@ describe('Stateless Server Scenario Negative Tests', () => { jsonrpc: '2.0', id: reqBody.id, result: { - supportedVersions: ['DRAFT-2026-v1'], + supportedVersions: ['2026-07-28'], capabilities: {}, serverInfo: { name: 'bad-meta-server', version: '1.0.0' } } @@ -341,7 +341,7 @@ describe('Stateless Server Scenario Negative Tests', () => { jsonrpc: '2.0', id: reqBody.id, result: { - supportedVersions: ['DRAFT-2026-v1'], + supportedVersions: ['2026-07-28'], capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } diff --git a/src/spec-types/SOURCE b/src/spec-types/SOURCE index 201d600..49ed355 100644 --- a/src/spec-types/SOURCE +++ b/src/spec-types/SOURCE @@ -1 +1 @@ -modelcontextprotocol@33c3724972e6a87a8ad153e3f8a0d4714b0b9536 +modelcontextprotocol@0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65 diff --git a/src/spec-types/draft.ts b/src/spec-types/draft.ts index 3d7cd42..f99c529 100644 --- a/src/spec-types/draft.ts +++ b/src/spec-types/draft.ts @@ -34,7 +34,7 @@ export type JSONRPCMessage = | JSONRPCResponse; /** @internal */ -export const LATEST_PROTOCOL_VERSION = "DRAFT-2026-v1"; +export const LATEST_PROTOCOL_VERSION = "2026-07-28"; /** @internal */ export const JSONRPC_VERSION = "2.0"; @@ -102,6 +102,10 @@ export interface RequestMetaObject extends MetaObject { * 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; } @@ -162,7 +166,7 @@ export interface Notification { * 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"; +export type ResultType = "complete" | "input_required" | string; /** * Common result fields. @@ -292,15 +296,14 @@ export interface InvalidRequestError extends Error { /** * A JSON-RPC error indicating that the requested method does not exist or is not available. * - * In MCP, this error is returned when a request is made for a method that requires a capability that has not been declared. This can occur in either direction: + * 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 server returning this error when the client requests a capability it doesn't support (e.g., requesting completions when the `completions` capability was not advertised) - * - A client returning this error when the server requests a capability it doesn't support (e.g., requesting roots when the client did not declare the `roots` capability) + * A request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32003`). * * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} * - * @example Roots not supported - * {@includeCode ./examples/MethodNotFoundError/roots-not-supported.json} + * @example Prompts not supported + * {@includeCode ./examples/MethodNotFoundError/prompts-not-supported.json} * * @category Errors */ @@ -578,7 +581,7 @@ export interface DiscoverRequest extends JSONRPCRequest { * * @category `server/discover` */ -export interface DiscoverResult extends Result { +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. @@ -628,6 +631,10 @@ export interface ClientCapabilities { /** * 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} */ @@ -636,14 +643,18 @@ export interface ClientCapabilities { /** * 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 (soft-deprecated) - * {@includeCode ./examples/ClientCapabilities/sampling-context-inclusion-support-soft-deprecated.json} + * @example Sampling — context inclusion support (deprecated) + * {@includeCode ./examples/ClientCapabilities/sampling-context-inclusion-support-deprecated.json} */ sampling?: { /** @@ -675,7 +686,10 @@ export interface ClientCapabilities { * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are * per-extension settings objects. An empty object indicates support with no settings. * - * @example Extensions — UI extension with MIME type support + * 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 }; @@ -694,6 +708,10 @@ export interface ServerCapabilities { /** * 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} */ @@ -762,11 +780,14 @@ export interface ServerCapabilities { }; /** * Optional MCP extensions that the server supports. Keys are extension identifiers - * (e.g., "io.modelcontextprotocol/apps"), and values are per-extension settings + * (e.g., "io.modelcontextprotocol/tasks"), and values are per-extension settings * objects. An empty object indicates support with no settings. * - * @example Extensions — UI extension support - * {@includeCode ./examples/ServerCapabilities/extensions-ui.json} + * 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 }; } @@ -1848,6 +1869,10 @@ export interface Tool extends BaseMetadata, Icons { /** * 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} * @@ -1871,6 +1896,10 @@ export interface LoggingMessageNotificationParams extends NotificationParams { /** * 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} * @@ -1887,6 +1916,10 @@ export interface LoggingMessageNotification extends JSONRPCNotification { * 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 = @@ -1912,6 +1945,10 @@ export type LoggingLevel = * @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 { @@ -1928,8 +1965,12 @@ export interface CreateMessageRequestParams { * 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 {@link ClientCapabilities.sampling.context}. These values may be removed in future spec releases. + * 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"; /** @@ -1963,6 +2004,10 @@ export interface CreateMessageRequestParams { /** * 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 { @@ -1981,6 +2026,10 @@ export interface ToolChoice { * @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 { @@ -2002,6 +2051,10 @@ export interface CreateMessageRequest { * @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 { @@ -2033,6 +2086,10 @@ export interface CreateMessageResult extends SamplingMessage { * @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 { @@ -2042,6 +2099,10 @@ export interface SamplingMessage { } /** + * @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 = @@ -2190,6 +2251,10 @@ export interface AudioContent { * @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 { @@ -2225,6 +2290,10 @@ export interface ToolUseContent { * @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 { @@ -2284,6 +2353,10 @@ export interface ToolResultContent { * @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 { @@ -2338,6 +2411,10 @@ export interface ModelPreferences { * 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 { @@ -2486,6 +2563,10 @@ export interface PromptReference extends BaseMetadata { * @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 { @@ -2504,6 +2585,10 @@ export interface ListRootsRequest { * @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 { @@ -2516,6 +2601,10 @@ export interface ListRootsResult { * @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 { @@ -2666,8 +2755,17 @@ 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; } @@ -2916,6 +3014,18 @@ export interface ElicitResult { content?: { [key: string]: string | number | boolean | string[] }; } +/** + * Parameters for a {@link ElicitationCompleteNotification | notifications/elicitation/complete} notification. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotificationParams extends NotificationParams { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; +} + /** * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. * @@ -2926,12 +3036,7 @@ export interface ElicitResult { */ export interface ElicitationCompleteNotification extends JSONRPCNotification { method: "notifications/elicitation/complete"; - params: { - /** - * The ID of the elicitation that completed. - */ - elicitationId: string; - }; + params: ElicitationCompleteNotificationParams; } /* Client messages */ 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 2e9dd22..4da546f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,7 @@ export const LATEST_SPEC_VERSION: DatedSpecVersion = '2025-11-25'; * `LATEST_PROTOCOL_VERSION` in the spec repo's `schema/draft/schema.ts`; * bump when that constant changes. */ -export const DRAFT_PROTOCOL_VERSION = 'DRAFT-2026-v1'; +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[] = [ From 70b17b3945603c26aa4f4940e4b7e8eb61f9f25c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 8 Jun 2026 18:06:00 +0100 Subject: [PATCH 155/226] chore: bump version to 0.2.0-alpha.3 (#334) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3ddfc49..e7849fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index c26cc45..b6ced4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 28f83384eff3d95488a7c2c642ebaac353d30fe2 Mon Sep 17 00:00:00 2001 From: Bill Prin Date: Mon, 8 Jun 2026 10:37:38 -0700 Subject: [PATCH 156/226] feat: add python-sdk as a built-in SDK runner target (#329) * feat: add python-sdk as a built-in SDK runner target Wire modelcontextprotocol/python-sdk into the `conformance sdk` runner alongside typescript-sdk and go-sdk, using the SDK's existing conformance fixtures for both client and server modes. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: target python-sdk v1.x on spec 2025-11-25 Convert the built-in python-sdk entry from v2/main to python-sdk-v1, mirroring typescript-sdk-v1: clone the v1.x branch, default specVersion to 2025-11-25 so draft-only scenarios/checks are excluded, and point at the SDK's own expected-failures baseline. v2/main is mid-refactor and noisy; v1.x is the stable published line. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: default sdk-runner server mode to the `active` suite Server runs previously fell through to the underlying `server` command's default; make it explicit and consistent with tiering, which runs the `active` suite (excludes pending/draft). Avoids surfacing intentionally -deferred `pending` scenarios (e.g. json-schema-2020-12, server-sse -polling) as failures. Runner-wide default for all SDKs; callers can still override with --suite. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/sdk-runner/index.ts | 5 ++++- src/sdk-runner/known-sdks.ts | 23 +++++++++++++++++++++++ src/sdk-runner/sdk-runner.test.ts | 10 ++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/sdk-runner/index.ts b/src/sdk-runner/index.ts index 0797163..d4ac74d 100644 --- a/src/sdk-runner/index.ts +++ b/src/sdk-runner/index.ts @@ -273,7 +273,10 @@ export function createSdkCommand(): Command { serverUrl, ...passThrough({ scenario: options.scenario, - suite: options.suite, + // 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.suite ?? 'active', verbose: options.verbose, output, specVersion diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts index 528bfd8..8a6b30f 100644 --- a/src/sdk-runner/known-sdks.ts +++ b/src/sdk-runner/known-sdks.ts @@ -45,6 +45,29 @@ export const KNOWN_SDKS: Record = { command: './.conformance-server -http=:3000', url: 'http://localhost:3000' } + }, + // v1.x — the stable, published line of the python-sdk, analogous to + // typescript-sdk-v1 (v2/main is mid-refactor and noisy). 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' } }; diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts index fbfa7fb..6c6353e 100644 --- a/src/sdk-runner/sdk-runner.test.ts +++ b/src/sdk-runner/sdk-runner.test.ts @@ -77,6 +77,16 @@ describe('lookupBuiltinConfig', () => { expect(lookupBuiltinConfig('rust-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'); From b9041ea41b0188581803459dbae71bc7e02fd995 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:13:23 +0100 Subject: [PATCH 157/226] fix: include required result members in 2026-07-28 mock responses (#341) At the 2026-07-28 revision every server result must include resultType, and cacheable results (server/discover, tools/list, prompts/list, resources/list, resources/templates/list, resources/read) must also include ttlMs and cacheScope. The client-direction mock servers omitted these, so a strictly-validating client was failed by the suite's own non-conformant mock. The shared stateless mock now stamps the missing members onto handler results and its server/discover response (withRequiredDraftResultFields), and the hand-rolled per-scenario servers set them explicitly or route their fallbacks through the helper. The deliberately resultType-less probe in sep-2322-client-request-state is unchanged. New tests pin the response shapes. --- src/mock-server/index.ts | 7 +- src/mock-server/mock-server.test.ts | 138 +++++++- src/mock-server/stateless.ts | 49 ++- .../client/auth/helpers/createServer.ts | 9 +- .../client/draft-result-fields.test.ts | 296 ++++++++++++++++++ src/scenarios/client/http-base.ts | 10 +- src/scenarios/client/http-custom-headers.ts | 8 + src/scenarios/client/http-standard-headers.ts | 14 + src/scenarios/client/json-schema-ref-deref.ts | 3 + src/scenarios/client/mrtr-client.ts | 21 +- src/scenarios/client/request-metadata.ts | 17 +- src/scenarios/client/tools_call.test.ts | 57 ++++ 12 files changed, 612 insertions(+), 17 deletions(-) create mode 100644 src/scenarios/client/draft-result-fields.test.ts diff --git a/src/mock-server/index.ts b/src/mock-server/index.ts index 7303cb8..7c8470e 100644 --- a/src/mock-server/index.ts +++ b/src/mock-server/index.ts @@ -56,5 +56,10 @@ export interface ScenarioContext { } export { createServerStateful } from './stateful'; -export { createServerStateless, validateStatelessRequest } from './stateless'; +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 index c0247f8..8af684c 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest'; import { createServerFor } from './select'; import { createServerStateful } from './stateful'; -import { createServerStateless, validateStatelessRequest } from './stateless'; +import { + createServerStateless, + validateStatelessRequest, + withRequiredDraftResultFields, + CACHEABLE_RESULT_METHODS +} from './stateless'; import { STATELESS_SPEC_VERSIONS } from '../connection/select'; import { DRAFT_PROTOCOL_VERSION } from '../types'; @@ -73,6 +78,28 @@ describe('validateStatelessRequest', () => { 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 -32004 and echoes it', () => { const v = validateStatelessRequest( { @@ -105,6 +132,53 @@ describe('validateStatelessRequest', () => { }); }); +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 stateful for dated 2025-x versions', () => { expect(createServerFor('2025-06-18')).toBe(createServerStateful); @@ -286,6 +360,68 @@ describe('createServerStateless', () => { } }); + it('stamps the draft-required result members onto handler results', async () => { + const srv = await createServerStateless({ + 'tools/list': () => ({ tools: [{ name: 'x' }] }), + '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 } + }, + headers + ); + expect(body.result.resultType).toBe('input_required'); + } finally { + await srv.close(); + } + }); + it('returns -32601 for unknown methods', async () => { const srv = await createServerStateless({}); try { diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index 78fd638..904d0e0 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -20,6 +20,45 @@ const META_KEYS = [ '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 = @@ -114,11 +153,11 @@ export function validateStatelessRequest( body: { jsonrpc: '2.0', id, - result: { + result: withRequiredDraftResultFields(method, { supportedVersions, capabilities, serverInfo: { name: 'conformance-mock-server', version: '1.0.0' } - } + }) } }; } @@ -166,7 +205,11 @@ export async function createServerStateless( } try { const result = await handler(params, req.body as JSONRPCRequest); - return res.json({ jsonrpc: '2.0', id, result }); + return res.json({ + jsonrpc: '2.0', + id, + result: withRequiredDraftResultFields(method, result) + }); } catch (e) { return error(500, -32603, e instanceof Error ? e.message : String(e)); } diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index 8cf04d9..cd32d21 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -12,6 +12,7 @@ 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'; @@ -210,16 +211,18 @@ export function createServer( return res.json({ jsonrpc: '2.0', id, - result: { + result: withRequiredDraftResultFields(method, { tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] - } + }) }); } if (method === 'tools/call') { return res.json({ jsonrpc: '2.0', id, - result: { content: [{ type: 'text', text: 'test' }] } + result: withRequiredDraftResultFields(method, { + content: [{ type: 'text', text: 'test' }] + }) }); } return res.status(404).json({ 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..3d5c37a --- /dev/null +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { testScenarioContext } from '../../mock-server/testing'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; +import { HttpStandardHeadersScenario } from './http-standard-headers'; +import { + HttpCustomHeadersScenario, + HttpInvalidToolHeadersScenario +} from './http-custom-headers'; +import { RequestMetadataScenario } from './request-metadata'; +import { MRTRClientScenario } from './mrtr-client'; + +/** + * 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' +}; + +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. + { 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('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(CACHEABLE_FIELDS); + + 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 -32004 + // 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(); + } + }); +}); diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts index 9f6187d..866ed65 100644 --- a/src/scenarios/client/http-base.ts +++ b/src/scenarios/client/http-base.ts @@ -1,4 +1,7 @@ -import type { ScenarioContext } from '../../mock-server'; +import { + withRequiredDraftResultFields, + type ScenarioContext +} from '../../mock-server'; /** * Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios. * @@ -135,6 +138,7 @@ export abstract class BaseHttpScenario implements Scenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', protocolVersion: DRAFT_PROTOCOL_VERSION, serverInfo: { name: this.name + '-server', version: '1.0.0' }, capabilities @@ -151,7 +155,9 @@ export abstract class BaseHttpScenario implements Scenario { this.sendJson(res, { jsonrpc: '2.0', id: request.id, - result: {} + // Method-aware so cacheable methods that fall through to the generic + // reply still carry the ttlMs/cacheScope the draft revision requires. + result: withRequiredDraftResultFields(request.method, {}) }); } } diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index 25e3661..b445dc0 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -276,6 +276,9 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: 'test_custom_headers', @@ -591,6 +594,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'Custom headers test completed' }] } }); @@ -760,6 +764,9 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ // ── Valid tool (should be kept by client) ── { @@ -938,6 +945,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { 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.ts b/src/scenarios/client/http-standard-headers.ts index fb527d0..dcc45f6 100644 --- a/src/scenarios/client/http-standard-headers.ts +++ b/src/scenarios/client/http-standard-headers.ts @@ -221,6 +221,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: 'test_headers', @@ -259,6 +262,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', content: [ { type: 'text', @@ -281,6 +285,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', resources: [ { uri: 'file:///path/to/file%20name.txt', @@ -309,6 +316,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', contents: [] } }) @@ -326,6 +336,9 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', prompts: [ { name: 'test_prompt', @@ -348,6 +361,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { jsonrpc: '2.0', id: request.id, result: { + resultType: 'complete', messages: [] } }) diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts index a7b315e..1783d57 100644 --- a/src/scenarios/client/json-schema-ref-deref.ts +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -44,6 +44,9 @@ function createMcpServer(canaryUrl: string, onToolsListed: () => void): Server { server.setRequestHandler(ListToolsRequestSchema, async () => { onToolsListed(); return { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: TOOL_NAME, diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index 4521ca0..eaeb414 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -96,7 +96,12 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { res.json({ jsonrpc: '2.0', id, - result: { tools: TOOLS } + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: TOOLS + } }); return; } @@ -141,7 +146,11 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { res.json({ jsonrpc: '2.0', id, - result: { action: 'accept', content: { confirmed: true } } + result: { + resultType: 'complete', + action: 'accept', + content: { confirmed: true } + } }); return; } @@ -258,6 +267,7 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { jsonrpc: '2.0', id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'echo-state-ok' }] } }); @@ -324,6 +334,7 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { jsonrpc: '2.0', id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'no-state-ok' }] } }); @@ -370,6 +381,7 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { jsonrpc: '2.0', id, result: { + resultType: 'complete', content: [{ type: 'text', text: 'unrelated-ok' }] } }); @@ -403,7 +415,10 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { res.json({ jsonrpc: '2.0', id, - result: { content: [{ type: 'text', text: 'unexpected-retry' }] } + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'unexpected-retry' }] + } }); return; } diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 38576b5..4d66f2c 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -1,4 +1,7 @@ -import type { ScenarioContext } from '../../mock-server'; +import { + withRequiredDraftResultFields, + type ScenarioContext +} from '../../mock-server'; import http from 'http'; import { Scenario, @@ -319,11 +322,11 @@ export class RequestMetadataScenario implements Scenario { JSON.stringify({ jsonrpc: '2.0', id: request.id, - result: { + result: withRequiredDraftResultFields(request.method, { supportedVersions: [DRAFT_PROTOCOL_VERSION], capabilities: {}, serverInfo: { name: 'test', version: '1.0' } - } + }) }) ); return; @@ -337,7 +340,13 @@ export class RequestMetadataScenario implements Scenario { result = { content: [] }; } res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ jsonrpc: '2.0', id: request.id, result })); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: withRequiredDraftResultFields(request.method, result) + }) + ); }); } } diff --git a/src/scenarios/client/tools_call.test.ts b/src/scenarios/client/tools_call.test.ts index b9fc42d..ad6a58e 100644 --- a/src/scenarios/client/tools_call.test.ts +++ b/src/scenarios/client/tools_call.test.ts @@ -3,6 +3,7 @@ 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 () => { @@ -20,6 +21,62 @@ describe('tools_call scenario', () => { } }); + 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()); From 1be81415861bfebe678f2f24d47089b60e70f689 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:17:03 +0100 Subject: [PATCH 158/226] fix: enforce HTTP 400 and data.requested for per-request _meta and version errors (#343) The everything-server returned its -32602 missing-_meta rejection with HTTP 200; the draft spec requires 400 Bad Request. It also applied the per-request _meta validation to sessionless requests carrying a legacy session-era version header, although per-request metadata is a 2026-07-28 requirement; such requests now fall through to the session path. The server-stateless scenario gains a companion check (sep-2575-http-server-meta-invalid-400) requiring HTTP 400 on those rejections, and sep-2575-server-unsupported-version-error now also requires error.data.requested to echo the requested version. Two new negative tests pin both. --- .../servers/typescript/everything-server.ts | 26 +++++- src/scenarios/server/stateless.test.ts | 87 +++++++++++++++++++ src/scenarios/server/stateless.ts | 72 +++++++++++---- 3 files changed, 164 insertions(+), 21 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index fb0d6bb..f8a0272 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1225,6 +1225,16 @@ 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' +]; + // Handle POST requests - stateful mode app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; @@ -1236,7 +1246,15 @@ app.post('/mcp', async (req, res) => { const meta = params._meta; const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; - if (!sessionId && (reqVersion || meta)) { + // 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({ @@ -1246,14 +1264,16 @@ app.post('/mcp', async (req, res) => { }); } - // Per-Request Metadata Integrity Checks (Fields verification) + // Per-Request Metadata Integrity Checks (Fields verification). + // A request missing any required `_meta` field is malformed: -32602 and, + // on HTTP, status 400 Bad Request. if ( !meta || !meta['io.modelcontextprotocol/protocolVersion'] || !meta['io.modelcontextprotocol/clientInfo'] || !meta['io.modelcontextprotocol/clientCapabilities'] ) { - return res.status(200).json({ + return res.status(400).json({ jsonrpc: '2.0', id, error: { diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 133d932..a2fb5f3 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -127,6 +127,93 @@ describe('Stateless Server Scenario Negative Tests', () => { expect(missingVersionCheck?.status).toBe('FAILURE'); }); + 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: -32004, + 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) => { diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index f6dfc78..a995b83 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -33,8 +33,8 @@ export class ServerStatelessScenario implements ClientScenario { **Grouped Specification Requirements**: -1. **Per-Request _meta Validation (4 Checks)** - - Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature. +1. **Per-Request _meta Validation (5 Checks)** + - Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature and an HTTP status code \`400 Bad Request\`. 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. @@ -356,26 +356,50 @@ export class ServerStatelessScenario implements ClientScenario { } ]; 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, - async () => { - const { data } = await sendRpc( - 'server/discover', - testCase.params, - undefined, - testCase.rpcId - ); - checkErrorId(data, testCase.rpcId); - - if (data?.error?.code !== -32602) { + () => { + if (!metaProbe) + return { error: '_meta validation probe failed completely' }; + if (metaData?.error?.code !== -32602) { return { - error: `Expected error code -32602, got ${data?.error?.code}`, - details: { fieldIssue: testCase.slug, response: data } + error: `Expected error code -32602, got ${metaData?.error?.code}`, + details: { fieldIssue: testCase.slug, response: metaData } }; } - return { details: { fieldIssue: testCase.slug, response: data } }; + 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 } ); @@ -498,14 +522,15 @@ export class ServerStatelessScenario implements ClientScenario { // ========================================== // 3. Version Negotiation & Headers (3 Checks) // ========================================== + const requestedUnsupportedVersion = 'v999.0.0'; const unsupportedMeta = { ...validMeta, - 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' + 'io.modelcontextprotocol/protocolVersion': requestedUnsupportedVersion }; const response301 = await sendRpc( 'server/discover', { _meta: unsupportedMeta }, - { 'MCP-Protocol-Version': 'v999.0.0' }, + { 'MCP-Protocol-Version': requestedUnsupportedVersion }, 301 ).catch(() => null); const res301: any = response301?.res ?? null; @@ -515,7 +540,7 @@ export class ServerStatelessScenario implements ClientScenario { 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.', + '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' }; @@ -533,6 +558,17 @@ export class ServerStatelessScenario implements ClientScenario { 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 } }; } ); From 7a620cb192419728808c8f466adabec52f43e79c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 16 Jun 2026 17:29:40 +0100 Subject: [PATCH 159/226] chore: bump version to 0.2.0-alpha.4 (#342) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e7849fd..1250406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.3", + "version": "0.2.0-alpha.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.3", + "version": "0.2.0-alpha.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index b6ced4a..1d11004 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.3", + "version": "0.2.0-alpha.4", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 8445dd3aa01729842adc7b89671b437225b53db4 Mon Sep 17 00:00:00 2001 From: Bill Prin Date: Wed, 17 Jun 2026 10:55:31 -0700 Subject: [PATCH 160/226] fix: gate SEP-2350 scope-union check to 2026-07-28+ (#337) The auth/scope-step-up scenario emitted sep-2350-scope-union-on-reauth at every spec version, but the set-wise union requirement (SEP-2350) was only introduced in 2026-07-28. This wrongly warned clients that are conformant at 2025-11-25 and drop the previously-granted scope on re-authorization (e.g. python-sdk v1, surfaced while wiring up its runner in #329). Gate the check behind specVersionAtLeast(specVersion, DRAFT_PROTOCOL_VERSION), a small timeline-ordering helper added to types.ts (mirrors the ordering used by scenario applicability). Add tests pinning both sides of the branch: enforced at draft, silent at 2025-11-25. Follow-up to #329. Co-authored-by: Claude Opus 4.8 (1M context) --- src/scenarios/client/auth/index.test.ts | 19 ++++++- src/scenarios/client/auth/scope-handling.ts | 63 +++++++++++++-------- src/types.ts | 20 +++++++ 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 3d2d6ff..e4b2fe5 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -130,8 +130,12 @@ 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', { + specVersion: DRAFT_PROTOCOL_VERSION, expectedFailureSlugs: [ 'scope-step-up-escalation', 'sep-2350-scope-union-on-reauth' @@ -139,13 +143,26 @@ describe('Negative tests', () => { }); }); - test('client echoes challenge scope without accumulating prior grant (SEP-2350)', async () => { + 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' + }); + }); + test('client uses DCR instead of CIMD when server supports it', async () => { const runner = new InlineClientRunner(noCimdClient); await runClientAgainstScenario(runner, 'auth/basic-cimd', { diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 2134678..227c6c4 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -6,6 +6,7 @@ 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'; /** @@ -294,9 +295,17 @@ export class ScopeStepUpAuthScenario implements Scenario { 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(ctx: ScenarioContext): Promise { this.checks = []; + this.unionRequired = specVersionAtLeast( + ctx.specVersion, + DRAFT_PROTOCOL_VERSION + ); const initialScope = 'mcp:basic'; // tools/call gates on mcp:write only (not the union) so the scenario can @@ -352,22 +361,24 @@ export class ScopeStepUpAuthScenario implements Scenario { } }); - 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' - } - }); + 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' + } + }); + } } } }); @@ -500,15 +511,17 @@ export class ScopeStepUpAuthScenario implements Scenario { timestamp: new Date().toISOString(), specReferences: [SpecReferences.MCP_SCOPE_SELECTION_STRATEGY] }); - 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] - }); + 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; diff --git a/src/types.ts b/src/types.ts index 4da546f..ed44f66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,26 @@ export const NEGOTIABLE_PROTOCOL_VERSIONS: readonly string[] = [ */ export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; +/** Spec versions in timeline order, dated revisions followed by the draft. */ +const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [ + ...DATED_SPEC_VERSIONS, + DRAFT_PROTOCOL_VERSION +]; + +/** + * 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'; From 206709f8a648fd94a4652455fb3fc08be4f65c85 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:30:19 +0100 Subject: [PATCH 161/226] fix: serve server/discover from the hand-rolled client-scenario mocks (#347) The SEP-2243, SEP-2322, and SEP-2106 client-direction scenarios build their own mock servers and did not answer server/discover, so a 2026-07-28 client that negotiates via discover before its scenario calls hit a -32601 or generic fallback and had to synthesize the response itself. The shared stateless mock and the request-metadata and auth scenarios already served it. BaseHttpScenario now answers server/discover with a valid DiscoverResult (supportedVersions, capabilities from an overridable discoverCapabilities(), serverInfo, plus the cacheable-result members) before delegating to handlePost; http-standard-headers overrides the capabilities to match the methods it serves. mrtr-client and json-schema-ref-no-deref answer it directly. New tests pin the response shape for all five scenarios. --- .../client/draft-result-fields.test.ts | 65 +++++++++++++++++++ src/scenarios/client/http-base.ts | 27 +++++++- src/scenarios/client/http-standard-headers.ts | 10 +-- src/scenarios/client/json-schema-ref-deref.ts | 23 +++++++ src/scenarios/client/mrtr-client.ts | 16 +++++ 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts index 3d5c37a..54f6f9b 100644 --- a/src/scenarios/client/draft-result-fields.test.ts +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -8,6 +8,7 @@ import { } from './http-custom-headers'; import { RequestMetadataScenario } from './request-metadata'; import { MRTRClientScenario } from './mrtr-client'; +import { JsonSchemaRefDerefScenario } from './json-schema-ref-deref'; /** * Pins that the hand-rolled mock servers used by client-direction scenarios @@ -43,6 +44,70 @@ const CACHEABLE_FIELDS = { 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(); diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts index 866ed65..3e8cbe8 100644 --- a/src/scenarios/client/http-base.ts +++ b/src/scenarios/client/http-base.ts @@ -102,6 +102,10 @@ export abstract class BaseHttpScenario implements Scenario { 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' }); @@ -129,10 +133,31 @@ export abstract class BaseHttpScenario implements Scenario { 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 = { tools: {} } + capabilities: object = this.discoverCapabilities() ): void { this.sendJson(res, { jsonrpc: '2.0', diff --git a/src/scenarios/client/http-standard-headers.ts b/src/scenarios/client/http-standard-headers.ts index dcc45f6..227546b 100644 --- a/src/scenarios/client/http-standard-headers.ts +++ b/src/scenarios/client/http-standard-headers.ts @@ -202,12 +202,12 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario { }); } + protected discoverCapabilities(): object { + return { tools: {}, resources: {}, prompts: {} }; + } + private handleInitialize(res: http.ServerResponse, request: any): void { - this.sendInitialize(res, request, { - tools: {}, - resources: {}, - prompts: {} - }); + this.sendInitialize(res, request); } private handleToolsList(res: http.ServerResponse, request: any): void { diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts index 1783d57..ebf1367 100644 --- a/src/scenarios/client/json-schema-ref-deref.ts +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -109,6 +109,29 @@ The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at }); 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' + } + } + }); + } try { // Stateless: fresh server and transport per request const server = createMcpServer(this.canaryUrl(), () => { diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index eaeb414..2bd5692 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -87,6 +87,22 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { 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; From 7c4bea418840e1e2c3acba22bf0690a0c0e468dd Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Thu, 18 Jun 2026 16:01:49 +0100 Subject: [PATCH 162/226] chore: sync spec-types/draft.ts to modelcontextprotocol@2fb207d (#348) Re-vendors schema/draft/schema.ts from the spec repo at the merge commit of PR #2891 (2fb207da428f43a4981513ec2aef218b7533c5b3), which also includes #2889 and #2890. Type changes picked up: - Add NotificationMetaObject (with io.modelcontextprotocol/subscriptionId) - Add HEADER_MISMATCH (-32001) const and HeaderMismatchError interface - CancelledNotificationParams.requestId is now required - ClientNotification narrowed to CancelledNotification only - ListRootsRequest.params narrowed (drops progressToken) - Remove ElicitationCompleteNotification and elicitationId Also replaces the local -32001 literal in src/scenarios/server/http-standard-headers.ts with the new exported HEADER_MISMATCH constant. Claude-Session: https://claude.ai/code/session_01Gbqx5LodtAghT5fRYFxeja --- src/scenarios/server/http-standard-headers.ts | 3 +- src/spec-types/SOURCE | 2 +- src/spec-types/draft.ts | 123 +++++++++++------- 3 files changed, 80 insertions(+), 48 deletions(-) diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index ca84e9a..94ef9e8 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -25,6 +25,7 @@ import { sendStatelessRequest, type RunContext } from '../../connection'; +import { HEADER_MISMATCH } from '../../spec-types/draft'; const SPEC_REFERENCE = { id: 'SEP-2243-Server-Validation', @@ -55,7 +56,7 @@ const SPEC_REFERENCE_CUSTOM = { url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-behavior-for-custom-headers' }; -const HEADER_MISMATCH_ERROR_CODE = -32001; +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; diff --git a/src/spec-types/SOURCE b/src/spec-types/SOURCE index 49ed355..5719bd9 100644 --- a/src/spec-types/SOURCE +++ b/src/spec-types/SOURCE @@ -1 +1 @@ -modelcontextprotocol@0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65 +modelcontextprotocol@2fb207da428f43a4981513ec2aef218b7533c5b3 diff --git a/src/spec-types/draft.ts b/src/spec-types/draft.ts index f99c529..dfa64e7 100644 --- a/src/spec-types/draft.ts +++ b/src/spec-types/draft.ts @@ -110,6 +110,29 @@ export interface RequestMetaObject extends MetaObject { "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; +} + /** * A progress token, used to associate progress notifications with the original request. * @@ -147,7 +170,7 @@ export interface Request { * @category Common Types */ export interface NotificationParams { - _meta?: MetaObject; + _meta?: NotificationMetaObject; } /** @internal */ @@ -357,6 +380,15 @@ export interface InternalError extends Error { code: typeof INTERNAL_ERROR; } +/** + * 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 = -32001; + /** * Error code returned when a server requires a client capability that was * not declared in the request's `clientCapabilities`. @@ -373,6 +405,26 @@ export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; */ export const UNSUPPORTED_PROTOCOL_VERSION = -32004; +/** + * 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 @@ -529,9 +581,9 @@ 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 correspond to the ID of a request the client previously issued. */ - requestId?: RequestId; + requestId: RequestId; /** * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. @@ -540,7 +592,9 @@ export interface CancelledNotificationParams extends NotificationParams { } /** - * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * 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. * @@ -1007,11 +1061,13 @@ export interface CacheableResult extends Result { * Indicates the intended scope of the cached response, analogous to HTTP * `Cache-Control: public` vs `Cache-Control: private`. * - * - `"public"`: Any client or intermediary (e.g., shared gateway, proxy) - * MAY cache the response and serve it to any user. - * - `"private"`: Only the requesting user's client MAY cache the response. - * Shared caches (e.g., multi-tenant gateways) MUST NOT serve a cached - * copy to a different user. + * - `"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"; @@ -1154,7 +1210,7 @@ export interface ReadResourceResultResponse extends JSONRPCResultResponse { } /** - * 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. + * 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} @@ -1598,7 +1654,7 @@ export interface EmbeddedResource { _meta?: MetaObject; } /** - * 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. + * 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} @@ -1740,7 +1796,7 @@ export interface CallToolRequest extends JSONRPCRequest { } /** - * 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. + * 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} @@ -1842,6 +1898,11 @@ export interface Tool extends BaseMetadata, Icons { * (`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 }; @@ -2571,7 +2632,9 @@ export interface PromptReference extends BaseMetadata { */ export interface ListRootsRequest { method: "roots/list"; - params?: RequestParams; + params?: { + _meta?: MetaObject; + }; } /** @@ -2681,12 +2744,6 @@ export interface ElicitRequestURLParams { */ 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. * @@ -3014,31 +3071,6 @@ export interface ElicitResult { content?: { [key: string]: string | number | boolean | string[] }; } -/** - * Parameters for a {@link ElicitationCompleteNotification | notifications/elicitation/complete} notification. - * - * @category `notifications/elicitation/complete` - */ -export interface ElicitationCompleteNotificationParams extends NotificationParams { - /** - * The ID of the elicitation that completed. - */ - elicitationId: string; -} - -/** - * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. - * - * @example Elicitation complete - * {@includeCode ./examples/ElicitationCompleteNotification/elicitation-complete.json} - * - * @category `notifications/elicitation/complete` - */ -export interface ElicitationCompleteNotification extends JSONRPCNotification { - method: "notifications/elicitation/complete"; - params: ElicitationCompleteNotificationParams; -} - /* Client messages */ /** @internal */ export type ClientRequest = @@ -3054,7 +3086,7 @@ export type ClientRequest = | ListToolsRequest; /** @internal */ -export type ClientNotification = CancelledNotification | ProgressNotification; +export type ClientNotification = CancelledNotification; /** @internal */ export type ClientResult = EmptyResult; @@ -3070,7 +3102,6 @@ export type ServerNotification = | ResourceListChangedNotification | ToolListChangedNotification | PromptListChangedNotification - | ElicitationCompleteNotification | SubscriptionsAcknowledgedNotification; /** @internal */ From fe3a3103bc6f9469039b7ed5c8f2739438de2fa7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:17:54 +0100 Subject: [PATCH 163/226] chore: renumber draft error codes per modelcontextprotocol#2907 (#353) The error-code allocation policy in modelcontextprotocol#2907 renumbers the draft-only codes: HeaderMismatch -32001 -> -32020, MissingRequiredClientCapability -32003 -> -32021, UnsupportedProtocolVersion -32004 -> -32022. This updates every reference in the suite (mocks, scenarios, fixtures, tests, traceability yaml) and re-syncs the vendored draft schema to that commit. src/seps/traceability.json is generated and intentionally left for the scheduled refresh workflow. --- .../clients/typescript/everything-client.ts | 8 ++---- .../servers/typescript/everything-server.ts | 14 +++++----- src/mock-server/mock-server.test.ts | 10 +++---- src/mock-server/stateless.ts | 8 +++--- .../client/draft-result-fields.test.ts | 2 +- src/scenarios/client/request-metadata.test.ts | 2 +- src/scenarios/client/request-metadata.ts | 2 +- src/scenarios/index.ts | 2 +- src/scenarios/server/http-standard-headers.ts | 22 +++++++-------- src/scenarios/server/stateless.test.ts | 2 +- src/scenarios/server/stateless.ts | 22 +++++++-------- src/seps/sep-2243.yaml | 4 +-- src/seps/sep-2575.yaml | 2 +- src/spec-types/SOURCE | 2 +- src/spec-types/draft.ts | 28 ++++++++++++++++--- 15 files changed, 73 insertions(+), 57 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index ef272e4..2c9e842 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -265,12 +265,8 @@ async function runRequestMetadataClient(serverUrl: string): Promise { const clone = response.clone(); try { const errorResult = await clone.json(); - // -32004 is UnsupportedProtocolVersionError in the draft schema; - // -32001 is tolerated for servers that predate the dedicated code. - if ( - errorResult.error?.code === -32004 || - errorResult.error?.code === -32001 - ) { + // UnsupportedProtocolVersionError is -32022 in the draft schema. + if (errorResult.error?.code === -32022) { logger.debug( 'Received UnsupportedProtocolVersionError, starting negotiation...' ); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index f8a0272..52b4b6a 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1260,7 +1260,7 @@ app.post('/mcp', async (req, res) => { return res.status(400).json({ jsonrpc: '2.0', id, - error: { code: -32001, message: 'Missing MCP-Protocol-Version header' } + error: { code: -32020, message: 'Missing MCP-Protocol-Version header' } }); } @@ -1283,25 +1283,25 @@ app.post('/mcp', async (req, res) => { }); } - // Header Mismatch Verification (-32001, HTTP 400) + // Header Mismatch Verification (-32020, HTTP 400) if (reqVersion !== metaVersion) { return res.status(400).json({ jsonrpc: '2.0', id, error: { - code: -32001, + code: -32020, message: 'Mismatched MCP-Protocol-Version header' } }); } - // Protocol Version Negotiation Matrix (-32004, HTTP 400) + // Protocol Version Negotiation Matrix (-32022, HTTP 400) if (metaVersion !== '2026-07-28') { return res.status(400).json({ jsonrpc: '2.0', id, error: { - code: -32004, + code: -32022, message: 'UnsupportedProtocolVersionError', data: { supported: ['2026-07-28'], @@ -1655,13 +1655,13 @@ app.post('/mcp', async (req, res) => { if (name === 'test_missing_capability') { const clientCaps = meta['io.modelcontextprotocol/clientCapabilities']; - // Missing Required Client Capability Check (-32003, HTTP 400) + // Missing Required Client Capability Check (-32021, HTTP 400) if (!clientCaps?.sampling) { return res.status(400).json({ jsonrpc: '2.0', id, error: { - code: -32003, + code: -32021, message: 'MissingRequiredClientCapabilityError', data: { requiredCapabilities: ['sampling'] } } diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts index 8af684c..2d26cf5 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -100,7 +100,7 @@ describe('validateStatelessRequest', () => { }); }); - it('rejects versions outside the supported list with -32004 and echoes it', () => { + it('rejects versions outside the supported list with -32022 and echoes it', () => { const v = validateStatelessRequest( { headers: { 'mcp-protocol-version': '2099-01-01' }, @@ -124,7 +124,7 @@ describe('validateStatelessRequest', () => { status: 400, body: { error: { - code: -32004, + code: -32022, data: { supported: [DRAFT_PROTOCOL_VERSION], requested: '2099-01-01' } } } @@ -216,7 +216,7 @@ describe('createServerStateless', () => { params: { _meta: meta } }); expect(status).toBe(400); - expect(body.error.code).toBe(-32001); + expect(body.error.code).toBe(-32020); } finally { await srv.close(); } @@ -258,7 +258,7 @@ describe('createServerStateless', () => { } }); - it('accepts the version it was created for and rejects others with -32004', async () => { + it('accepts the version it was created for and rejects others with -32022', async () => { const srv = await createServerStateless( { 'tools/list': () => ({ tools: [] }) }, DRAFT_PROTOCOL_VERSION @@ -292,7 +292,7 @@ describe('createServerStateless', () => { { 'mcp-protocol-version': '2099-01-01' } ); expect(rejected.status).toBe(400); - expect(rejected.body.error.code).toBe(-32004); + expect(rejected.body.error.code).toBe(-32022); expect(rejected.body.error.data.supported).toEqual([ DRAFT_PROTOCOL_VERSION ]); diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index 904d0e0..2a69f98 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -84,7 +84,7 @@ export type StatelessValidation = * `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 -32004 carrying + * 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. */ @@ -108,7 +108,7 @@ export function validateStatelessRequest( const headerVersion = req.headers['mcp-protocol-version']; if (!headerVersion) { - return reject(400, -32001, 'Missing MCP-Protocol-Version header'); + return reject(400, -32020, 'Missing MCP-Protocol-Version header'); } const missing = META_KEYS.filter((k) => meta?.[k] === undefined); if (missing.length > 0) { @@ -121,7 +121,7 @@ export function validateStatelessRequest( if (meta?.[META_KEYS[0]] !== headerVersion) { return reject( 400, - -32001, + -32020, 'MCP-Protocol-Version header does not match _meta.protocolVersion' ); } @@ -136,7 +136,7 @@ export function validateStatelessRequest( jsonrpc: '2.0', id, error: { - code: -32004, + code: -32022, message: 'Unsupported protocol version', data: { supported: supportedVersions, diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts index 54f6f9b..1fdc6d4 100644 --- a/src/scenarios/client/draft-result-fields.test.ts +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -238,7 +238,7 @@ describe('request-metadata mock results (2026-07-28)', () => { ); const headers = { 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION }; try { - // The first request is always answered with the simulated -32004 + // 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, diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index e2046fe..567d1ca 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -105,7 +105,7 @@ async function incompatibleVersionClient(serverUrl: string) { if (response.status === 400) { const body = await response.json(); - if (body.error?.code === -32004) { + if (body.error?.code === -32022) { return body; // Abort cleanly } } diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 4d66f2c..61dc5e1 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -282,7 +282,7 @@ export class RequestMetadataScenario implements Scenario { id: request.id ?? null, error: { // UnsupportedProtocolVersionError per the draft schema. - code: -32004, + code: -32022, message: 'Unsupported protocol version', data: { supported: [DRAFT_PROTOCOL_VERSION], diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index b7230ab..c9857ec 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -120,7 +120,7 @@ const pendingClientScenariosList: ClientScenario[] = [ // HTTP Standardization (SEP-2243) // Pending until the everything-server fully implements SEP-2243 - // header validation (case-insensitive names, whitespace trimming, -32001 error code) + // header validation (case-insensitive names, whitespace trimming, -32020 error code) new HttpHeaderValidationScenario(), new HttpCustomHeaderServerValidationScenario() ]; diff --git a/src/scenarios/server/http-standard-headers.ts b/src/scenarios/server/http-standard-headers.ts index 94ef9e8..59bd30c 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -8,7 +8,7 @@ * - 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 -32001 (HeaderMismatch) + * - Return 400 Bad Request with error code -32020 (HeaderMismatch) * * This is a ClientScenario (connects to a server under test and validates * its behavior). @@ -66,8 +66,8 @@ 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 -32001 error-code half of every custom-header -// rejection is the "reject with 400 + JSON-RPC error code -32001 if any +// 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'; @@ -148,8 +148,8 @@ async function sendRawRequest( /** * Builds two checks for a rejection case: one for the HTTP 400 status, one for - * the -32001 JSON-RPC error code. Per SEP-2243 §Server Validation, 400 is MUST - * but -32001 is SHOULD for *standard* headers (and MUST for *custom* headers, + * 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. * @@ -196,7 +196,7 @@ function createRejectionChecks( { id: errorCodeId, name: `${name}ErrorCode`, - description: `${description} — uses JSON-RPC error code -32001 (HeaderMismatch)`, + description: `${description} — uses JSON-RPC error code -32020 (HeaderMismatch)`, status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity, timestamp: ts, errorMessage: codeOk @@ -265,7 +265,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { - 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 -32001 (HeaderMismatch)`; +- Server MUST return JSON-RPC error with code -32020 (HeaderMismatch)`; async run(ctx: RunContext): Promise { const { serverUrl } = ctx; @@ -507,7 +507,7 @@ export class HttpHeaderValidationScenario implements ClientScenario { ...extraHeaders }); if (expectation === 'reject') { - // Standard-header rejection: 400 is MUST, -32001 is SHOULD. All + // 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( @@ -926,10 +926,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario ) ); } else { - // Custom-header rejection: both 400 and -32001 are MUST per + // 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 + -32001 if any validation fails" + // error-code half is the "400 + -32020 if any validation fails" // requirement. checks.push( ...createRejectionChecks( @@ -992,7 +992,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } ); - // Custom-header rejection: both 400 and -32001 are MUST. A 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( diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index a2fb5f3..f8b93bf 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -180,7 +180,7 @@ describe('Stateless Server Scenario Negative Tests', () => { jsonrpc: '2.0', id: reqBody.id, error: { - code: -32004, + code: -32022, message: 'Unsupported protocol version', // Spec Violation: data.requested is a required member data: { supported: ['2026-07-28'] } diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index a995b83..ae157b1 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -40,9 +40,9 @@ export class ServerStatelessScenario implements ClientScenario { - 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 \`-32001 Header Mismatch\` error with an HTTP 400 boundary state. + - 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 \`-32003 MissingRequiredClientCapabilityError\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`. + - Accessing platform capabilities without explicit declaration drops requests with a \`-32021 MissingRequiredClientCapabilityError\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`. 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. @@ -608,9 +608,9 @@ export class ServerStatelessScenario implements ClientScenario { () => { if (!resAbsent) return { error: 'Header verification endpoint network hit failed' }; - if (resAbsent.status !== 400 || dataAbsent?.error?.code !== -32001) { + if (resAbsent.status !== 400 || dataAbsent?.error?.code !== -32020) { return { - error: `Expected HTTP 400 and JSON-RPC error -32001, got status ${resAbsent.status} with code ${dataAbsent?.error?.code}` + error: `Expected HTTP 400 and JSON-RPC error -32020, got status ${resAbsent.status} with code ${dataAbsent?.error?.code}` }; } return { details: { response: dataAbsent } }; @@ -631,12 +631,12 @@ export class ServerStatelessScenario implements ClientScenario { if (data401) checkErrorId(data401, 401); // Determine if this server actively enforces client capabilities - const serverRequiresCapability = data401?.error?.code === -32003; + const serverRequiresCapability = data401?.error?.code === -32021; 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 (-32003).', + '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 { @@ -645,22 +645,22 @@ export class ServerStatelessScenario implements ClientScenario { }; if (!serverRequiresCapability) { - // The server didn't return -32003, so this requirement isn't + // The server didn't return -32021, so this requirement isn't // exercised for this method. Report SKIPPED rather than a green PASS. return { skipped: true, details: { - note: 'Skipped requirement tracking: Server returned a non-32003 response, indicating it does not require explicit client capability authorization constraints for this method.', + note: 'Skipped requirement tracking: Server returned a non-32021 response, indicating it does not require explicit client capability authorization constraints for this method.', response: data401 } }; } - // If it DOES return -32003, strictly validate the requirement payload structure + // If it DOES return -32021, strictly validate the requirement payload structure const reqCaps = data401?.error?.data?.requiredCapabilities; if (!Array.isArray(reqCaps) || !reqCaps.includes('sampling')) { return { - error: `Server responded with error code -32003 but failed to provide an array containing the expected 'sampling' capability in error.data.requiredCapabilities`, + error: `Server responded with error code -32021 but failed to provide an array containing the expected 'sampling' capability in error.data.requiredCapabilities`, details: { response: data401 } }; } @@ -680,7 +680,7 @@ export class ServerStatelessScenario implements ClientScenario { }; if (!serverRequiresCapability) { - // No -32003 means the HTTP-400 requirement doesn't apply here. + // No -32021 means the HTTP-400 requirement doesn't apply here. // Report SKIPPED rather than a green PASS. return { skipped: true, diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml index bb7803f..a39b6d2 100644 --- a/src/seps/sep-2243.yaml +++ b/src/seps/sep-2243.yaml @@ -8,7 +8,7 @@ requirements: - 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 -32001.' + 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 @@ -45,7 +45,7 @@ requirements: - 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 -32001 if any validation fails.' + 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.' diff --git a/src/seps/sep-2575.yaml b/src/seps/sep-2575.yaml index 798cae4..057355d 100644 --- a/src/seps/sep-2575.yaml +++ b/src/seps/sep-2575.yaml @@ -5,7 +5,7 @@ requirements: 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 (-32003).' + 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].' diff --git a/src/spec-types/SOURCE b/src/spec-types/SOURCE index 5719bd9..33d35e9 100644 --- a/src/spec-types/SOURCE +++ b/src/spec-types/SOURCE @@ -1 +1 @@ -modelcontextprotocol@2fb207da428f43a4981513ec2aef218b7533c5b3 +modelcontextprotocol@f817239f4d6b1efff2c4dfc2f7af85c985d73076 diff --git a/src/spec-types/draft.ts b/src/spec-types/draft.ts index dfa64e7..c48f9d7 100644 --- a/src/spec-types/draft.ts +++ b/src/spec-types/draft.ts @@ -321,7 +321,7 @@ export interface InvalidRequestError extends Error { * * 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} (`-32003`). + * 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} * @@ -380,6 +380,26 @@ 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 @@ -387,7 +407,7 @@ export interface InternalError extends Error { * * @category Errors */ -export const HEADER_MISMATCH = -32001; +export const HEADER_MISMATCH = -32020; /** * Error code returned when a server requires a client capability that was @@ -395,7 +415,7 @@ export const HEADER_MISMATCH = -32001; * * @category Errors */ -export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; /** * Error code returned when the request's protocol version is not supported @@ -403,7 +423,7 @@ export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; * * @category Errors */ -export const UNSUPPORTED_PROTOCOL_VERSION = -32004; +export const UNSUPPORTED_PROTOCOL_VERSION = -32022; /** * Returned when a server rejects a request because the values in the HTTP From d70d7ad4759cc2559ef9a0a31ed84aad63c280b6 Mon Sep 17 00:00:00 2001 From: Sri Panyam Date: Thu, 18 Jun 2026 11:14:02 -0700 Subject: [PATCH 164/226] Feat/tasks mrtr extension (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tasks): SEP-2663 lifecycle scenario (8 checks) Adds the first scenario for the SEP-2663 io.modelcontextprotocol/tasks extension — a single TasksLifecycleScenario covering sync vs async dispatch, DetailedTask shape on tasks/get, tool errors vs protocol errors, and cancellation semantics. 8 ConformanceCheck records, all passing against a SEP-2663-conformant Go fixture. Why "tasks" (not "tasks-v2"): SEP-2663 IS the tasks surface once it lands; the v2 suffix is only meaningful in implementations that maintain a v1 surface alongside, which the conformance suite does not. Layout: - src/scenarios/server/tasks/lifecycle.ts — scenario class - src/scenarios/server/tasks/helpers.ts — raw-fetch escape hatch (the SDK's typed schemas strip resultType/inputRequests/...) - src/scenarios/server/tasks/lifecycle.test.ts — fork-local vitest runner. Two modes: spawn a fixture binary via MCPKIT_TASKS_BINARY, or point at an already-running server via MCPKIT_TASKS_SERVER_URL. Skips when neither is set so it doesn't break upstream CI runs that go through everything-server (which doesn't yet implement io.modelcontextprotocol/tasks). Scenario is registered in pendingClientScenariosList so all-scenarios.test.ts skips it; promote to active once the upstream fixture grows extension support. Tagged ['extension', DRAFT_PROTOCOL_VERSION] — selectable via --suite extensions and --spec-version draft. * style(tasks): apply prettier formatting * feat(tasks,mrtr): port full SEP-2663 + SEP-2322 scenario suite Builds out the rest of the tasks scenarios (atop the lifecycle canary) and adds the SEP-2322 ephemeral MRTR scenario in a sibling folder. Both target their own fixtures; both runners are brand-neutral and language-agnostic (TASKS_SERVER_URL / TASKS_SERVER_CMD, MRTR_SERVER_URL / MRTR_SERVER_CMD; readiness via TCP polling). Tasks ClientScenario classes: - TasksLifecycleScenario (8 checks; v2-01..v2-08) - TasksCapabilityNegotiationScenario (4 checks; v2-11/22/23/25, SEP-2575) - TasksWireFieldsScenario (3 checks; v2-12/13/21) - TasksRequestStateScenario (3 checks; v2-14/15/28) - TasksMRTRInputScenario (3 checks; v2-16/17/29 partial fulfillment) - TasksRequestHeadersScenario (3 checks; SEP-2243 request-header tolerance) - TasksDispatchScenario (8 checks; v2-09/10/19/20/26/27/30/31) - TasksStatusNotificationsScenario (1 check; SEP-2663 §notifications, optional) MRTR ClientScenario class: - MrtrEphemeralFlowScenario (7 checks + 1 SKIPPED; mrtr-01..07, mrtr-08 deferred for spec terminology + reference-impl reasons) Both runners spawn the fixture via a shell command and detect readiness by TCP-polling the URL's host/port — no log-line scanning, no language-specific assumptions. The same env vars work for any server implementation. Scenarios are tagged ['extension', DRAFT_PROTOCOL_VERSION] and registered in pendingClientScenariosList so all-scenarios.test.ts (which targets the upstream everything-server) skips them until the fixture grows SEP-2322 / SEP-2663 support. * docs(tasks,mrtr): scenario READMEs for upstream porting Restructured around ClientScenario classes (one row per class with check-list under it) rather than per-numbered-test slugs. Documents fixture requirements, env vars, open spec questions, and the wire-format diff for each suite. Per AGENTS.md, severity follows spec keyword (MUST/MUST NOT → FAILURE, SHOULD/SHOULD NOT → WARNING). The READMEs explain why some checks emit INFO rather than FAILURE (optional emission paths per SEP-2322). * tasks: assert createdAt + lastUpdatedAt; factor _shared/ helpers Two reviewer-driven additions: 1. SEP-2663 createdAt / lastUpdatedAt ISO-8601 assertion in `tasks-server-task-creation` (per Luca's PR #262 review feedback). The check now flags servers that emit non-ISO timestamps (epoch seconds, RFC-2822, etc.) on TaskInfoV2 envelopes. 2. Factor cross-cutting test-harness helpers into _shared/: - `_shared/test-runner.ts` — `waitForServerReady` (renamed from `waitForTcpReady`; the call site cares about server readiness, not the TCP-poll mechanism). Imported by tasks/ and mrtr/ all-scenarios.test.ts; replaces ~30 LOC of inline duplication in each. - `_shared/wire-format.ts` — `ISO_8601_PATTERN` constant + `isIso8601(s)` predicate. Documented rationale for choosing a regex over `Date.parse` (too permissive), `new Date(s).toISOString()` (too strict), or `Temporal.Instant.from` (Node 24+ experimental). Future wire-shape predicates (data URI, percent-encoded filename, etc.) can land here. Cherry-pick footprint when graduating to upstream PR is the SEP folder + the imported `_shared/` files. First PR through carries them upstream; subsequent feat branches inherit via standard upstream-sync flow. All 9 scenario tests still pass against the Go reference fixtures. * refactor(tasks,mrtr): use SDK Client + AnyResult instead of raw-fetch helpers Drops initRawSession/rawRequest/rawRequestFull from tasks/helpers.ts in favor of the SDK's Client + StreamableHTTPClientTransport, paired with a Zod passthrough schema (AnyResult) that preserves SEP-2663 / SEP-2322 draft fields the SDK's typed schemas would strip. headers.ts and notifications.ts keep a small inline fetch where the SDK can't reach: per-request HTTP headers (SEP-2243) and SSE notification observation. Both reuse the SDK session via transport.sessionId. All SEP-2663 + MRTR ephemeral-flow scenarios pass against the Go fixture. * style: prettier formatting on tasks/mrtr scenarios * tasks: align ttlMs / pollIntervalMs assertions per 2026-05-07 SEP PR 2663 commit 62758914 standardised every duration field on the Ms suffix, integer milliseconds. wire-fields.ts now asserts ttlMs and pollIntervalMs are present on CreateTaskResult, the legacy v1 ttl and pollInterval keys are absent (already covered), and the interim ttlSeconds / pollIntervalMilliseconds keys are also absent on a post-2026-05-07 server. lifecycle.ts and the scenario README pick up matching prose updates. Verified by make testconf-tasks-v2 (8/8) against a renamed mcpkit fixture, and make testconf-mrtr (7/7 + 1 SKIPPED) against the paired MRTR surface. * style: prettier column alignment on tasks README * mrtr: flip MRTR_INPUT_REQUIRED_RESULT_TYPE per merged SEP-2322 SEP-2322 merged on 2026-05-06 with the variant renamed from IncompleteResult to InputRequiredResult and the resultType discriminator from "incomplete" to "input_required" (commit de6d76fb, per dsp-ant request). The MRTR_INCOMPLETE_RESULT_TYPE constant was specifically designed as a one-line flip point for this scenario. Renames - MRTR_INCOMPLETE_RESULT_TYPE = "incomplete" -> MRTR_INPUT_REQUIRED_RESULT_TYPE = "input_required" - isIncompleteResult -> isInputRequiredResult - All "IncompleteResult" -> "InputRequiredResult" in scenario prose and check descriptions (ephemeral-flow.ts, README.md) SEP-2663 had not yet flipped its discriminator literal as of PR head 82fb2c4d (5/7 21:52 UTC). Caitie's 5/15 RC commitment (issue comment 4384052694 on PR 2322) tracks the alignment to "input_required" both sides. The constant remains the one-line flip point in case the 2663 follow-up surprises us. Tested via mcpkit's make testconf-mrtr (7/7 + 1 SKIPPED green against a renamed mcpkit fixture) and make testconf-tasks-v2 (8/8 still green, no regressions on the paired surface). * mrtr: prettier formatting + fix README rename typo Lefthook prettier reformatted column alignment on first push attempt; README also had a stale "renamed from InputRequiredResult" — should read "renamed from IncompleteResult". Fix both. * mrtr-08: refresh SKIPPED prose post SEP-2322 merge Two stale references in the mrtr-tasks-composition SKIPPED check: - Comment block + errorMessage framed blocker (a) as "spec authors disagree" / "input_required vs incomplete". SEP-2322 merged 2026-05-06 with "input_required" (commit de6d76fb). The blocker now reads as "SEP-2663 has not yet aligned to the merged 2322 literal" — Caitie's 5/15 RC commitment (PR 2322 issue comment 4384052694) tracks the alignment. - errorMessage referenced "IsIncomplete signal" — that field was renamed to IsInputRequired on the mcpkit side in lockstep with the SEP-2322 wire-variant rename. Updated to match. Status stays SKIPPED because blocker (b) — the mcpkit middleware refactor (issue 347) — is still open. * mrtr-08: drop the resolved-discriminator framing from the SKIPPED message After SEP-2322 merged with "input_required", the only blocker that actually keeps mrtr-08 SKIPPED is the eager-task-creation pattern in reference-server middleware (panyam/mcpkit issue 347). The earlier two-blocker framing read as if the test were waiting on both, but blocker (a) is effectively resolved for any server that emits the merged-2322 literal — leaving (b) as the sole gate. Tighten the comment block + description + errorMessage to lead with the middleware refactor and demote the discriminator history to a parenthetical aside. * style: prettier line-break on mrtr-08 description string * tasks-wire-fields: address PR 262 review feedback Two requested changes from the SEP-2663 author's review pass on modelcontextprotocol/conformance PR 262 (pullrequestreview-4254601106). (1) Drop the interim-key absence checks. The transition window between "ttlSeconds / pollIntervalMilliseconds" and the final "ttlMs / pollIntervalMs" wording is over now that the merged spec settled on the Ms suffix. Useful while the spec was in flight, noise once it stabilized. Removes the absence checks plus the surrounding comment + description + details fields. (2) Add Number.isInteger() to ttlMs and pollIntervalMs validation. Spec says integer milliseconds; the previous typeof + range check would have allowed fractional values. Now both fields fail if they're not integers. README scenario table tightened: "ttlMs + pollIntervalMs present and integer-valued; legacy ttl / pollInterval keys absent". * style: prettier column alignment on wire-fields README row * feat(tasks): align scenarios with the Final-merged SEP-2663 spec SEP-2663 merged Final on 2026-05-15. Four normative wire changes baked in at merge time. This commit updates the tasks server-conformance scenarios so the suite validates each one as a MUST rather than tolerating divergence as INFO/MAY. 1. notifications/tasks/status renamed to notifications/tasks. The status-notifications scenario now FAILs on observing the legacy method on the v2 surface, and validates the new name's payload shape when emitted. 2. notifications/progress and notifications/message MUST NOT be sent for tasks. Two new absence-asserts on the status-notifications scenario, reusing the existing SSE-observation harness. 3. requestState removed from the tasks-v2 wire. The previous request-state scenario tested the deleted "Request State Management" section; replaced with absence-asserts on CreateTaskResult, DetailedTask, and notifications/tasks payloads. SEP-2322's InputRequiredResult on the MRTR surface still carries requestState and is unchanged. 4. -32003 (Missing Required Client Capability) for required-task tools when the client did not negotiate the extension. New scenario TasksRequiredTaskErrorScenario initializes without declaring the extension, calls a TaskSupport=required tool, and asserts the rejection code plus the structured requiredCapabilities payload. Documents `failing_job` as a fixture requirement. Verified locally against panyam/mcpkit examples/tasks-v2: 8/9 scenarios pass; the request-state-removal scenario intentionally FAILs against the current mcpkit main because the implementation still emits requestState on DetailedTask. That gap is the next item on the mcpkit catch-up plan. * chore(tasks): address PR 262 review feedback - notifications.ts: skip the entire scenario; SEP-2663 routes notifications/tasks over the SEP-2575 subscriptions/listen stream, not the tools/call POST SSE the prior harness observed. Body preserved in git blame for the rewrite follow-up. - request-state.ts: keep the absence-asserts (per the asymmetric recommendation in review) and reframe the doc block around the SEP-2322 lexical-adjacency motivation rather than pre-merge SEP history. - required-task-error.ts: cite SEP-2575 §"Missing Required Capabilities" as the canonical home of -32003; add SEP_2575_REF to specReferences on the -32003 checks. - Sweep decontextualized "merged spec dropped X" / "pre-merge SEP-2663 said Y" framing from tasks/* doc blocks and inline check descriptions. * chore(tasks): prettier — strip trailing blank line in notifications.ts * chore(tasks): drop `requestState` from `tasks/update` params `taskId` is the session-continuation handle on the tasks-v2 MRTR-resume path; `requestState` lives only on the SEP-2322 ephemeral InputRequiredResult surface, not on the tasks-v2 wire. * chore(tasks): loosen strict ack equality on tasks/cancel + tasks/update The cancel-empty-ack and tasks/update-ack checks were comparing the whole response against the literal `{resultType:"complete"}` via `JSON.stringify` equality. That rejected acks carrying `_meta` or any other future result-shape metadata even though the spec permits them. Now structural: assert `resultType === "complete"` plus absence of the task-envelope fields (`taskId`, `status`, `result`, `error`, `inputRequests`). `_meta` and other metadata pass through. * chore(tasks): poll for terminal status after tasks/cancel The cancel-empty-ack check observed status via an immediate tasks/get after the cancel ack. Cancellation is eventually-consistent, so the task could still be `working` (or transitioning) at the moment of observation, making the assertion flaky against any reasonable server. Now polls via `waitForTerminal()` and asserts the terminal status is `cancelled`. README's `slow_compute` fixture row documents the matching tool contract ("MUST settle to `cancelled` when `tasks/cancel` arrives while running") so the test has a deterministic anchor. * chore(tasks): tasks/cancel on a terminal task is idempotent The previous check asserted `-32602` when cancelling an already-terminal task. That forces every client to handle the race between observing a task's terminal status and racing in a cancel request — the task can terminate between the two and the cancel then errors instead of acking. Check 8 renamed to `tasks-cancel-terminal-idempotent-ack` and now asserts the same empty-ack shape (`resultType:"complete"` + no task- envelope fields) as on an active task. README's lifecycle table row updated to match. * docs(tasks): drop `requestState` from SEP-2663 wire-field listings `requestState` belongs to the SEP-2322 ephemeral MRTR result shape (`InputRequiredResult`), not to the tasks-v2 wire. The README listed it under SEP-2663 wire fields in four spots — top blurb, the SEP-2663 row of the Specs table, the v1↔v2 wire-format diff, and the raw-fetch design note. All four now describe only the actual SEP-2663 shapes; the raw-fetch note still calls out the SEP-2322 ephemeral `requestState` separately so the rationale for stripping it via raw fetch stays intact. The SEP-2322 row of the Specs table is unchanged since `requestState` is correctly listed there as an MRTR base type. * docs(tasks): rewrite stale request-state table to match absence-asserts The README's `tasks-request-state` table still described the prior scenario shape (shape / echo / stale-tolerance checks), which had assumed `requestState` was on the tasks-v2 wire. The scenario itself was rewritten earlier into the two-check absence assertion (`tasks-create-result-no-request-state` / `tasks-get-detailed-no-request-state`); the README now matches. A motivational paragraph above the table explains why a negative test exists for a field the spec never defines (the SEP-2322 lexical-adjacency confusion vector). * chore(tasks): assert -32003 (not -32601) for gated tasks methods When a client calls tasks/get / tasks/update / tasks/cancel without having negotiated the io.modelcontextprotocol/tasks extension, the suite now asserts -32003 (Missing Required Client Capability, SEP-2575 §"Missing Required Capabilities") instead of -32601. The spec doesn't currently mandate this code for the gated-method path, so the assertion is forward-looking: it follows the SEP-2575 pattern that already governs `required` tools (and that subscriptions/listen for tasks is expected to use). README's open-questions list calls this out so readers know it's a spec-clarification ahead-of-the-curve. Drops the now-obsolete "Invalid requestState — silent ack vs -32602" open-questions item: the previous request-state scenario covered that, but the rewritten absence-asserts scenario no longer exercises that path. * chore(tasks): prettier — realign README table pipes after row edits * feat(tasks/mrtr): initRawSession helper for draft-version handshake The SDK's Client.connect() pins protocolVersion to LATEST_PROTOCOL_VERSION in the initialize body. Scenarios tagged DRAFT_PROTOCOL_VERSION would still negotiate the previous stable on the wire, which means a strict draft-only server rejects the handshake with unsupported_protocol_version. initRawSession sidesteps the SDK: a raw fetch initialize that carries the draft version, captures the session ID, sends notifications/initialized, and exposes the small surface scenarios actually use - request, requestFull, notification, close. Errors are thrown as McpError so existing instanceof / .code checks keep working. SSE response parsing folds in the per-scenario raw helper from headers.ts. All 8 SEP-2663 tasks scenarios + the MRTR ephemeral-flow scenario migrate to the raw session. tsc clean. * feat(tasks/mrtr): wire-mode matrix; SEP-2575 _meta + SEP-2243 routing headers initRawSession grows a `stateless` option that picks between the legacy session wire (initialize handshake + Mcp-Session-Id) and the SEP-2575 stateless wire (no initialize; server/discover up front; per-request `_meta.io.modelcontextprotocol/{protocolVersion, clientInfo, clientCapabilities}` envelope; `MCP-Protocol-Version` header on every call). Tasks behavior is wire-independent in spec, so the harness can run each scenario against both wires. To keep the legacy run green through this PR's refactor (and to surface stateless-mode gaps deliberately rather than as a regression), the default is legacy- only — set TASKS_WIRE_MODES=legacy,stateless (or just stateless) to opt the matrix on. Same toggle for MRTR via MRTR_WIRE_MODES. Two follow-on cleanups in the same commit so the legacy run actually passes against an SEP-2243-enforcing server: - All request paths now auto-emit Mcp-Method (and Mcp-Name for tools/call.params.name / resources/read.params.uri) on protocol versions in `SEP_2243_ENFORCED_VERSIONS` (today DRAFT-2026-v1). Scenarios that probe the mismatch path can still override via `extraHeaders`. Empty record on older versions so the helper stays silent when the spec doesn't require it. - `tasks-headers-body-method-authoritative` was written under an older SEP-2243 reading where Mcp-Method was tolerant. The current spec (and mcpkit's PR 477 implementation, plus the upstream http-header-validation scenario) says the server MUST reject mismatches with -32001 HeaderMismatch. Renamed to `tasks-headers-reject-mismatched-method` and the assertion flipped to expect the rejection. Verification: testconf-tasks-v2 + testconf-mrtr both green on legacy wire against mcpkit's tasks-v2 fixture (9/9 + 1/1). * rename TASKS_WIRE_MODES / MRTR_WIRE_MODES → MCP_WIRE_MODES Wire-mode choice is a cross-cutting conformance concern, not a per-suite knob. Operators wouldn't realistically toggle tasks and mrtr to different wires (both fixtures speak the same MCP server, either it supports both wires or one). Single env var read by both harnesses; default unchanged at ['legacy']. * default MCP_WIRE_MODES to legacy+stateless The conformance suite is brand-neutral — every SDK consuming it should validate both MCP wires for SEP-2663 / SEP-2322 behavior by default. Pinning the default to legacy-only let mcpkit's CI stay green while gaps in its stateless dispatcher pended, but quietly under-tested any other SDK that adopts the suite. Default now runs every tasks-v2 scenario × 2 wires and the mrtr ephemeral-flow scenario × 2 wires. SDKs with only one wire implemented pin via MCP_WIRE_MODES=legacy or =stateless. mcpkit's own audit will need that pin (or to close panyam/mcpkit issue 480) until the stateless dispatcher consumes per-request _meta.clientCapabilities for the tasks extension declaration. * helpers: send MCP-Protocol-Version on every post-initialize POST Per 2025-11-25 transport spec §Protocol-Version Header, clients MUST include MCP-Protocol-Version on every subsequent HTTP request after initialize. Universal post-initialize requirement; applies to both the legacy session wire and the SEP-2575 stateless wire. The helper was dropping the header on legacy follow-up calls as a workaround for our server's detectWireKind treating the header as a stateless-wire signal. That workaround was non-spec-compliant; the right fix is for our server to drop the header from its wire-detection precedence list and rely on body-level _meta.protocolVersion as the SEP-2575 signal. Filed separately. Comment thread: PR 5 review (Luca, 2026-05-27). * types/helpers: parameterize wire mode via ScenarioRunOptions Drop the module-level defaultStateless flag + setDefaultWireStateless shim that the harness was toggling in beforeEach. Wire mode now flows as an explicit parameter through ClientScenario.run, so tests can be parallelized safely (no shared mutable state between concurrent runs). ClientScenario.run gains an optional ScenarioRunOptions second arg. Existing scenarios that ignore the arg satisfy the interface as-is (TypeScript permits an implementation to declare fewer parameters than the interface). The 10 task/mrtr scenarios that consume wire mode add one line each to thread `opts?.stateless` into initRawSession. Harness loops call `scenario.run(SERVER_URL, { stateless: wire === 'stateless' })` directly; no more beforeEach setter. Comment thread: PR 5 review (Luca, 2026-05-27, comments 2 and 7 on parallelism). * harness: use describe.each + it.each for the (wire x scenario) matrix Replaces the nested for-loops in both the tasks and mrtr harnesses with vitest's table-driven describe.each + it.each idioms. Same N x M cardinality of tests, but vitest sees the parameter table directly: - Reporter labels render the wire row inline: `... > stateless wire > 'tasks-lifecycle' - all checks ...` - Each (wire, scenario) becomes a leaf test vitest can schedule independently, opening the door to test.concurrent / pool config later without restructuring. WIRE_TABLE pairs each WireMode with the boolean flag the harness needs to pass via ScenarioRunOptions, so the `it.each` callback receives the prepared `stateless` directly rather than deriving it inside every test body. No on-wire change. Behavior identical against the reference fixture (9 task scenarios + 1 mrtr scenario, each per wire). Comment thread: PR 5 review (Luca, 2026-05-27, comments 5, 6, 9, 10). * helpers: extend Mcp-Name to prompts/get + tasks/* (SEP-2243 + SEP-2663) routingHeaders now auto-emits Mcp-Name for the full standard-header surface the spec defines: - prompts/get -> params.name (SEP-2243 §Standard Headers) - tasks/get -> params.taskId (SEP-2663 §Streamable HTTP routing headers) - tasks/update -> params.taskId (SEP-2663) - tasks/cancel -> params.taskId (SEP-2663) tools/call (params.name) and resources/read (params.uri) were already covered. Switch from if/else chain to a method-name switch since the surface is now wider; cleaner to extend further. Helper doc updated with the full surface list and a TODO for SEP-2243 §Custom Headers from Tool Parameters (Mcp-Param-* mirroring from x-mcp-header annotations), which requires caching the tools/list schema. Tracked as a separate follow-up; no current tasks/mrtr scenario exercises a tool with x-mcp-header annotated inputs. Comment thread: PR 5 review (Luca, 2026-05-27, comment 4). * _shared/wire-mode: hoist parseWireModes out of the per-suite harnesses Tasks and mrtr harnesses had byte-identical copies of WireMode type, VALID_MODES, DEFAULT_WIRE_MODES, parseWireModes, and WIRE_MODES. Hoisted to src/scenarios/server/_shared/wire-mode.ts so the single parser drives both suites. Each harness now does: import { parseWireModes, type WireMode } from '../_shared/wire-mode'; const WIRE_MODES: WireMode[] = parseWireModes(); No behavior change. MCP_WIRE_MODES env var semantics identical; default still legacy+stateless. Companion TODO landed in tasks/helpers.ts: most of that module (initRawSession, RawSession, routingHeaders, SEP_2243_ENFORCED_VERSIONS, readJsonRpcResponse, AnyResult, the failureCheck/skipCheck/errMsg test helpers) isn't actually tasks-specific and belongs in _shared too. Holding that file reshuffle for a separate follow-up PR so the move gets its own focused review thread without crowding the SEP work. Comment thread: PR 5 review (Luca, 2026-05-27, comment 8). * refactor: hoist raw-session + sep-refs + checks helpers into _shared/ Picks up the TODO left in tasks/helpers.ts on PR 5: the raw fetch session machinery, the SEP-2243 routing-header builder, the SEP_* reference constants, and the failureCheck / skipCheck / errMsg / AnyResult test scaffolding were never tasks-specific. They lived in tasks/helpers.ts only because tasks/ was the first server suite to need them. mrtr/helpers.ts re-declared SEP_2322_REF + errMsg + failureCheck for the same reason. Move them to src/scenarios/server/_shared/: - sep-refs.ts: SEP_2243_REF, SEP_2322_REF, SEP_2575_REF, SEP_2663_REF - checks.ts: errMsg, failureCheck, skipCheck, AnyResult - raw-session.ts: JsonRpcResponse, RawSession, InitRawOptions, initRawSession (legacy + stateless paths), routingHeaders, SEP_2243_ENFORCED_VERSIONS, readJsonRpcResponse, nextRawId tasks/helpers.ts shrinks to the tasks-shaped pieces it actually owns: TASKS_EXTENSION_ID and the waitForTerminal / waitForStatus polling loops. mrtr/helpers.ts drops the duplicate SEP ref + the duplicate test-scaffolding wrappers and re-imports from _shared/. skipCheck and failureCheck in _shared/checks.ts make specReferences required (a shared scaffold can't pick a sensible default), so all call sites now pass the SEP they're grading against explicitly. Pure file move + import-path churn. No behavioral change. Typecheck + lint clean. * feat: emit SEP-2243 Mcp-Param-* headers from tool inputSchema annotations Closes the TODO in routingHeaders. SEP-2243 §"Custom Headers from Tool Parameters" requires the client to mirror primitive-typed tools/call arguments as `Mcp-Param-` HTTP headers when the tool's inputSchema annotates the property with `x-mcp-header: ""`. Servers behind L7 routers / WAFs use these headers to make routing or policy decisions without parsing the JSON body. Implementation: - RawSession gains a `toolSchemas: Map` cache. - `initRawSession` issues a best-effort `tools/list` after the handshake (after `notifications/initialized` for legacy; after `server/discover` for SEP-2575 stateless) and populates the cache from the response. Failures are swallowed — empty cache is the safe default. - `routingHeaders` takes an optional `toolSchemas` and walks the cached schema for tools/call: for each primitive-typed property (string / number / integer / boolean) with `x-mcp-header`, it encodes the argument value per SEP-2243 §value-encoding (plain printable ASCII verbatim; everything else wrapped as `=?base64?{b64-utf8}?=`) and emits `Mcp-Param-`. - Both wire flavours (legacy + SEP-2575) call routingHeaders the same way, so the same Mcp-Param-* headers ship regardless of transport mode. Behavior wash for tools without `x-mcp-header` annotations — they emit only Mcp-Method + Mcp-Name as before. Behavior wash for non-tools/call methods. No new dependencies (uses `globalThis.btoa` + `TextEncoder`, both standard in Node 20+). Typecheck + lint + the existing vitest suite stay green. * chore(_shared/raw-session): drop reference-impl naming from doc comments Tightens three doc-comment passages that named or hinted at a specific server-side implementation as the canonical reference. The conformance suite is positioned brand-neutral; the doc comments now describe the wire behavior in spec-anchored language only. No behavioral change. * sep-refs: point at rendered SEP pages on modelcontextprotocol.io PR URLs freeze at merge commit; the rendered SEP pages on the spec website absorb post-merge amendments. Switch all four refs: SEP-2243 → /seps/2243-http-standardization SEP-2322 → /seps/2322-MRTR SEP-2575 → /seps/2575-stateless-mcp SEP-2663 → /seps/2663-tasks-extension * raw-session: use eventsource-parser for SSE parsing Delegate the SSE branch of readJsonRpcResponse to eventsource-parser (the same parser the MCP TS SDK uses). The hand-rolled splitter worked for the single-frame JSON-RPC response we need but didn't cover the full WHATWG SSE semantics — multi-line `data:` continuation, CR/CRLF line endings, comment lines, the `event:`/`id:`/`retry:` fields. The library covers all of it. Already a direct dependency (3.x), so no new transitive footprint. * test: pin readJsonRpcResponse contract for SSE + JSON branches 12 vitest cases against `_shared/raw-session.ts`'s content-type dispatcher: application/json (id-match, mismatch, error frames) and text/event-stream (single-frame extraction, id-filter across multi- event streams, multi-line `data:` continuation, CRLF tolerance, comment/retry/id field handling, error-frame return, empty body, non-JSON data interleaving). Builds synthetic `Response` objects locally; no fixture server required, runs in every CI. Tests pin the contract the eventsource-parser swap inherits — guards against future parser- version regressions and against any subsequent rewrite that subtly changes the SSE branch. * tasks: isolate negative-path dispatch checks (conformance#262) The removed-method (-32601) and unknown-taskId (-32602) checks in tasks-dispatch-and-envelope previously sent requests missing Mcp-Name, so strict servers validating routing headers per SEP-2243 §Server Behavior could reject before reaching method dispatch — making the asserted error code unreachable. - routingHeaders(): emit Mcp-Name on tasks/result so the removed-method check fails specifically on -32601, not -32001 HeaderMismatch. - validTasksParams() helper: baseline params per tasks-namespace method; negative checks override one field to express isolation intent declaratively. - ISOLATION_HINT on wrong-code failures points at the most likely cause (routing headers / _meta / params shape short-circuit before dispatch). Forward-safe under either reading of the open SEP-2663 client-MUST → server-MUST-reject elevation question: the added Mcp-Name matches the body, so SEP-2243's universal mismatch rule can't fire either way. Verified: 18/18 tasks scenarios pass. * tasks: rename scenario check IDs to sep-2663-* slugs Aligns 13 tasks scenario check IDs with the SEP-2484 traceability convention used by sep-2322.yaml and others: the slug emitted at runtime matches the check: row in the SEP's requirement-traceability yaml, so plan.modelcontextprotocol.io can link the SEP requirement to its implementing scenario. Rename map: tasks-tools-call-without-extension-sync → sep-2663-server-rejects-undeclared-client tasks-methods-gated-without-extension → sep-2663-tasks-methods-non-declaring-32003 tasks-server-task-creation → sep-2663-result-type-task-on-create tasks-get-during-working → sep-2663-tasks-get-status-working tasks-get-terminal-inlined-result → sep-2663-tasks-get-status-completed tasks-tool-error-completed-iserror → sep-2663-tool-error-uses-completed-status tasks-protocol-error-failed-shape → sep-2663-tasks-get-status-failed tasks-cancel-empty-ack → sep-2663-cancel-ack-empty-result tasks-removed-tasks-result → sep-2663-tasks-result-removed-method-not-found tasks-legacy-task-param-ignored → sep-2663-legacy-task-param-ignored tasks-strong-consistency-immediate-get → sep-2663-durable-create-strong-consistency tasks-get-unknown-task-id-rejected → sep-2663-tasks-get-invalid-task-id-32602 tasks-mrtr-input-requests-on-tasks-get → sep-2663-tasks-get-status-input-required Wire behavior unchanged — only the reported slugs change. Verified 18/18 tasks scenarios still pass. Untouched (intentionally): scenario-flow gates like tasks-extension-advertised, tasks-sync-tool-call, tasks-wire-field-renames, tasks-immediate-result-shortcut etc., which test end-to-end flows rather than a specific RFC-2119 sentence and surface in the traceability manifest as untracked (same pattern as sep-2322.yaml). * seps: add sep-2663.yaml requirement-traceability declaration Declares the 37 conformance checks (and 12 excluded requirements) the SEP-2663 Tasks Extension surface should produce, mapping each normative MUST/SHOULD sentence on the SEP page to either a check ID emitted by an existing scenario or a documented reason for exclusion. Exclusions are all client-internal, host-internal, or architectural requirements that aren't observable at the protocol level — confirmed during the new-sep skill's exclusion round. The 13 scenario checks renamed in the previous commit now match the check: slugs declared here; the remaining ~12 yaml rows describe requirements without a scenario emitter yet — those are gaps to fill in follow-up work (notifications via subscriptions/listen, key uniqueness across MRTR rounds, legacy-capability migration probes). spec_url points at the SEP page itself (status: Final) — SEP-2663's normative content lives on /seps/2663-tasks-extension and is mirrored in modelcontextprotocol/experimental-ext-tasks for source-control purposes. Every quoted text: field was verified copy-paste findable against the rendered SEP page after stripping markdown formatting. * tasks: enforce SEP-2663 routing-header validation on tasks surface Two related fixes: 1. tasks/headers.ts description previously said servers "MUST NOT require" the Mcp-Method / Mcp-Name routing headers on the tasks surface and "MUST NOT change dispatch behavior based on them — including when the headers disagree with the body." Both claims are spec-incorrect. SEP-2663 author confirmed in the conformance review thread that the client-MUST in §"Streamable HTTP: Routing Headers" elevates Mcp-Name to "required standard header" status for tasks/get / tasks/update / tasks/cancel, so SEP-2243's §"Server Behavior" mismatch + missing-header rejection applies. Description rewritten to match the spec: routing headers are REQUIRED, server MUST reject missing or mismatched with -32001. 2. Add a negative-path check covering the most common failure mode on the tasks surface: tasks/get with mismatched Mcp-Name MUST return -32001 HeaderMismatch. Probes tasks/get specifically; tasks/update / tasks/cancel follow by extension. Missing-header case is deferred (requires raw-session enhancement to suppress a header entirely; current extraHeaders mechanism only overrides). 3. New yaml row pinning the rule to the SEP-2243 §"Server Behavior" sentence via row-level url override (the rule itself is SEP-2243; the scope of application on the tasks surface is what's distinctively SEP-2663). Known impact: surfaces a real gap in SDKs that derived their routing-header validation from the SEP-2243 base table only (which lists tasks/* as NOT requiring Mcp-Name). Those SDKs will see this check go FAILURE until they extend their validation to cover tasks-namespace methods per SEP-2663 §540. CI against everything-server is unaffected (skips the tasks suite entirely; everything-server doesn't implement the extension). * mrtr: activate mrtr-08 composition check + fold into sep-2663.yaml Folds PR 7 (panyam/mcpconformance) into PR 262: - Cherry-picks the mrtr-tasks-composition activation from feat/sep-2663-mrtr-08-composition (ad4f267 upstream): real end-to-end check driving the SEP-2663 commit 451f5e1 promotion flow (InputRequiredResult rounds gather user_name via elicitation/create, then CreateTaskResult on the final round escalates to async; the final tasks/get result reflects the answer gathered during MRTR). - Addresses LucaButBoring's PR 7 nit: replaces the inline SEP-2663 reference literal with the shared SEP_2663_REF constant (now also pointing at the rendered SEP page). - Renames the scenario's emitted check ID from `mrtr-tasks-composition` to `sep-2663-mrtr-synchronous-before-task-creation` to match the corresponding row in sep-2663.yaml — closes the gap between declared requirement and implementing check. - Updates mrtr/README.md: refreshes the Specs-covered table and the per-check description table for the new slug, and drops the "Open issues" section (both blockers are resolved — discriminator value aligned upstream; mcpkit middleware refactored per panyam/mcpkit#347). - Updates the sep-2663.yaml flow-gate comment block to note that the ephemeral mrtr-* flow gates also fall into the untracked category for the same reason as the tasks-* flow gates. One fewer skipped check on PR 262. Verified 123/123 server-suite tests pass against mcpkit's tasks-v2 + mrtr fixtures (post panyam/mcpkit#499 + #501). Closes panyam/mcpconformance#7. * tasks/mrtr: drop legacy wire when targeting DRAFT-2026-v1 SEP-2575 (Accepted) removes the initialize handshake on DRAFT-2026-v1 and replaces it with self-contained per-request _meta. Driving the legacy initialize+Mcp-Session-Id wire against a draft fixture is therefore a logical contradiction: the harness asserts `protocolVersion: "DRAFT-2026-v1"` while doing the handshake the version removed. Servers that strictly enforce SEP-2575 correctly reject these requests with -32602 (see Randgalt's report on conformance PR 262, 2026-05-31); lenient servers silently accept them and mask the spec gap. Both effects are wrong for the harness — we should only emit spec-permitted traffic. Adds `effectiveWireModes(protocolVersion)` in _shared/wire-mode.ts. It wraps `parseWireModes()` and filters `legacy` out when the target version is in POST_SEP_2575_VERSIONS (currently just DRAFT-2026-v1; widen when a future dated release picks SEP-2575 up). If the user explicitly pinned MCP_WIRE_MODES=legacy against a post-SEP-2575 version, the helper warns and falls back to ['stateless'] so the suite still runs. Both draft-only suites (tasks, mrtr) switch to `effectiveWireModes(DRAFT_PROTOCOL_VERSION)`. The MCP_WIRE_MODES override remains honored within the spec-permitted set. Verified end-to-end with a strict mcpkit fixture (panyam/mcpkit fix/sep-2575-enforce-meta-on-draft): - pre-fix harness → 7/18 fail with the spec-required error - post-fix harness → 9/9 pass (stateless wire only) * tasks/mrtr: derive wire from spec version, drop RunContext.wire `RunContext.wire` was a parallel knob to `specVersion` that the tasks and MRTR harnesses used to loop scenarios across both the legacy session wire and the SEP-2575 stateless wire. After SEP-2575 collapsed the legacy wire on DRAFT-2026-v1 (a6f7c27), the matrix shrank to a single column on draft — and an explicit `wire` field was both redundant and unsafe: it let callers go out of sync with the `specVersion` they declared on the wire. The CLI's `runServerConformanceTest` never set the field, so every `npm start -- server --scenario tasks-* --url ...` invocation silently emitted the legacy `initialize` handshake against a draft server, producing requests with no `_meta.io.modelcontextprotocol/*` envelope. Strict SEP-2575 servers correctly rejected the traffic; lenient ones accepted it and masked the gap. Changes: - Drop `wire?` from `RunContext`. - Add `isStateless(ctx)` in `_shared/wire-mode.ts`; scenarios derive `stateless` from `POST_SEP_2575_VERSIONS.has(ctx.specVersion)`. - Extend the CLI's spec-version inference to default extension scenarios to draft (today every extension scenario in the repo lives there). Without this, the inference stayed on `LATEST_SPEC_VERSION` for tasks scenarios and kept emitting legacy traffic. - Flatten the now-1-element wire loop in the tasks + mrtr harnesses; remove the parseWireModes/effectiveWireModes/MCP_WIRE_MODES infra that drove it (no remaining consumers). Adds a vitest regression in `runner/server.test.ts` that spins up an in-process recording HTTP server, drives `runServerConformanceTest` at `tasks-lifecycle`, and asserts the first outgoing call is `server/discover` carrying `_meta.io.modelcontextprotocol/*` — the SEP-2575 fingerprint — so the CLI wire selection can't silently regress. * scenarios + connection: address conformance#262 layout review Folds the parallel mini-framework the tasks/MRTR suites had grown into the existing harness shape pcarleton called out in conformance PR 262. No new spec coverage; existing scenarios still pass 241/241 unit tests and the regression added for the wire-derivation fix still pins the CLI fingerprint. Layout scenarios/server/_shared/ is gone. The helper bundle moves to scenarios/server/tasks-mrtr-helpers.ts (matching the existing input-required-result-helpers.ts precedent); raw-session.ts and its test fold into connection/stateless.ts. Per-suite runners + READMEs (tasks/all-scenarios.test.ts, mrtr/all-scenarios.test.ts, _shared/test-runner.ts, both READMEs) are removed. The scenarios are already registered in scenarios/index.ts pendingClientScenariosList, so the CLI runner (`npm start -- server --scenario tasks-lifecycle --url ...` or `--suite pending --url ...`) is the supported entry point. Per AGENTS.md: don't reimplement the runner. Connection extension Connection grows the three things tasks/MRTR scenarios actually need beyond the existing surface: - `request(method, params, extraHeaders?)` for the SEP-2243 header-mismatch tests. - `discover(): Promise>` for capability-negotiation scenarios. Resolved eagerly from the SDK accessors on the stateful wire; issued lazily as `server/discover` on the stateless wire and memoized. - `ConnectOptions { capabilities?, clientInfo? }` on RunContext.connect() so scenarios can negotiate extensions (`extensions: { 'io.modelcontextprotocol/tasks': {} }`) at bootstrap on either wire. Crucially the stateless wire does NOT eagerly emit `server/discover` when capabilities are declared. SEP-2575 says per-request `_meta.io.modelcontextprotocol/clientCapabilities` is authoritative; a server that gates extensions on a prior `server/discover` is non-conformant, and the conformance suite's job is to surface that, not paper over it. Scenario migration Every tasks/MRTR scenario switches from `initRawSession(...)` to `await ctx.connect({ capabilities })`, and the capability scenario switches from `session.initializeResult.capabilities` to `await withExt.discover()`. The wire choice now flows entirely from `specVersion` — `isStateless(ctx)` (moved to connection/select.ts next to `connectFor`) is the single source of truth. Description sweep Each scenario's `description` string now spells out the required server fixtures (tool names + behavior) directly. Previously that content lived only in the file-header comment, so an implementer reading the runner's emitted description was missing it. Reviewer notes - 23 files changed, +455 / -291 (net + because the Connection abstraction now carries surface that used to live in raw-session; the raw-session deletion was 692 lines on its own). - Unit tests: 241 pass (the 12 deleted tests were redundant with connection-level coverage; the genuinely-unique SSE multi-line `data:` test pinned behavior the connection parser doesn't have). - Reference fixture runs: tasks scenarios surface a fixture-side spec gap (per-request `_meta` not honored without a prior `server/discover`). That's the suite working; the gap will be filed upstream against the fixture, not papered over here. * tasks: dedupe MRTR ephemeral-flow against #188, fold composition into tasks/ Of the 8 checks in mrtr/ephemeral-flow.ts, 7 substantially overlapped the SEP-2322 InputRequiredResult* scenarios that #188 already shipped (basic-elicitation, sampling, list-roots, request-state, multiple-input- requests, multi-round, wrong-input-key). Only sep-2663-mrtr-synchronous- before-task-creation — the SEP-2663 commit 451f5e1 composition path where the MRTR loop's final round returns a CreateTaskResult — is genuinely new versus #188's coverage, and it's SEP-2663-specific, not SEP-2322-specific. Moves that one check to its own tasks scenario in tasks/composition.ts (extensionId = io.modelcontextprotocol/tasks, registered alongside the other tasks-* scenarios in scenarios/index.ts) and deletes the entire mrtr/ directory. Reuses the existing #188 helpers (isInputRequiredResult, mockElicitResponse from input-required-result-helpers.ts) rather than reintroducing the parallel mrtr/helpers.ts ones. * address review on conformance#262 follow-up - connectStateless.discover() now reuses the request() pipeline (send + drainEvents + unwrap) instead of inlining a duplicate copy. The local memoization stays via discoverPromise ??=. - runner/server.test.ts mock server no longer silently swallows unparseable bodies. They land in a parseFailures sidecar that the test asserts is empty — a non-JSON body to this mock is exactly the kind of regression the test exists to catch. - Drop the tautological connectFor identity tests. They asserted `typeof === 'function'` and `not.toBe(connectStateful)`, both of which hold trivially once connectFor wraps in a closure. Wire-shape behavior is already covered by the connectStateless mock-fetch suite and by runner/server.test.ts. * tasks/mrtr: address PR 262 audit before review Pre-emptive cleanup against the upstream patterns established by recent merges (#318 Connection abstraction, #319 draft-spec consistency, #303 SEP-2243 check IDs, #321 MockServer surface). No behavior change for scenarios — every check is still emitted; only the shape of how they're emitted moves toward upstream's house style. Surface - Revert withRequestMeta / sendStatelessRequest signatures to match upstream byte-for-byte. The need that pushed me to widen those (per-request _meta.clientCapabilities for extension declaration on the SEP-2575 stateless wire) is now handled inside connectStateless: ConnectOptions.capabilities / clientInfo are folded into params._meta via the trailing _meta spread that withRequestMeta already supports. This keeps the new "extension declaration" concept on the Connection layer only — the lower-level helpers stay shared with every other scenario in the suite. - Document that Connection.request's extraHeaders throws on the stateful wire instead of "ignored with a warning" — the SDK transport manages headers internally, so silent drop in a conformance harness would mask test correctness. Layout - src/scenarios/server/tasks-mrtr-helpers.ts moves under tasks/ as tasks/mrtr-helpers.ts. All 10 importers live in tasks/; the file's prior placement next to input-required-result-helpers.ts no longer applies once those scenarios moved into tasks/. - Drop the unused AnyResult export + zod import. The Connection wrapper doesn't take a zod schema, so the helper was dead on arrival. - SEP-2322 spec reference now re-exports MRTR_SPEC_REFERENCES[0] from input-required-result-helpers so the codebase holds one URL per SEP instead of two for SEP-2322. Naming - All 10 tasks/*.ts switch to `readonly source = { ... } as const` to match the convention used by caching.ts, resources.ts, http-standard-headers.ts. Drops the ScenarioSource import where now unused. - Scenario-scope each scenario's connect-failure check ID (tasks-lifecycle-bootstrap, tasks-headers-bootstrap, …) instead of sharing tasks-session-bootstrap. Matches the upstream caching.ts pattern (sep-2549-caching-connection) where each scenario owns its own bootstrap-failure ID. Traceability - sep-2663.yaml gains the positive-path counterpart to the existing rejection requirement under Streamable HTTP: Routing Headers, so every sep-2663-* check ID emitted by tasks/*.ts is declared in the manifest. Verification - Conformance vitest: 270/270. - Build: clean. - mcpkit testconf-tasks-v2 (47 fork scenarios + 1 sentinel): pass. - mcpkit testconf-mrtr (3 fork scenarios + 1 sentinel): pass. * tasks/required-task-error: anchor -32003 checks to sep-2663.yaml rows The two checks in required-task-error.ts each verified a distinct normative requirement that sep-2663.yaml already declared, but the check IDs (tasks-required-task-error-code, tasks-required-task-error-data-shape) used the tasks-* flow-gate prefix and went untracked by the traceability manifest. Promote both check IDs to sep-2663-*: - tasks-required-task-error-code → sep-2663-server-returns-32003-when-required - tasks-required-task-error-data-shape → sep-2663-server-returns-32003-data-shape Split the existing YAML entry to mirror the split. The spec sentence glues two observable requirements with a trailing colon (the JSON-RPC code, and the data-shape payload that names the missing extension); each gets its own row so each is verifiable independently. Remove the now-stale tasks-required-task-error-code reference from the sep-2663.yaml flow-gate comment block — that check is no longer a flow-gate. No behavior change. 270/270 vitest still pass. * runner: forward ConnectOptions from RunContext.connect server.ts built the RunContext with `connect: () => connectFor(...)(serverUrl)`, dropping the opts argument scenarios pass to ctx.connect(). TypeScript doesn't catch this because `() => T` is assignable to `(opts?) => T`. Result: scenarios declaring capabilities sent DEFAULT_CLIENT_CAPABILITIES on the wire in CLI runs while testContext() forwarded opts correctly, so unit tests passed but `npm start -- server --scenario tasks-*` against a strict-draft server failed with missing-capability errors. Tightens the regression test to assert scenario-passed capabilities reach _meta.clientCapabilities.extensions, not just that defaults are present. * tasks: align scenario descriptions and cancel-ack check with SEP-2663 capability.ts: description said non-declaring tasks/* requests get -32601 MethodNotFound; the assertion checks MissingRequiredClientCapability per SEP-2663 §Error Handling. Reworded to the named error type and per-request _meta/server-discover framing (SEP-2575). lifecycle.ts: description claimed cancel-on-terminal MUST return -32602; Check 8 in the same file asserts an idempotent ack and the SEP has no -32602 carve-out for terminal tasks. Removed the bullet. lifecycle.ts Check 7: SEP-2663 §Task Cancellation says transition to `cancelled` is not guaranteed; stop failing the ack-shape check on the settled status and record it in details instead. * connection: fix stale docstring and drop redundant eta-expansion stateless.ts docstring referenced `connection.initializeResult`; the implementation exposes `discover()`. select.ts wrapped connectStateful in an identity arrow; pass it directly now that signatures match. --------- Co-authored-by: Paul Carleton --- src/connection/connection.test.ts | 23 +- src/connection/index.ts | 46 +- src/connection/sdk-client.ts | 36 +- src/connection/select.ts | 22 +- src/connection/stateful.ts | 31 +- src/connection/stateless.ts | 83 ++- src/connection/testing.ts | 4 +- src/runner/server.test.ts | 93 ++- src/runner/server.ts | 8 +- src/scenarios/index.ts | 41 +- src/scenarios/server/negative-mrtr.test.ts | 2 +- src/scenarios/server/tasks/capability.ts | 275 +++++++++ src/scenarios/server/tasks/composition.ts | 179 ++++++ src/scenarios/server/tasks/dispatch.ts | 529 +++++++++++++++++ src/scenarios/server/tasks/headers.ts | 316 +++++++++++ src/scenarios/server/tasks/helpers.ts | 110 ++++ src/scenarios/server/tasks/lifecycle.ts | 534 ++++++++++++++++++ src/scenarios/server/tasks/mrtr-helpers.ts | 107 ++++ src/scenarios/server/tasks/mrtr-input.ts | 372 ++++++++++++ src/scenarios/server/tasks/notifications.ts | 56 ++ src/scenarios/server/tasks/request-state.ts | 181 ++++++ .../server/tasks/required-task-error.ts | 221 ++++++++ src/scenarios/server/tasks/wire-fields.ts | 242 ++++++++ src/seps/sep-2663.yaml | 200 +++++++ src/types.ts | 3 +- 25 files changed, 3648 insertions(+), 66 deletions(-) create mode 100644 src/scenarios/server/tasks/capability.ts create mode 100644 src/scenarios/server/tasks/composition.ts create mode 100644 src/scenarios/server/tasks/dispatch.ts create mode 100644 src/scenarios/server/tasks/headers.ts create mode 100644 src/scenarios/server/tasks/helpers.ts create mode 100644 src/scenarios/server/tasks/lifecycle.ts create mode 100644 src/scenarios/server/tasks/mrtr-helpers.ts create mode 100644 src/scenarios/server/tasks/mrtr-input.ts create mode 100644 src/scenarios/server/tasks/notifications.ts create mode 100644 src/scenarios/server/tasks/request-state.ts create mode 100644 src/scenarios/server/tasks/required-task-error.ts create mode 100644 src/scenarios/server/tasks/wire-fields.ts create mode 100644 src/seps/sep-2663.yaml diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index 822d3bb..6c77607 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -1,30 +1,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { - connectFor, - isStatefulVersion, - STATELESS_SPEC_VERSIONS -} from './select'; -import { connectStateful } from './stateful'; +import { isStatefulVersion, STATELESS_SPEC_VERSIONS } from './select'; import { connectStateless } from './stateless'; import { JsonRpcError } from './index'; import { DRAFT_PROTOCOL_VERSION } from '../types'; -describe('connectFor', () => { - it('returns stateful for dated 2025-x versions', () => { - expect(connectFor('2025-03-26')).toBe(connectStateful); - expect(connectFor('2025-06-18')).toBe(connectStateful); - expect(connectFor('2025-11-25')).toBe(connectStateful); - }); - it('returns stateless for the draft version', () => { - // connectFor wraps connectStateless in a closure (to pass the spec - // version through), so identity with connectStateless no longer holds; - // assert it did not select the stateful implementation. The wire-level - // behaviour of the wrapper is covered in stateless.test.ts. - expect(connectFor('2026-07-28')).not.toBe(connectStateful); - expect(connectFor('2026-07-28')).not.toBe(connectStateless); - }); -}); - describe('STATELESS_SPEC_VERSIONS', () => { it('contains exactly the versions isStatefulVersion rejects', () => { expect(STATELESS_SPEC_VERSIONS.length).toBeGreaterThan(0); diff --git a/src/connection/index.ts b/src/connection/index.ts index e20830b..47952c6 100644 --- a/src/connection/index.ts +++ b/src/connection/index.ts @@ -15,14 +15,40 @@ 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 + params?: Record, + extraHeaders?: Record ): Promise; /** @@ -33,6 +59,20 @@ export interface Connection { */ 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; } @@ -48,7 +88,7 @@ export interface RunContext { * Scenarios that test the connection mechanics themselves (initialize, * GET-SSE, DNS rebinding) bypass this and use raw fetch. */ - connect(): Promise; + connect(opts?: ConnectOptions): Promise; } export class JsonRpcError extends Error { @@ -75,4 +115,4 @@ export { type JsonRpcResponse, type StatelessResponse } from './stateless'; -export { connectFor } from './select'; +export { connectFor, isStateless } from './select'; diff --git a/src/connection/sdk-client.ts b/src/connection/sdk-client.ts index eebbd9b..3556ca7 100644 --- a/src/connection/sdk-client.ts +++ b/src/connection/sdk-client.ts @@ -9,30 +9,36 @@ import { ProgressNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +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; } /** - * Create and connect an MCP client to a server + * 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`. */ export async function connectToServer( - serverUrl: string + serverUrl: string, + opts: ConnectOptions = {} ): Promise { - const client = new Client( - { - name: 'conformance-test-client', - version: '1.0.0' - }, - { - capabilities: { - // Client capabilities - sampling: {}, - elicitation: {} - } - } - ); + const client = new Client(opts.clientInfo ?? DEFAULT_CLIENT_INFO, { + capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES + }); const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); diff --git a/src/connection/select.ts b/src/connection/select.ts index aa6fe17..592461c 100644 --- a/src/connection/select.ts +++ b/src/connection/select.ts @@ -3,13 +3,15 @@ import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types'; -import type { Connection } from './index'; +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. + * 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', @@ -40,10 +42,22 @@ export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] = export function connectFor( specVersion: SpecVersion -): (serverUrl: string) => Promise { +): (serverUrl: string, opts?: ConnectOptions) => Promise { return isStatefulVersion(specVersion) ? connectStateful : // Pass the version through so stateless requests declare the spec // version the run was invoked with (matters under --force). - (serverUrl) => connectStateless(serverUrl, 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 index 2602ddd..0fa9e20 100644 --- a/src/connection/stateful.ts +++ b/src/connection/stateful.ts @@ -15,10 +15,13 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { connectToServer } from './sdk-client'; import type { JSONRPCNotification } from '../spec-types/2025-11-25'; -import { JsonRpcError, type Connection } from './index'; +import { JsonRpcError, type Connection, type ConnectOptions } from './index'; -export async function connectStateful(serverUrl: string): Promise { - const { client, close } = await connectToServer(serverUrl); +export async function connectStateful( + serverUrl: string, + opts: ConnectOptions = {} +): Promise { + const { client, close } = await connectToServer(serverUrl, opts); const notifications: JSONRPCNotification[] = []; const collect = (n: unknown) => { @@ -45,10 +48,30 @@ export async function connectStateful(serverUrl: string): Promise { 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 = {} + 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 { return (await client.request({ method, params }, ResultSchema)) as R; } catch (e) { diff --git a/src/connection/stateless.ts b/src/connection/stateless.ts index 39d643d..9ed7dac 100644 --- a/src/connection/stateless.ts +++ b/src/connection/stateless.ts @@ -22,7 +22,7 @@ import { DRAFT_PROTOCOL_VERSION, type SpecVersion } from '../types'; import type { JSONRPCNotification } from '../spec-types/2025-11-25'; -import { JsonRpcError, type Connection } from './index'; +import { JsonRpcError, type Connection, type ConnectOptions } from './index'; export interface JsonRpcResponse { jsonrpc: '2.0'; @@ -58,7 +58,9 @@ let nextRequestId = 1; /** * The `Mcp-Name` source field per SEP-2243: `params.name` for tools/call and - * prompts/get, `params.uri` for resources/read; absent otherwise. + * 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, @@ -70,6 +72,13 @@ export function mcpNameForRequest( 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; } @@ -298,21 +307,52 @@ export async function sendStatelessRequest( * `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 + 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; - async function request( - method: string, + // 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 - ): Promise { - const response = await sendStatelessRequest(serverUrl, method, params, { - specVersion - }); + ): 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)) { @@ -324,7 +364,9 @@ export async function connectStateless( ); } } + } + 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 @@ -350,8 +392,31 @@ export async function connectStateless( 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 index 92468f6..f9177d1 100644 --- a/src/connection/testing.ts +++ b/src/connection/testing.ts @@ -1,6 +1,6 @@ import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; import { connectFor } from './select'; -import type { RunContext } from './index'; +import type { ConnectOptions, RunContext } from './index'; /** * Build a RunContext for unit tests that drive a scenario directly. @@ -14,6 +14,6 @@ export function testContext( return { serverUrl, specVersion, - connect: () => connectFor(specVersion)(serverUrl) + connect: (opts?: ConnectOptions) => connectFor(specVersion)(serverUrl, opts) }; } diff --git a/src/runner/server.test.ts b/src/runner/server.test.ts index 9351872..f2a2faa 100644 --- a/src/runner/server.test.ts +++ b/src/runner/server.test.ts @@ -3,7 +3,9 @@ * skipping (an explicitly-requested version outside a scenario's window * skips rather than silently testing something else; --force overrides). */ -import { describe, test, expect } from 'vitest'; +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'; @@ -50,3 +52,92 @@ describe('runServerConformanceTest spec-version applicability', () => { expect(result.checks.length).toBeGreaterThan(0); }, 60000); }); + +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 a6c66d4..d19f340 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -74,10 +74,12 @@ export async function runServerConformanceTest( // 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. + // 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 ?? - ('introducedIn' in scenario.source && + ('extensionId' in scenario.source || scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION ? DRAFT_PROTOCOL_VERSION : LATEST_SPEC_VERSION); @@ -89,7 +91,7 @@ export async function runServerConformanceTest( const ctx: RunContext = { serverUrl, specVersion: resolvedSpecVersion, - connect: () => connectFor(resolvedSpecVersion)(serverUrl) + connect: (opts) => connectFor(resolvedSpecVersion)(serverUrl, opts) }; const checks = await scenario.run(ctx); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index c9857ec..6769b60 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -86,6 +86,17 @@ import { 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 @@ -122,7 +133,22 @@ const pendingClientScenariosList: ClientScenario[] = [ // Pending until the everything-server fully implements SEP-2243 // header validation (case-insensitive names, whitespace trimming, -32020 error code) new HttpHeaderValidationScenario(), - new HttpCustomHeaderServerValidationScenario() + 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() ]; // All client scenarios @@ -189,6 +215,19 @@ const allClientScenariosList: ClientScenario[] = [ 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(), diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts index 57ca059..840c592 100644 --- a/src/scenarios/server/negative-mrtr.test.ts +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -2,7 +2,7 @@ import { testContext } from '../../connection/testing'; /** * SEP-2322 MRTR negative tests. * - * Positive tests run via all-scenarios.test.ts against the everything-server + * 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. */ diff --git a/src/scenarios/server/tasks/capability.ts b/src/scenarios/server/tasks/capability.ts new file mode 100644 index 0000000..d807b70 --- /dev/null +++ b/src/scenarios/server/tasks/capability.ts @@ -0,0 +1,275 @@ +/** + * 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 { 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 -32003 (Missing Required + // Client Capability) 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-32003'; + const name = 'TasksMethodsGatedWithoutExtension'; + const description = + 'tasks/get, tasks/update, tasks/cancel return -32003 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 !== -32003) { + errs.push( + `${tc.method} MUST return -32003; 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..a8bf60a --- /dev/null +++ b/src/scenarios/server/tasks/headers.ts @@ -0,0 +1,316 @@ +/** + * 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 `-32001 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 → -32001). + * + * 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 { 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 = -32001; + +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 \`-32001\` +(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 -32001 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 -32001 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 -32001 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 -32001 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 -32001 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 -32001 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.ts b/src/scenarios/server/tasks/lifecycle.ts new file mode 100644 index 0000000..adf2fdb --- /dev/null +++ b/src/scenarios/server/tasks/lifecycle.ts @@ -0,0 +1,534 @@ +/** + * 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, skipCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID, waitForTerminal } from './helpers'; +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( + skipCheck(id, name, description, 'no task created', [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( + skipCheck(id, name, description, 'no task created', [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..a5ed87c --- /dev/null +++ b/src/scenarios/server/tasks/mrtr-helpers.ts @@ -0,0 +1,107 @@ +/** + * 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). */ +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..f33065c --- /dev/null +++ b/src/scenarios/server/tasks/required-task-error.ts @@ -0,0 +1,221 @@ +/** + * SEP-2663 Tasks Extension — required-task error conformance. + * + * SEP-2575 (Stateless MCP) §"Missing Required Capabilities" defines + * JSON-RPC error code `-32003` 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 `-32003`. 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: -32003` 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 { SEP_2575_REF, SEP_2663_REF } from './mrtr-helpers'; +import { errMsg } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID } from './helpers'; + +const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; +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 -32003 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 \`-32003\` (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 \`-32003\`. + +**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 -32003. + const id = 'sep-2663-server-returns-32003-when-required'; + const name = 'Sep2663ServerReturns32003WhenRequired'; + const description = `tools/call for a TaskSupport=required tool MUST reject with code -32003 (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 -32003 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-32003-data-shape'; + const name2 = 'Sep2663ServerReturns32003DataShape'; + const description2 = `Error data for -32003 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 -32003 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..e87d5b1 --- /dev/null +++ b/src/scenarios/server/tasks/wire-fields.ts @@ -0,0 +1,242 @@ +/** + * 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, skipCheck } from './mrtr-helpers'; +import { TASKS_EXTENSION_ID, waitForTerminal } from './helpers'; + +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( + skipCheck(id, name, description, 'no task created', [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( + skipCheck(id, name, description, 'no task created', [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/seps/sep-2663.yaml b/src/seps/sep-2663.yaml new file mode 100644 index 0000000..c1acc1b --- /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 -32003 ..., + # 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-32003-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 -32003 (Missing Required Client Capability).' + + - check: sep-2663-server-returns-32003-data-shape + text: 'The -32003 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-32003-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-32003 + 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/types.ts b/src/types.ts index ed44f66..0a5d3a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,7 +88,8 @@ export type ScenarioSpecTag = SpecVersion | 'extension'; */ export const EXTENSION_IDS = [ 'io.modelcontextprotocol/oauth-client-credentials', - 'io.modelcontextprotocol/enterprise-managed-authorization' + 'io.modelcontextprotocol/enterprise-managed-authorization', + 'io.modelcontextprotocol/tasks' ] as const; export type ExtensionId = (typeof EXTENSION_IDS)[number]; From bb11cff34866c13aa643a80205572f493421edc1 Mon Sep 17 00:00:00 2001 From: Sri Panyam Date: Mon, 22 Jun 2026 03:55:23 -0700 Subject: [PATCH 165/226] fix(sep-2663): use renumbered draft error-code constants in tasks scenarios (#356) The tasks extension scenarios hardcoded -32003 (Missing Required Client Capability) and -32001 (Header Mismatch). The draft spec types renumbered these to -32021 and -32020 per modelcontextprotocol#2907, so a server built against current draft.ts now returns the new codes and these checks would produce false failures. Import MISSING_REQUIRED_CLIENT_CAPABILITY and HEADER_MISMATCH from spec-types/draft instead of hardcoding, matching the existing http-standard-headers scenario. The checks now assert whatever the schema currently defines, so a future renumber propagates automatically. Also drop the now-stale code number from the affected check-ID slugs (for example sep-2663-server-returns-32003-when-required becomes sep-2663-server-returns-missing-capability-when-required) and update the sep-2663.yaml requirement text to -32021, keeping it consistent with the already-updated sep-2575 and sep-2243 manifests. Scenarios are still in the pending list so default CI is unaffected. The traceability.json manifest already predates the upstream renumber and is refreshed separately from a full suite run. --- src/scenarios/server/tasks/capability.ts | 21 ++++----- src/scenarios/server/tasks/headers.ts | 25 +++++------ .../server/tasks/required-task-error.ts | 43 ++++++++++--------- src/seps/sep-2663.yaml | 14 +++--- 4 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/scenarios/server/tasks/capability.ts b/src/scenarios/server/tasks/capability.ts index d807b70..2a0bcda 100644 --- a/src/scenarios/server/tasks/capability.ts +++ b/src/scenarios/server/tasks/capability.ts @@ -12,6 +12,7 @@ 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'; @@ -117,16 +118,16 @@ export class TasksCapabilityNegotiationScenario implements ClientScenario { }); } - // Check 2: tasks/* methods rejected with -32003 (Missing Required - // Client Capability) 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. + // 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-32003'; + const id = 'sep-2663-tasks-methods-non-declaring'; const name = 'TasksMethodsGatedWithoutExtension'; - const description = - 'tasks/get, tasks/update, tasks/cancel return -32003 when the client did not negotiate the tasks extension (SEP-2575 Missing Required Capabilities)'; + 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' } }, { @@ -141,9 +142,9 @@ export class TasksCapabilityNegotiationScenario implements ClientScenario { await withoutExt.request(tc.method, tc.params); errs.push(`${tc.method} MUST reject (it returned a result)`); } catch (e: any) { - if (e.code !== -32003) { + if (e.code !== MISSING_REQUIRED_CLIENT_CAPABILITY) { errs.push( - `${tc.method} MUST return -32003; got ${e.code ?? ''}` + `${tc.method} MUST return ${MISSING_REQUIRED_CLIENT_CAPABILITY}; got ${e.code ?? ''}` ); } } diff --git a/src/scenarios/server/tasks/headers.ts b/src/scenarios/server/tasks/headers.ts index a8bf60a..d523994 100644 --- a/src/scenarios/server/tasks/headers.ts +++ b/src/scenarios/server/tasks/headers.ts @@ -7,13 +7,13 @@ * 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 `-32001 HeaderMismatch`. + * 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 → -32001). + * header → -32020). * * Required server fixtures: * - greet — sync-only, returns "Hello, {name}!" @@ -24,11 +24,12 @@ 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 = -32001; +const HEADER_MISMATCH_ERROR_CODE = HEADER_MISMATCH; export class TasksRequestHeadersScenario implements ClientScenario { name = 'tasks-request-headers'; @@ -53,7 +54,7 @@ into the HTTP layer for routing intermediaries: 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 \`-32001\` +values trigger rejection with JSON-RPC error code \`-32020\` (HeaderMismatch) and HTTP 400. **Required server fixtures (\`tools/list\` MUST include all):** @@ -190,8 +191,7 @@ values trigger rejection with JSON-RPC error code \`-32001\` { 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 -32001 HeaderMismatch (SEP-2243 §Server Validation)'; + 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', @@ -204,8 +204,7 @@ values trigger rejection with JSON-RPC error code \`-32001\` description, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: - 'tools/call with Mcp-Method: tasks/get returned a successful response; SEP-2243 requires -32001 rejection', + 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) { @@ -227,7 +226,7 @@ values trigger rejection with JSON-RPC error code \`-32001\` description, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: `expected -32001 HeaderMismatch; got ${code ?? errMsg(error)}`, + errorMessage: `expected ${HEADER_MISMATCH_ERROR_CODE} HeaderMismatch; got ${code ?? errMsg(error)}`, specReferences: [SEP_2243_REF] }); } @@ -244,8 +243,7 @@ values trigger rejection with JSON-RPC error code \`-32001\` { 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 -32001 HeaderMismatch'; + 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, @@ -270,8 +268,7 @@ values trigger rejection with JSON-RPC error code \`-32001\` description, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: - 'tasks/get with mismatched Mcp-Name returned a result; SEP-2243 requires -32001 rejection', + 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) { @@ -293,7 +290,7 @@ values trigger rejection with JSON-RPC error code \`-32001\` description, status: 'FAILURE', timestamp: new Date().toISOString(), - errorMessage: `expected -32001 HeaderMismatch; got ${code ?? errMsg(error)}`, + errorMessage: `expected ${HEADER_MISMATCH_ERROR_CODE} HeaderMismatch; got ${code ?? errMsg(error)}`, specReferences: [SEP_2243_REF, SEP_2663_REF] }); } diff --git a/src/scenarios/server/tasks/required-task-error.ts b/src/scenarios/server/tasks/required-task-error.ts index f33065c..1da07c8 100644 --- a/src/scenarios/server/tasks/required-task-error.ts +++ b/src/scenarios/server/tasks/required-task-error.ts @@ -2,12 +2,13 @@ * SEP-2663 Tasks Extension — required-task error conformance. * * SEP-2575 (Stateless MCP) §"Missing Required Capabilities" defines - * JSON-RPC error code `-32003` 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 `-32003`. The error data + * 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. @@ -15,7 +16,7 @@ * 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: -32003` and + * 3. Expect a JSON-RPC error with `code: -32021` and * `data.requiredCapabilities.extensions["io.modelcontextprotocol/tasks"]` * present. * @@ -31,17 +32,17 @@ 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 MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; 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 -32003 error path for required-task tools when the + description = `Verify the -32021 error path for required-task tools when the client has not negotiated the io.modelcontextprotocol/tasks extension. **Server Implementation Requirements:** @@ -51,7 +52,7 @@ 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 \`-32003\` (Missing +> the server MUST return an error with the code \`-32021\` (Missing > Required Client Capability), indicating the required extension in the > error response. @@ -70,7 +71,7 @@ shape mirrors \`InitializeRequest.capabilities\`, e.g. 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 \`-32003\`. +conformant server MUST reject with \`-32021\`. **Required server fixtures (\`tools/list\` MUST include all):** - \`failing_job\` — registered with task support declared as \`required\`. @@ -102,10 +103,11 @@ conformant server MUST reject with \`-32003\`. return checks; } - // Check 1: tools/call for a required-task tool returns -32003. - const id = 'sep-2663-server-returns-32003-when-required'; - const name = 'Sep2663ServerReturns32003WhenRequired'; - const description = `tools/call for a TaskSupport=required tool MUST reject with code -32003 (Missing Required Client Capability) when the client did not declare ${TASKS_EXTENSION_ID}`; + // 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; @@ -134,7 +136,7 @@ conformant server MUST reject with \`-32003\`. 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 -32003 rejection in this case.`, + 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(() => {}); @@ -170,9 +172,9 @@ conformant server MUST reject with \`-32003\`. // 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-32003-data-shape'; - const name2 = 'Sep2663ServerReturns32003DataShape'; - const description2 = `Error data for -32003 SHOULD carry data.requiredCapabilities.extensions["${TASKS_EXTENSION_ID}"]`; + 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({ @@ -181,8 +183,7 @@ conformant server MUST reject with \`-32003\`. description: description2, status: 'INFO', timestamp: new Date().toISOString(), - errorMessage: - 'Error returned -32003 but carried no `data` field; the spec example shows requiredCapabilities, but data is SHOULD.', + 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 ( diff --git a/src/seps/sep-2663.yaml b/src/seps/sep-2663.yaml index c1acc1b..dec9dd9 100644 --- a/src/seps/sep-2663.yaml +++ b/src/seps/sep-2663.yaml @@ -11,17 +11,17 @@ requirements: - 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 -32003 ..., + # 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-32003-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 -32003 (Missing Required Client Capability).' + - 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-32003-data-shape - text: 'The -32003 error response MUST indicate the required extension in the error response payload (the spec example shows a `data.requiredCapabilities.extensions["io.modelcontextprotocol/tasks"]` shape).' + - 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 @@ -93,7 +93,7 @@ requirements: 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-32003-non-declaring + - 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 @@ -124,7 +124,7 @@ requirements: - 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-32003 + - 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 ───────────────────────────────────────────────── From af6489593ea8bb121d123c11c3a84ebce1f849e6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:44:29 +0100 Subject: [PATCH 166/226] =?UTF-8?q?fix(auth):=20make=202025-03-26=20backco?= =?UTF-8?q?mpat=20mock=20RFC=208414=20=C2=A73.3-compliant=20(#359)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): make 2025-03-26 backcompat mock RFC 8414 §3.3-compliant Drop the routePrefix from auth/2025-03-26-oauth-metadata-backcompat. The prefix caused createAuthServer to report issuer:'/oauth' while serving the metadata at the root /.well-known/oauth-authorization-server — an RFC 8414 §3.3 mismatch a conforming client must reject. The MCP server in this scenario only mounts /mcp, so the unprefixed /authorize, /token, /register routes do not collide (the sibling endpoint-fallback scenario already mounts them at root). The scenario continues to test exactly what it's meant to: 2025-03-26-spec discovery with no PRM, OAuth metadata at the root well-known path. * fix(auth): keep /oauth prefix in 2025-03-26 backcompat, override issuer instead Dropping routePrefix made the metadata-advertised endpoints land on the 2025-03-26 hardcoded fallback paths, so a client that fetches the well-known doc but ignores its body would now pass. Restore the prefix and pin the metadata issuer to the bare origin via a lazy metadataIssuer override so the mock is RFC 8414 §3.3-compliant without losing the fallback-vs-metadata distinction. --------- Co-authored-by: Paul Carleton --- src/scenarios/client/auth/helpers/createAuthServer.ts | 9 +++++++-- src/scenarios/client/auth/march-spec-backcompat.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index d7d1fc4..4dde479 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -59,8 +59,10 @@ export interface AuthServerOptions { * 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; + metadataIssuer?: string | (() => string); tokenVerifier?: MockTokenVerifier; onTokenRequest?: (requestData: { scope?: string; @@ -156,7 +158,10 @@ export function createAuthServer( }); const metadata: any = { - issuer: metadataIssuer ?? `${getAuthBaseUrl()}${routePrefix}`, + issuer: + typeof metadataIssuer === 'function' + ? metadataIssuer() + : (metadataIssuer ?? `${getAuthBaseUrl()}${routePrefix}`), authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, ...(!disableDynamicRegistration && { diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index 3e54388..48f9170 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -25,8 +25,12 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { 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, From 38c4852d8c482401cf7c3e2c5a54a430977e0b4f Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:57:58 +0100 Subject: [PATCH 167/226] chore: bump version to 0.2.0-alpha.5 (#357) Co-authored-by: Paul Carleton --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1250406..b90efef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.4", + "version": "0.2.0-alpha.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.4", + "version": "0.2.0-alpha.5", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 1d11004..938f1af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.4", + "version": "0.2.0-alpha.5", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From fa96f3aca1f7d1ece4fb69e539ef2b74e56e1f65 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Wed, 24 Jun 2026 01:59:50 +0900 Subject: [PATCH 168/226] feat: scenario option for authorization server test (#227) --- src/index.ts | 21 +++ src/runner/authorization-server.ts | 45 +++++- .../authorization-server-metadata.test.ts | 135 ++++++++++++++++++ src/schemas.ts | 18 ++- 4 files changed, 216 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index f122ccb..6612d76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { runInteractiveMode } from './runner'; import { + printAuthorizationServerResults, printAuthorizationServerSummary, runAuthorizationServerConformanceTest } from './runner/authorization-server'; @@ -514,20 +515,40 @@ program 'Run conformance tests against an authorization server implementation' ) .requiredOption('--url ', 'URL of the authorization server issuer') + .option('--scenario ', 'Test scenario to run') .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 { // Validate options with Zod const validated = AuthorizationServerOptionsSchema.parse(options); + 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 result = await runAuthorizationServerConformanceTest( + validated.url, + validated.scenario, + outputDir + ); + + const { failed } = printAuthorizationServerResults( + result.checks, + result.scenarioDescription, + verbose + ); + + process.exit(failed > 0 ? 1 : 0); + } + let scenarios: string[]; scenarios = listClientScenariosForAuthorizationServer(); if (specVersionFilter) { diff --git a/src/runner/authorization-server.ts b/src/runner/authorization-server.ts index 5b4094b..ba9d535 100644 --- a/src/runner/authorization-server.ts +++ b/src/runner/authorization-server.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { ConformanceCheck } from '../types'; import { getClientScenarioForAuthorizationServer } from '../scenarios'; -import { createResultDir } from './utils'; +import { createResultDir, formatPrettyChecks } from './utils'; export async function runAuthorizationServerConformanceTest( serverUrl: string, @@ -49,6 +49,49 @@ export async function runAuthorizationServerConformanceTest( }; } +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 } { diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index 1cf862b..1c1d6bb 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -89,3 +89,138 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { expect(check.errorMessage).toContain('code_challenge_methods_supported'); }); }); + +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(); + } + }); +}); + +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/schemas.ts b/src/schemas.ts index e0df8ce..4888dd5 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({ @@ -40,7 +44,17 @@ export type ServerOptions = z.infer; // Authorization server command options schema export const AuthorizationServerOptionsSchema = z.object({ - url: z.string().url('Invalid authorization server URL') + url: z.string().url('Invalid authorization server URL'), + scenario: z + .string() + .refine( + (scenario) => + getClientScenarioForAuthorizationServer(scenario) !== undefined, + { + error: (iss) => `Unknown scenario '${iss.input}'` + } + ) + .optional() }); export type AuthorizationServerOptions = z.infer< From 1ff5d88370c47e43bb61f26d2589a40fe75ad1db Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Tue, 23 Jun 2026 18:33:21 +0100 Subject: [PATCH 169/226] docs: sync sep-2549 traceability with spec PR #2890 (#349) Updates SEP-2549 caching traceability text to match modelcontextprotocol/modelcontextprotocol#2890: - Scope the five *-caching-hints requirements to results with resultType: "complete" (interim input_required results carry no hints). - Add two excluded client MUST NOT rows from the new Cache Key section (cache-key mismatch, multi round-trip retry results). Neither is wire-observable since a conformant client may simply never cache. No check IDs change; traceability.json will be refreshed by the scheduled traceability workflow. Claude-Session: https://claude.ai/code/session_01Gbqx5LodtAghT5fRYFxeja --- src/seps/sep-2549.yaml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/seps/sep-2549.yaml b/src/seps/sep-2549.yaml index 75f76e6..4a5c1a5 100644 --- a/src/seps/sep-2549.yaml +++ b/src/seps/sep-2549.yaml @@ -2,15 +2,15 @@ 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 returned by tools/list' + 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 returned by prompts/list' + 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 returned by resources/list' + 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 returned by resources/templates/list' + 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 returned by resources/read' + 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 @@ -42,3 +42,7 @@ requirements: 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' From 5d1b3cea3b958e4548c4b88d7b75f8d719ce1941 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:30:25 +0100 Subject: [PATCH 170/226] fix(auth): callback iss must equal metadata issuer (honor metadataIssuer override) (#361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #359 made the metadata `issuer` honor the metadataIssuer override so the 2025-03-26 backcompat scenario is RFC 8414 §3.3-compliant. The authorize callback's `iss` parameter was still derived as `getAuthBaseUrl()+routePrefix`, so when a scenario sets metadataIssuer (bare origin) but keeps a routePrefix, the callback `iss` mismatches the metadata `issuer` and an RFC 9207-conforming client correctly rejects it. Extract `resolveIssuer()` and use it for both the metadata `issuer` and the `'correct'`/`'normalized'` callback `iss` so they always match. --- .../client/auth/helpers/createAuthServer.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 4dde479..cbcbd70 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -127,6 +127,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 })); @@ -158,10 +166,7 @@ export function createAuthServer( }); const metadata: any = { - issuer: - typeof metadataIssuer === 'function' - ? metadataIssuer() - : (metadataIssuer ?? `${getAuthBaseUrl()}${routePrefix}`), + issuer: resolveIssuer(), authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`, token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`, ...(!disableDynamicRegistration && { @@ -274,16 +279,19 @@ export function createAuthServer( redirectUrl.searchParams.set('state', state); } - // ISS: Include iss parameter in redirect if configured + // 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', `${getAuthBaseUrl()}${routePrefix}`); + 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', `${getAuthBaseUrl()}${routePrefix}/`); + redirectUrl.searchParams.set('iss', `${resolveIssuer()}/`); } res.redirect(redirectUrl.toString()); From 1d197a9930fdf021090d7e706182ef9daf3ed5eb Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:44:20 +0100 Subject: [PATCH 171/226] chore: bump version to 0.2.0-alpha.6 (#362) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b90efef..a35d008 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.5", + "version": "0.2.0-alpha.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.5", + "version": "0.2.0-alpha.6", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 938f1af..571cada 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.5", + "version": "0.2.0-alpha.6", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 42fc3eb33467e21846ad15039c4ce412018bde58 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 24 Jun 2026 15:07:32 +0100 Subject: [PATCH 172/226] feat: add JSON-based settings file for authorization-server tests (supersedes #228) (#360) * feat: add JSON-based setup option to authorization server conformance test * refactor(auth): single-schema validation for --file; drop duplicate schema/merge * chore: move authorization-server settings sample to examples/ * test(auth): cover --file merge precedence and validation * fix(auth): validate --file standalone via .strict(); reject partial/typo'd files The settings file must be a complete, valid AuthorizationServerOptions on its own; CLI flags are optional overrides on top. .strict() rejects unknown keys so a typo'd field surfaces as an error instead of being silently ignored. ZodError from file parsing is reported with the file path. * chore: drop placeholder --file merge test pending CLI test pattern The test re-implemented the merge logic locally, which can drift from src/index.ts. Proper coverage needs either programmatic commander.parse() or an extracted loadAuthorizationServerOptions() helper; deferring that decision to a follow-up rather than establishing the pattern here. --------- Co-authored-by: Michito Okai --- ...authorization-server-settings.example.json | 3 ++ src/index.ts | 47 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 examples/authorization-server-settings.example.json 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/src/index.ts b/src/index.ts index 6612d76..1893423 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, @@ -41,6 +42,7 @@ import { ClientOptionsSchema, ServerOptionsSchema } from './schemas'; +import type { AuthorizationServerOptions } from './schemas'; import { loadExpectedFailures, evaluateBaseline, @@ -514,7 +516,11 @@ program .description( 'Run conformance tests against an authorization server implementation' ) - .requiredOption('--url ', 'URL of the authorization server issuer') + .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('-o, --output-dir ', 'Save results to this directory') .option( @@ -524,8 +530,43 @@ program .option('--verbose', 'Show verbose output (JSON instead of pretty print)') .action(async (options) => { try { - // Validate options with Zod - const validated = AuthorizationServerOptionsSchema.parse(options); + 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 From 1464ec0d86bb095002db008aaad9f85a6dd55cf5 Mon Sep 17 00:00:00 2001 From: Aditya Chilka <80785619+adityachilka1@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:09 +0530 Subject: [PATCH 173/226] fix: use $GITHUB_ACTION_PATH to avoid bash backslash escaping on Windows runners (#298) The ${{ github.action_path }} template expression resolves to a backslash path on Windows (D:\a\_actions\...\v0.1.14). When inlined into a bash run block, bash treats backslashes as escape sequences, mangling the path. Switching to the auto-injected $GITHUB_ACTION_PATH env var routes through shell variable expansion, which preserves the literal value. Fixes #150. Co-authored-by: aditya --- action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 3c6fb21..f3ee636 100644 --- a/action.yml +++ b/action.yml @@ -42,14 +42,14 @@ runs: - 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 }}" From 565eaffc902017060cb8bc38517af7de0f2e2adb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 24 Jun 2026 16:13:55 +0100 Subject: [PATCH 174/226] feat: add positive tests for the Authorization Code Grant (supersedes #267) (#364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add positive tests for the Authorization Code Grant * fix(auth): use import type for AuthorizationServerOptions in types.ts * fix(auth): make client-id/client-secret optional; rename --secret→--client-secret * fix(auth): generate PKCE verifier per-run; derive S256 challenge * fix(auth): build authorization URL with URLSearchParams; drop hardcoded resource * fix(auth): default token_endpoint_auth_methods_supported to client_secret_basic; add 'none' branch * fix(auth): check auth-method support before starting callback server * fix(auth): check error param first; treat state mismatch as fatal; drop fabricated code_challenge assertion * fix(auth): redact tokens from check details * fix(auth): harden callback server (path-scoped, close(), error handler, clear timeout) * feat(auth): print redirect URI and timeout hint before browser prompt --------- Co-authored-by: Michito Okai --- src/index.ts | 28 +- src/runner/authorization-server.ts | 8 +- .../auth/helpers/createCallbackServer.ts | 57 +++ .../auth/spec-references.ts | 12 + .../authorization-code-grant.test.ts | 330 +++++++++++++++ .../authorization-code-grant.ts | 376 ++++++++++++++++++ .../authorization-server-metadata.test.ts | 13 +- .../authorization-server-metadata.ts | 18 +- src/scenarios/index.ts | 4 +- src/schemas.ts | 10 +- src/types.ts | 6 +- 11 files changed, 842 insertions(+), 20 deletions(-) create mode 100644 src/scenarios/authorization-server/auth/helpers/createCallbackServer.ts create mode 100644 src/scenarios/authorization-server/auth/spec-references.ts create mode 100644 src/scenarios/authorization-server/authorization-code-grant.test.ts create mode 100644 src/scenarios/authorization-server/authorization-code-grant.ts diff --git a/src/index.ts b/src/index.ts index 1893423..0e8b14f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -522,6 +522,20 @@ program ) .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( + '-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 ', @@ -575,9 +589,11 @@ program // If a single scenario is specified, run just that one if (validated.scenario) { + const details: Record = {}; const result = await runAuthorizationServerConformanceTest( - validated.url, + validated, validated.scenario, + details, outputDir ); @@ -604,14 +620,22 @@ program ); 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.url, + validated, scenarioName, + details, outputDir ); + 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); diff --git a/src/runner/authorization-server.ts b/src/runner/authorization-server.ts index ba9d535..6d10c9e 100644 --- a/src/runner/authorization-server.ts +++ b/src/runner/authorization-server.ts @@ -3,10 +3,12 @@ import path from 'path'; import { ConformanceCheck } from '../types'; import { getClientScenarioForAuthorizationServer } from '../scenarios'; import { createResultDir, formatPrettyChecks } from './utils'; +import { AuthorizationServerOptions } from '../schemas'; export async function runAuthorizationServerConformanceTest( - serverUrl: string, + options: AuthorizationServerOptions, scenarioName: string, + details: Record, outputDir?: string ): Promise<{ checks: ConformanceCheck[]; @@ -28,10 +30,10 @@ export async function runAuthorizationServerConformanceTest( const scenario = getClientScenarioForAuthorizationServer(scenarioName)!; console.log( - `Running client scenario for authorization server '${scenarioName}' against server: ${serverUrl}` + `Running client scenario for authorization server '${scenarioName}' against server: ${options.url}` ); - const checks = await scenario.run(serverUrl); + const checks = await scenario.run(options, details); 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..6b51bf6 --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -0,0 +1,330 @@ +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 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('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..48bcdee --- /dev/null +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -0,0 +1,376 @@ +/** + * 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' + }); + + 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 + }); + 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 index 1c1d6bb..fef6fa5 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -11,6 +11,13 @@ 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, @@ -33,7 +40,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { const scenario = new AuthorizationServerMetadataEndpointScenario(); mockMetadataResponse(validMetadata); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(OPTIONS, details); expect(checks).toHaveLength(1); @@ -64,7 +71,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { response_types_supported: validMetadata.response_types_supported }); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(OPTIONS, details); expect(checks).toHaveLength(1); @@ -80,7 +87,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { code_challenge_methods_supported: ['plain'] }); - const checks = await scenario.run(SERVER_URL); + const checks = await scenario.run(OPTIONS, details); expect(checks).toHaveLength(1); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index 00c930f..cd65603 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -1,11 +1,13 @@ /** * Authorization server metadata endpoint test scenarios for MCP authorization servers */ +import { AuthorizationServerOptions } from '../../schemas'; import { ClientScenarioForAuthorizationServer, ConformanceCheck } from '../../types'; import { request } from 'undici'; +import { SpecReferences } from '../authorization-server/auth/spec-references'; type Status = 'SUCCESS' | 'FAILURE'; @@ -24,13 +26,16 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar - 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.`; - async run(serverUrl: string): Promise { + async run( + options: AuthorizationServerOptions, + _details: Record + ): Promise { let status: Status = 'SUCCESS'; let errorMessage: string | undefined; let details: any; let response: any | null = null; try { - const wellKnownUrls = this.createWellKnownUrl(serverUrl); + const wellKnownUrls = this.createWellKnownUrl(options.url); for (const url of wellKnownUrls) { try { @@ -54,7 +59,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar const body = await this.parseJson(response); const errors: string[] = []; - this.validateMetadataBody(body, serverUrl, errors); + this.validateMetadataBody(body, options.url, errors); if (errors.length > 0) { status = 'FAILURE'; @@ -78,12 +83,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar status, timestamp: new Date().toISOString(), errorMessage, - specReferences: [ - { - id: 'Authorization-Server-Metadata', - url: 'https://datatracker.ietf.org/doc/html/rfc8414' - } - ], + specReferences: [SpecReferences.MCP_AUTH_DISCOVERY], ...(details ? { details } : {}) } ]; diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 6769b60..9db6351 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -110,6 +110,7 @@ import { } 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 { @@ -274,7 +275,8 @@ export const clientScenarios = new Map( const allClientScenariosListForAuthorizationServer: ClientScenarioForAuthorizationServer[] = [ // Authorization server scenarios - new AuthorizationServerMetadataEndpointScenario() + new AuthorizationServerMetadataEndpointScenario(), + new AuthorizationCodeGrantScenario() ]; // Client scenarios map for authorization server - built from list diff --git a/src/schemas.ts b/src/schemas.ts index 4888dd5..040aa2e 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -54,7 +54,15 @@ export const AuthorizationServerOptionsSchema = z.object({ error: (iss) => `Unknown scenario '${iss.input}'` } ) - .optional() + .optional(), + clientId: z.string().min(1, 'Client id cannot be empty').optional(), + clientSecret: z.string().min(1, 'Client secret cannot be empty').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< diff --git a/src/types.ts b/src/types.ts index 0a5d3a8..4ebdd60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { RunContext } from './connection'; import type { ScenarioContext } from './mock-server'; +import type { AuthorizationServerOptions } from './schemas'; export type CheckStatus = | 'SUCCESS' @@ -140,5 +141,8 @@ export interface ClientScenarioForAuthorizationServer { name: string; description: string; source: ScenarioSource; - run(serverUrl: string): Promise; + run( + options: AuthorizationServerOptions, + details: Record + ): Promise; } From 32523cc21a344373408c622c772ba09866e58158 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 24 Jun 2026 16:53:00 +0100 Subject: [PATCH 175/226] feat: CIMD support check for authorization-server metadata (supersedes #235) (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: check for CIMD of authorization server metadata * feat(runner): support per-check spec-version gating via ConformanceCheck.source * fix(auth): CIMD is SHOULD — emit WARNING; gate via check.source; reuse SpecReferences * test(runner): matchesSpecVersion gates introducedIn:'2025-11-25' correctly --------- Co-authored-by: Takashi Norimatsu --- src/index.ts | 6 +- src/runner/authorization-server.ts | 19 +++-- .../authorization-server-metadata.test.ts | 73 ++++++++++++++++++- .../authorization-server-metadata.ts | 42 ++++++++++- src/scenarios/index.ts | 2 +- src/scenarios/spec-version.test.ts | 12 +++ src/types.ts | 5 ++ 7 files changed, 145 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0e8b14f..b82093c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -594,7 +594,8 @@ program validated, validated.scenario, details, - outputDir + outputDir, + specVersionFilter ); const { failed } = printAuthorizationServerResults( @@ -628,7 +629,8 @@ program validated, scenarioName, details, - outputDir + outputDir, + specVersionFilter ); if ( result.checks[0].status === 'SUCCESS' && diff --git a/src/runner/authorization-server.ts b/src/runner/authorization-server.ts index 6d10c9e..15d0ad5 100644 --- a/src/runner/authorization-server.ts +++ b/src/runner/authorization-server.ts @@ -1,7 +1,10 @@ import { promises as fs } from 'fs'; import path from 'path'; -import { ConformanceCheck } from '../types'; -import { getClientScenarioForAuthorizationServer } from '../scenarios'; +import { ConformanceCheck, SpecVersion } from '../types'; +import { + getClientScenarioForAuthorizationServer, + matchesSpecVersion +} from '../scenarios'; import { createResultDir, formatPrettyChecks } from './utils'; import { AuthorizationServerOptions } from '../schemas'; @@ -9,7 +12,8 @@ export async function runAuthorizationServerConformanceTest( options: AuthorizationServerOptions, scenarioName: string, details: Record, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion ): Promise<{ checks: ConformanceCheck[]; resultDir?: string; @@ -34,18 +38,23 @@ export async function runAuthorizationServerConformanceTest( ); 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(checks, null, 2) + JSON.stringify(filtered, null, 2) ); console.log(`Results saved to ${resultDir}`); } return { - checks, + checks: filtered, resultDir, scenarioDescription: scenario.description }; diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index fef6fa5..a1a5d66 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -42,7 +42,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { const checks = await scenario.run(OPTIONS, details); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(2); const check = checks[0]; expect(check.status).toBe('SUCCESS'); @@ -73,7 +73,7 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { const checks = await scenario.run(OPTIONS, details); - expect(checks).toHaveLength(1); + expect(checks).toHaveLength(2); const check = checks[0]; expect(check.status).toBe('FAILURE'); @@ -89,12 +89,79 @@ describe('AuthorizationServerMetadataEndpointScenario', () => { const checks = await scenario.run(OPTIONS, details); - expect(checks).toHaveLength(1); + 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', () => { diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index cd65603..ec5fd3a 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -3,11 +3,13 @@ */ 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'; @@ -24,7 +26,8 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar - 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.`; +- 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, @@ -34,6 +37,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar let errorMessage: string | undefined; let details: any; let response: any | null = null; + let body: Record | undefined; try { const wellKnownUrls = this.createWellKnownUrl(options.url); @@ -57,7 +61,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar this.validateContentType(response.headers['content-type']); - const body = await this.parseJson(response); + body = await this.parseJson(response); const errors: string[] = []; this.validateMetadataBody(body, options.url, errors); @@ -75,7 +79,7 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar errorMessage = error instanceof Error ? error.message : String(error); } - return [ + const checks: ConformanceCheck[] = [ { id: 'authorization-server-metadata', name: 'AuthorizationServerMetadata', @@ -87,6 +91,38 @@ export class AuthorizationServerMetadataEndpointScenario implements ClientScenar ...(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[] { diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 9db6351..db1584e 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -428,7 +428,7 @@ function versionIndex( } // Off-timeline sources (extensions etc.) are never selected by --spec-version. -function matchesSpecVersion( +export function matchesSpecVersion( source: ScenarioSource, version: SpecVersion ): boolean { diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index dc84b0e..7e5f451 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -8,6 +8,7 @@ import { listExtensionScenarios, getScenario, getScenarioSpecVersions, + matchesSpecVersion, resolveSpecVersion, ALL_SPEC_VERSIONS, scenarios, @@ -92,6 +93,17 @@ describe('specVersions helpers', () => { expect(resolveSpecVersion(LATEST_SPEC_VERSION)).toBe(LATEST_SPEC_VERSION); }); + 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)); diff --git a/src/types.ts b/src/types.ts index 4ebdd60..eaa364b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,11 @@ 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; From 4686e064e2cb3b48e14b5c45aea80c240cfd279e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:57:48 +0100 Subject: [PATCH 176/226] fix(server-sse-polling): send mcp-protocol-version 2025-11-25 (the version this scenario tests) (#366) The scenario has introducedIn: '2025-11-25' and tests SEP-1699 priming events, which were introduced in that version. Sending '2025-03-26' as the client's protocol version makes a correctly-behaving server suppress the priming event (older clients cannot handle empty-data SSE), so the scenario WARNs against a conformant server. Verified against typescript-sdk@v2-2026-07-28: with this fix the scenario goes from 'WARNING: Server did not send priming event' to '3 passed, 0 failed'. --- src/scenarios/server/sse-polling.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scenarios/server/sse-polling.ts b/src/scenarios/server/sse-polling.ts index 0f52f40..d197d3f 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -138,7 +138,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': '2025-11-25' }, body: JSON.stringify({ jsonrpc: '2.0', @@ -417,7 +417,7 @@ export class ServerSSEPollingScenario implements ClientScenario { headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26', + 'mcp-protocol-version': '2025-11-25', 'last-event-id': lastEventId } }); From 5d7abd5e70513731b9fb70393639338631eaf7ec Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:26:47 +0100 Subject: [PATCH 177/226] chore: bump version to 0.2.0-alpha.7 (#367) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a35d008..25503f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.6", + "version": "0.2.0-alpha.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.6", + "version": "0.2.0-alpha.7", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 571cada..15ef51b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.6", + "version": "0.2.0-alpha.7", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From b18aa9185012bff51996be4d9d128cb3c0f9c481 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 16:34:38 +0200 Subject: [PATCH 178/226] fix(http-custom-headers): use spec-permitted x-mcp-header types (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(http-custom-headers): use spec-permitted x-mcp-header types The http-custom-headers fixture annotated three `number`-typed properties (priority and float_val) with x-mcp-header. The SEP-2243 spec forbids this: "MUST only be applied to parameters with primitive types (integer, string, boolean). Parameters with type `number` are not permitted." A spec-conformant client MUST reject (exclude from tools/list) any tool whose x-mcp-header annotations violate the constraints, so it would drop both test tools and never call them, failing ClientSupportsCustomHeaders through no fault of its own. - priority: number -> integer (its value, 42/1, and header are unchanged). - float_val: drop the x-mcp-header annotation; a floating-point header param is exactly what the spec prohibits and has no valid replacement. It stays in the body and is now asserted NOT to be mirrored, like the unannotated query param. - Treat 'integer' like 'number' in header-value validation and check-id classification so Priority keeps its cross-SDK numeric tolerance. * Reword float_val check to the rule actually under test The new ClientCustomHeaderNoMirrorNumber check asserts absence of a header for an *unannotated* parameter — the same designated-params-only rule the query check covers. The previous description stated a 'MUST NOT mirror number-typed params' requirement that SEP-2243 does not contain (the spec forbids the annotation on number, with rejection as the consequence; it does not define a client-side type filter). Reword the description and comment to the rule actually under test, keeping the number/SEP-2243 context as the explanatory why. Also trim the float_val schema description to match the sibling query param style. --------- Co-authored-by: Paul Carleton --- src/scenarios/client/http-custom-headers.ts | 43 ++++++++++++++------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index b445dc0..8e64e6a 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -127,7 +127,7 @@ function validateEncodedHeader( 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') { + if (valueType === 'number' || valueType === 'integer') { return compareNumericValues(decoded, bodyValue); } if (decoded !== bodyValue) { @@ -137,7 +137,7 @@ function validateEncodedHeader( } // Plain ASCII - compare directly (after decoding if Base64 was used) const decoded = decodeHeaderValue(rawHeader); - if (valueType === 'number') { + if (valueType === 'number' || valueType === 'integer') { return compareNumericValues(decoded, bodyValue); } if (decoded !== bodyValue) { @@ -293,7 +293,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { 'x-mcp-header': 'Region' }, priority: { - type: 'number', + type: 'integer', description: 'Integer numeric value', 'x-mcp-header': 'Priority' }, @@ -320,8 +320,8 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { }, float_val: { type: 'number', - description: 'Floating point numeric value', - 'x-mcp-header': 'FloatVal' + description: + 'Floating point value — no x-mcp-header annotation, should not be mirrored' }, non_ascii_val: { type: 'string', @@ -393,7 +393,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { 'x-mcp-header': 'Region' }, priority: { - type: 'number', + type: 'integer', description: 'Integer numeric value', 'x-mcp-header': 'Priority' }, @@ -449,8 +449,8 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { // Check Mcp-Param-Region header (plain ASCII string) this.checkParamHeader(req, 'Region', args.region, 'string'); - // Check Mcp-Param-Priority header (integer number) - this.checkParamHeader(req, 'Priority', args.priority, 'number'); + // 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 @@ -475,10 +475,25 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { this.checkParamHeader(req, 'Method', args.method_val, 'string'); } - // Check Mcp-Param-FloatVal header (floating point number) - if (args.float_val !== undefined && args.float_val !== null) { - this.checkParamHeader(req, 'FloatVal', args.float_val, 'number'); - } + // 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) { @@ -652,7 +667,9 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { typeof bodyValue === 'string' && needsBase64Encoding(String(bodyValue)); const checkId = needsBase64 ? 'sep-2243-client-base64-unsafe' - : valueType === 'number' || valueType === 'boolean' + : valueType === 'number' || + valueType === 'integer' || + valueType === 'boolean' ? 'sep-2243-client-encode-values' : 'sep-2243-client-mirrors-designated-params'; From 977813a130f4652a9ad1711b02b635bb1d474a07 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:42:01 +0100 Subject: [PATCH 179/226] fix: fail checks whose prerequisite is missing instead of skipping them (#372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A check that cannot be exercised — the server under test lacks a diagnostic fixture tool, or advertises a feature it then rejects — was reported SKIPPED, which is excluded from pass/fail counts, exit codes, and the expected-failures baseline. A server could dodge the entire SEP-2575 capability-constraint and subscription surface (7 MUST-level checks) and still read green, and a baselined failing scenario whose checks degraded to SKIPPED flipped its baseline entry to stale, which reads as fixed. Such checks now fail with a 'Not testable:' message naming the missing prerequisite and details.untestable for tooling, per the policy in issue #248 (severity follows the requirement's keyword: MUST -> FAILURE, SHOULD -> WARNING). Legitimate skips remain: optional capabilities the server never declared, runner-level spec-version inapplicability, and the documented tasks-status-notifications harness gap. Also fixes a test-suite bug found en route: the stream mock's reader lacked releaseLock, so listenToStream's teardown threw and every stream-based assertion silently ran against zero frames. --- AGENTS.md | 1 + .../server/http-standard-headers.test.ts | 240 ++++++++++++++++++ src/scenarios/server/http-standard-headers.ts | 130 +++++++--- src/scenarios/server/stateless.test.ts | 210 ++++++++++++++- src/scenarios/server/stateless.ts | 215 +++++++++++----- src/scenarios/server/tasks/lifecycle.test.ts | 82 ++++++ src/scenarios/server/tasks/lifecycle.ts | 19 +- src/scenarios/server/tasks/mrtr-helpers.ts | 9 +- src/scenarios/server/tasks/wire-fields.ts | 19 +- src/scenarios/untestable.test.ts | 39 +++ src/scenarios/untestable.ts | 55 ++++ 11 files changed, 902 insertions(+), 117 deletions(-) create mode 100644 src/scenarios/server/http-standard-headers.test.ts create mode 100644 src/scenarios/server/tasks/lifecycle.test.ts create mode 100644 src/scenarios/untestable.test.ts create mode 100644 src/scenarios/untestable.ts diff --git a/AGENTS.md b/AGENTS.md index 5e915ff..ca88ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ Keep scenarios separate when they're genuinely independent features or when they - **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 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..6ef5efd --- /dev/null +++ b/src/scenarios/server/http-standard-headers.test.ts @@ -0,0 +1,240 @@ +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: { + 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: { + 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: { 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 index 59bd30c..d5ca032 100644 --- a/src/scenarios/server/http-standard-headers.ts +++ b/src/scenarios/server/http-standard-headers.ts @@ -26,6 +26,7 @@ import { type RunContext } from '../../connection'; import { HEADER_MISMATCH } from '../../spec-types/draft'; +import { untestableCheck } from '../untestable'; const SPEC_REFERENCE = { id: 'SEP-2243-Server-Validation', @@ -86,6 +87,45 @@ export const CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS = [ '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, @@ -294,6 +334,10 @@ export class HttpHeaderValidationScenario implements ClientScenario { 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 { @@ -417,6 +461,14 @@ export class HttpHeaderValidationScenario implements ClientScenario { '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 --- @@ -608,22 +660,18 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario }); if (!xMcpTool) { - checks.push({ - id: 'sep-2243-server-no-xmcp-tool', - name: 'HttpCustomHeaderServerNoTool', - description: + checks.push( + untestableCheck( + 'sep-2243-server-no-xmcp-tool', + 'HttpCustomHeaderServerNoTool', 'Server has no tools with x-mcp-header annotations to test', - status: 'SKIPPED', - timestamp: new Date().toISOString(), - specReferences: [SPEC_REFERENCE_CUSTOM], - details: { - reason: - 'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.' - } - }); - this.skipDeclaredChecks( + '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.' + 'server exposes no tool with x-mcp-header annotations' ); return checks; } @@ -640,18 +688,18 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario def['x-mcp-header'] !== undefined && (def as any).type === 'string' ); if (!annotatedEntry) { - checks.push({ - id: 'sep-2243-server-no-string-param', - name: 'HttpCustomHeaderServerNoStringParam', - description: + checks.push( + untestableCheck( + 'sep-2243-server-no-string-param', + 'HttpCustomHeaderServerNoStringParam', 'Server has no string-typed x-mcp-header parameter to test', - status: 'SKIPPED', - timestamp: new Date().toISOString(), - specReferences: [SPEC_REFERENCE_CUSTOM] - }); - this.skipDeclaredChecks( + '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.' + 'server exposes no string-typed x-mcp-header parameter' ); return checks; } @@ -818,7 +866,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } // Declared-but-unemitted -> FAILURE. Reached only when setup threw partway - // through (the gate-out paths emit SKIPPED rows and the happy path emits + // through (the gate-out paths emit untestable FAILURE rows and the happy path emits // every declared ID). this.failDeclaredChecks(checks); @@ -846,23 +894,27 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario } /** - * Emit one SKIPPED row per declared requirement check when the scenario - * cannot run against this server (no x-mcp-header tool). The IDs must still - * reach checks.json so the traceability manifest records that a scenario - * exists for each requirement. + * 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 skipDeclaredChecks(checks: ConformanceCheck[], reason: string): void { + 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({ - id, - name: 'NotApplicable', - description: `Declared check ${id} is not testable against this server`, - status: 'SKIPPED', - timestamp: new Date().toISOString(), - specReferences: [SPEC_REFERENCE_CUSTOM], - details: { reason } - }); + checks.push( + untestableCheck( + id, + 'NotTestable', + `Declared check ${id} is not testable against this server`, + reason, + [SPEC_REFERENCE_CUSTOM] + ) + ); } } diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index f8b93bf..6fac1e2 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -66,7 +66,13 @@ describe('Stateless Server Scenario Negative Tests', () => { } 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 { @@ -91,6 +97,24 @@ describe('Stateless Server Scenario Negative Tests', () => { 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) => { @@ -127,6 +151,190 @@ describe('Stateless Server Scenario Negative Tests', () => { 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:'); + }); + + 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 = mockFetchTarget((reqBody) => { + if (reqBody.method === 'server/discover') { + return discoverResponse(reqBody, { tools: {} }, 'no-enforcement'); + } + 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: 200, + body: { + jsonrpc: '2.0', + id: reqBody.id, + 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('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. diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index ae157b1..e20a9dd 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -12,6 +12,7 @@ import { readSseJsonRpcResponse, type RunContext } from '../../connection'; +import { notTestable } from '../untestable'; const SPEC_REF = [ { @@ -114,6 +115,10 @@ export class ServerStatelessScenario implements ClientScenario { } } + // 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, @@ -156,6 +161,7 @@ export class ServerStatelessScenario implements ClientScenario { // Response might not be JSON } } + if (data?.error !== undefined) errorResponsesObserved++; return { res, data }; }; @@ -483,6 +489,22 @@ export class ServerStatelessScenario implements ClientScenario { } ); + // 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', @@ -493,12 +515,9 @@ export class ServerStatelessScenario implements ClientScenario { return { error: `Discovery runtime check failed: ${discoverRpcError.message}` }; - const { data: toolsData } = await sendRpc( - 'tools/list', - { _meta: validMeta }, - undefined, - 202 - ); + if (!toolsListResponse) + return { error: 'tools/list probe network hit failed' }; + const toolsData = toolsListResponse.data; if (discoverCapabilities.tools) { const toolsPassed = Array.isArray(toolsData?.result?.tools); @@ -632,6 +651,9 @@ export class ServerStatelessScenario implements ClientScenario { // 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', @@ -645,14 +667,29 @@ export class ServerStatelessScenario implements ClientScenario { }; if (!serverRequiresCapability) { - // The server didn't return -32021, so this requirement isn't - // exercised for this method. Report SKIPPED rather than a green PASS. + // 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 { - skipped: true, - details: { - note: 'Skipped requirement tracking: Server returned a non-32021 response, indicating it does not require explicit client capability authorization constraints for this method.', - response: data401 - } + 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 } }; } @@ -680,14 +717,16 @@ export class ServerStatelessScenario implements ClientScenario { }; if (!serverRequiresCapability) { - // No -32021 means the HTTP-400 requirement doesn't apply here. - // Report SKIPPED rather than a green PASS. + // 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 { - skipped: true, - details: { - note: 'Skipped status tracking: Server did not return a MissingRequiredClientCapabilityError.', - httpStatus: res401.status - } + 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 } }; } @@ -795,16 +834,17 @@ export class ServerStatelessScenario implements ClientScenario { }; } - // If the call was rejected outright (e.g. the diagnostic tool does - // not exist), nothing was streamed and the requirement was not - // exercised - report SKIPPED rather than a vacuous SUCCESS. + // 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 { - skipped: true, - details: { - note: 'Server does not expose diagnostic tool test_streaming_elicitation; the response stream could not be exercised.', - response: frames[0] - } + 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] } }; } @@ -841,17 +881,17 @@ export class ServerStatelessScenario implements ClientScenario { }; } - // If the call was rejected outright (e.g. the diagnostic tool does - // not exist), the server never had anything to log and the - // requirement was not exercised - report SKIPPED rather than a - // vacuous SUCCESS. + // 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 { - skipped: true, - details: { - note: 'Server does not expose diagnostic tool test_logging_tool; the no-log-without-logLevel requirement could not be exercised.', - response: frames[0] - } + 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] } }; } @@ -872,6 +912,41 @@ export class ServerStatelessScenario implements ClientScenario { // ========================================== // 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 } @@ -904,14 +979,8 @@ export class ServerStatelessScenario implements ClientScenario { 'ServerSendsSubscriptionAck', 'notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream', () => { - if (streamFrames[0]?.error?.code === -32601) { - return { - skipped: true, - details: { - note: 'Server does not support subscriptions/listen (Method not found)' - } - }; - } + const rejectedStream = listenRejected(streamFrames); + if (rejectedStream) return rejectedStream; if (streamFrames.length === 0) { return { error: @@ -934,14 +1003,8 @@ export class ServerStatelessScenario implements ClientScenario { 'ServerTagsSubscriptionId', 'Listen-stream notifications carry _meta.../subscriptionId', () => { - if (streamFrames[0]?.error?.code === -32601) { - return { - skipped: true, - details: { - note: 'Server does not support subscriptions/listen (Method not found)' - } - }; - } + const rejectedStream = listenRejected(streamFrames); + if (rejectedStream) return rejectedStream; if (streamFrames.length === 0) { return { error: @@ -1015,14 +1078,8 @@ export class ServerStatelessScenario implements ClientScenario { } ); - if (narrowFrames[0]?.error?.code === -32601) { - return { - skipped: true, - details: { - note: 'Server does not support subscriptions/listen (Method not found)' - } - }; - } + const rejectedNarrow = listenRejected(narrowFrames); + if (rejectedNarrow) return rejectedNarrow; if (narrowFrames.length === 0) { return { error: @@ -1086,11 +1143,13 @@ export class ServerStatelessScenario implements ClientScenario { } ); if (trigger?.data?.error?.code === -32601) { + // SHOULD-level requirement, so the untestable report is a WARNING. return { - skipped: true, - details: { - note: 'Server does not expose diagnostic hook test_trigger_prompt_change to mutate lists' - } + 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 } }; } @@ -1154,11 +1213,13 @@ export class ServerStatelessScenario implements ClientScenario { } ); if (trigger?.data?.error?.code === -32601) { + // SHOULD-level requirement, so the untestable report is a WARNING. return { - skipped: true, - details: { - note: 'Server does not expose diagnostic hook test_trigger_tool_change to mutate lists' - } + 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 } }; } @@ -1185,12 +1246,26 @@ export class ServerStatelessScenario implements ClientScenario { ); 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: 'SUCCESS', + 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 }); } 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 index adf2fdb..2214ff1 100644 --- a/src/scenarios/server/tasks/lifecycle.ts +++ b/src/scenarios/server/tasks/lifecycle.ts @@ -16,8 +16,9 @@ import { ClientScenario, ConformanceCheck } from '../../../types'; import type { Connection, RunContext } from '../../../connection'; import { SEP_2322_REF, SEP_2663_REF } from './mrtr-helpers'; -import { errMsg, failureCheck, skipCheck } 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 { @@ -231,7 +232,13 @@ The server MUST advertise \`io.modelcontextprotocol/tasks\` under 'tasks/get returns status + metadata for an active task'; if (!workingTaskId) { checks.push( - skipCheck(id, name, description, 'no task created', [SEP_2663_REF]) + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) ); } else { try { @@ -271,7 +278,13 @@ The server MUST advertise \`io.modelcontextprotocol/tasks\` under 'Completed task tasks/get inlines result with content[] (no separate tasks/result method)'; if (!workingTaskId) { checks.push( - skipCheck(id, name, description, 'no task created', [SEP_2663_REF]) + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) ); } else { try { diff --git a/src/scenarios/server/tasks/mrtr-helpers.ts b/src/scenarios/server/tasks/mrtr-helpers.ts index a5ed87c..f7da185 100644 --- a/src/scenarios/server/tasks/mrtr-helpers.ts +++ b/src/scenarios/server/tasks/mrtr-helpers.ts @@ -38,7 +38,14 @@ export function failureCheck( }; } -/** Build a SKIPPED check (preserves id stability so Ctrl+F still finds it). */ +/** + * 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, diff --git a/src/scenarios/server/tasks/wire-fields.ts b/src/scenarios/server/tasks/wire-fields.ts index e87d5b1..73684a1 100644 --- a/src/scenarios/server/tasks/wire-fields.ts +++ b/src/scenarios/server/tasks/wire-fields.ts @@ -13,8 +13,9 @@ import { ClientScenario, ConformanceCheck } from '../../../types'; import type { Connection, RunContext } from '../../../connection'; import { SEP_2663_REF } from './mrtr-helpers'; -import { errMsg, failureCheck, skipCheck } 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'; @@ -155,7 +156,13 @@ export class TasksWireFieldsScenario implements ClientScenario { 'Task remains accessible via tasks/get for the duration of its ttlMs'; if (!createdTaskId) { checks.push( - skipCheck(id, name, description, 'no task created', [SEP_2663_REF]) + untestableCheck( + id, + name, + description, + 'no task was created by the preceding step, so this check could not be exercised', + [SEP_2663_REF] + ) ); } else { try { @@ -204,7 +211,13 @@ export class TasksWireFieldsScenario implements ClientScenario { const taskId = created.taskId; if (!taskId) { checks.push( - skipCheck(id, name, description, 'no task created', [SEP_2663_REF]) + 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); 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 } + }; +} From 4944b268062f3c9cbde876e50845e974f59b6a72 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 30 Jun 2026 11:50:37 +0100 Subject: [PATCH 180/226] chore: bump version to 0.2.0-alpha.8 (#373) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 25503f0..6680499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.7", + "version": "0.2.0-alpha.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.7", + "version": "0.2.0-alpha.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 15ef51b..56ebce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.7", + "version": "0.2.0-alpha.8", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 9524edcf4178032eb703309230bd85a1604016ff Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:46:14 +0100 Subject: [PATCH 181/226] fix(server-stateless): requiredCapabilities is a ClientCapabilities object, not an array (#376) The sep-2575-server-rejects-undeclared-capability check required error.data.requiredCapabilities to be an array containing 'sampling'. The schema's MissingRequiredClientCapabilityError defines it as a ClientCapabilities object ({ "sampling": {} }), as do this repo's own spec-types and the tasks/required-task-error scenario, so a spec-correct server could not pass the check. It shipped because the positive -32021 branch had no self-test. - stateless.ts: accept the object shape; each capability value must itself be an object ({ "sampling": null } fails). State the shape in the scenario's implementer-facing description. - examples/servers/typescript/everything-server.ts: emit the object. - stateless.test.ts: self-tests for object-passes / array-fails / null-value-fails, sharing one capability-probe mock helper. --- .../servers/typescript/everything-server.ts | 4 +- src/scenarios/server/stateless.test.ts | 97 ++++++++++++++++--- src/scenarios/server/stateless.ts | 14 ++- 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 52b4b6a..39be2f0 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1663,7 +1663,9 @@ app.post('/mcp', async (req, res) => { error: { code: -32021, message: 'MissingRequiredClientCapabilityError', - data: { requiredCapabilities: ['sampling'] } + // Per the schema, requiredCapabilities is a ClientCapabilities + // object keyed by the missing capability, not an array of names. + data: { requiredCapabilities: { sampling: {} } } } }); } diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index 6fac1e2..f8175d8 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -186,13 +186,16 @@ describe('Stateless Server Scenario Negative Tests', () => { expect(statusCheck?.errorMessage).toContain('Not testable:'); }); - 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 = mockFetchTarget((reqBody) => { + // 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: {} }, 'no-enforcement'); + return discoverResponse(reqBody, { tools: {} }, label); } if (reqBody.method === 'tools/list') { return { @@ -209,15 +212,29 @@ describe('Stateless Server Scenario Negative Tests', () => { reqBody.params?.name === 'test_missing_capability' ) { return { - status: 200, - body: { - jsonrpc: '2.0', - id: reqBody.id, - result: { resultType: 'complete', content: [] } - } + 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)); @@ -230,6 +247,62 @@ describe('Stateless Server Scenario Negative Tests', () => { 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') { diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index e20a9dd..917e343 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -43,7 +43,7 @@ export class ServerStatelessScenario implements ClientScenario { - 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\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`. + - 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. @@ -693,11 +693,17 @@ export class ServerStatelessScenario implements ClientScenario { }; } - // If it DOES return -32021, strictly validate the requirement payload structure + // 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; - if (!Array.isArray(reqCaps) || !reqCaps.includes('sampling')) { + 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 failed to provide an array containing the expected 'sampling' capability in error.data.requiredCapabilities`, + 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 } }; } From 794dcab99ed1ef2b89607be9999574140ea5c96e Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:09:47 +0100 Subject: [PATCH 182/226] chore: bump version to 0.2.0-alpha.9 (#377) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6680499..3701aab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.8", + "version": "0.2.0-alpha.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.8", + "version": "0.2.0-alpha.9", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 56ebce6..7deb70d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.8", + "version": "0.2.0-alpha.9", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 1ca3bc301b9c261fd30a382dfcc4a5029c873955 Mon Sep 17 00:00:00 2001 From: Robin Otto Date: Thu, 2 Jul 2026 15:13:14 +0200 Subject: [PATCH 183/226] feat(auth): client conformance for Workload Identity Federation (SEP-1933) (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933) Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable, tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario (PR #2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates cross-app-access.ts to use it. Co-Authored-By: Claude Sonnet 4.6 * feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933) Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable, tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario (PR #2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates cross-app-access.ts to use it. Co-Authored-By: Claude Sonnet 4.6 * feat(auth): add WIF JWT-bearer scenario and negative tests (SEP-1933) Adds the auth/wif-jwt-bearer client conformance scenario using the RFC 7523 JWT-bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer). The scenario pre-signs valid, wrong-audience, and expired JWTs on start() to simulate cloud workload identity tokens. The conformance AS verifies the assertion and emits per-class checks (wif-assertion-verified, wif-assertion-missing, wif-assertion-audience, wif-assertion-expired, wif-assertion-malformed). Broken example clients exercise the missing-assertion and wrong-audience failure paths. Co-Authored-By: Claude Sonnet 4.6 * style: apply prettier formatting to wif-jwt-bearer.ts Co-Authored-By: Claude Sonnet 4.6 * fix(auth): add expired-assertion negative test; clarify wif-jwt-bearer comments - Add runWifJwtBearerExpiredAssertion + auth-test-wif-expired-assertion.ts to exercise the wif-assertion-expired check path (was dead code) - Clarify WifAssertionVerified description to note iss is not validated - Add comment explaining clockTolerance: 5 is intentional (same-run keypair) - Add comment explaining numeric expiresIn is absolute epoch seconds Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): disable DCR, detect retries, improve getChecks description - Add WIF_CLIENT_ID constant; pre-seed provider's clientInformation() so the SDK skips Dynamic Client Registration entirely (disableDynamicRegistration on the auth server + pre-seeded client_id on the provider side) - Add failedOnce/tokenRequestReceived tracking on the scenario; reject and record wif-no-retry FAILURE if the client attempts a second token request after the first fails - Add hasAttempted guard in WifJwtBearerProvider.prepareTokenRequest() to throw on retry from the client side - Fix getChecks() sentinel description: distinguish "no request made" from "request made but verification failed" - Add client_id to context and Zod schema Co-Authored-By: Claude Sonnet 4.6 * style: prettier formatting on everything-client.ts Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): let errors propagate from broken example clients The try/catch blocks were silently swallowing auth failures, so the negative tests passed purely because expectedFailureSlugs found the AS-emitted check — not because client error-surfacing was verified. allowClientError: true on the test cases handles the non-zero exit. Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): tag as draft, add scope-rejected check, add SEP traceability - Change specVersions from ['extension'] to [DRAFT_PROTOCOL_VERSION] and add source = { introducedIn: DRAFT_PROTOCOL_VERSION } so the scenario is reachable via --spec-version draft (extension tag excluded it from all spec-version runs) - Move registration from extensionScenariosList to draftScenariosList in index.ts to match the tag/list convention enforced by spec-version.test.ts - Add wif-assertion-scope-rejected check: AS returns invalid_scope for a valid JWT when the client requests the reserved 'wif.rejected' scope; verify client surfaces the error and does not retry - Add runWifJwtBearerScopeRejected to everything-client.ts; add optional scope param to WifJwtBearerProvider; add CLI entry point and vitest case - Add src/seps/sep-1933.yaml with requirements mapped to each check ID, covering the three deferred checks (iss, sub, jti) with exclusion rationale Co-Authored-By: Claude Sonnet 4.6 * style: prettier formatting Co-Authored-By: Claude Sonnet 4.6 * feat(wif-jwt-bearer): add wif-grant-fallback check for unauthorized_client fallback Client bug class: WIF client receives unauthorized_client from JWT-bearer grant and silently switches to authorization_code instead of surfacing the error. The MCP SDK retries after UnauthorizedClientError (auth.js:152-154), calling prepareTokenRequest() a second time. WifGrantFallbackProvider exploits this by returning authorization_code params on the second call. Spec anchor: RFC 7523 §2.1 — clients MUST use the JWT-bearer grant type; silent grant-type switching hides authentication failures. Co-Authored-By: Claude Sonnet 4.6 * style: rename unused scope param in WifGrantFallbackProvider Co-Authored-By: Claude Sonnet 4.6 * feat(wif-jwt-bearer): use realistic SPIFFE and K8s PSAT token formats valid_jwt uses a SPIFFE JWT-SVID subject (spiffe://conformance-test.local/...) issued by a SPIRE-style issuer. wrong_audience_jwt uses a Kubernetes PSAT subject and kubernetes.io claims, mirroring how K8s projected service-account tokens look in production. Token verification and client behaviour are unchanged; the formats ground the scenario in real workload identity platforms. k8s_issuer and k8s_subject added to context and schema for external clients that want to construct their own K8s-style assertions. Co-Authored-By: Claude Sonnet 4.6 * style: prettier formatting on wif-jwt-bearer.ts Co-Authored-By: Claude Sonnet 4.6 * chore(wif-jwt-bearer): drop informational k8s_issuer/k8s_subject from context These fields were not used by any client handler and are redundant since the K8s claims are already baked into wrong_audience_jwt. Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): address pre-merge review feedback - Downgrade wif-no-retry, wif-grant-fallback, and wif-assertion-scope-rejected to WARNING; RFC 7523 is silent on client retry and grant-type switching, so FAILURE status was not traceable to spec text - Add WifRetryProvider broken client that re-sends JWT-bearer after unauthorized_client, making wif-no-retry actually exercisable as a WARNING - Drop getChecks() sentinel (non-standard pattern that overloaded the wif-assertion-verified check ID); test harness detects empty check sets - Add comment on DRAFT_PROTOCOL_VERSION workaround for runner limitation - Add comment explaining why both slash/no-slash audience forms are accepted - Tighten schema comments for audience URL constraint and ES256 literal Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): drop decorative fields and neutralise JWT fixtures - Remove issuer and subject from context schema and scenario return value; neither is validated by the AS or used by the client, making them misleading. The iss exclusion rationale is updated to explain why. - Replace SPIFFE/K8s-PSAT constants and the kubernetes.io additionalClaims with neutral values across all three JWT fixtures; no check inspects token format, so format flavour was decorative and invited false assumptions about fixture semantics. Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): remove workaround comment, drop signing_algorithm, annotate retry provider - Remove self-admitted workaround comment on DRAFT_PROTOCOL_VERSION; the classification stands on its own and the comment was inviting challenge - Drop signing_algorithm from context schema and scenario return; the field was redundant once the value was fixed to ES256, and the assertion is opaque to the client regardless - Add comment to WifRetryProvider explaining it deliberately omits the hasAttempted guard so the SDK retry reaches the AS Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): export scope constants, remove unused AS field, annotate state machine - Export WIF_TRIGGER_UNAUTHORIZED_SCOPE and WIF_REJECTED_SCOPE from createWorkloadJwt.ts and import them in everything-client.ts; removes duplicate bare-string declarations that could silently diverge - Remove tokenEndpointAuthSigningAlgValuesSupported from mock AS config; the SDK does not consume this field in the JWT-bearer flow - Add comment to onTokenRequest clarifying wif-no-retry and wif-grant-fallback fire on any second request after any failure, not only post-unauthorized_client Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): address pcarleton review comments - Move all broken WIF provider classes and runners to wif-broken-clients.ts; everything-client.ts now contains only the conformant WifJwtBearerProvider - Add sentinel in getChecks() for !tokenRequestReceived to prevent a silent pass when the client never sends a request - Drop wif-assertion-scope-rejected check; the no-retry behaviour after invalid_scope is already covered by wif-no-retry, and sep-1933.yaml updated accordingly - Remove audience from context schema and scenario return; the field was unused by any client implementation - Expand scenario description to tell a client implementer what to implement - Apply suggested description fix to wif-grant-fallback (does not assume unauthorized_client was the specific error that preceded the fallback) Co-Authored-By: Claude Sonnet 4.6 * fix(wif-jwt-bearer): drop number type from expiresIn, use jose duration strings Jose supports negative duration strings ('-60s', '60 seconds ago') that produce already-expired tokens without needing absolute epoch arithmetic. Drop the number overload, update the expired_jwt call site to '-60s', and update the unit test accordingly. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../auth-test-wif-expired-assertion.ts | 15 + .../auth-test-wif-grant-fallback.ts | 15 + .../typescript/auth-test-wif-no-assertion.ts | 15 + .../clients/typescript/auth-test-wif-retry.ts | 15 + .../auth-test-wif-scope-rejected.ts | 15 + .../auth-test-wif-wrong-audience.ts | 15 + .../clients/typescript/everything-client.ts | 108 ++++++ .../clients/typescript/wif-broken-clients.ts | 303 +++++++++++++++++ .../auth/enterprise-managed-authorization.ts | 5 +- .../auth/helpers/createWorkloadJwt.test.ts | 194 +++++++++++ .../client/auth/helpers/createWorkloadJwt.ts | 86 +++++ src/scenarios/client/auth/index.test.ts | 76 ++++- src/scenarios/client/auth/index.ts | 4 +- src/scenarios/client/auth/spec-references.ts | 4 + src/scenarios/client/auth/wif-jwt-bearer.ts | 321 ++++++++++++++++++ src/schemas/context.ts | 7 + src/seps/sep-1933.yaml | 35 ++ 17 files changed, 1229 insertions(+), 4 deletions(-) create mode 100644 examples/clients/typescript/auth-test-wif-expired-assertion.ts create mode 100644 examples/clients/typescript/auth-test-wif-grant-fallback.ts create mode 100644 examples/clients/typescript/auth-test-wif-no-assertion.ts create mode 100644 examples/clients/typescript/auth-test-wif-retry.ts create mode 100644 examples/clients/typescript/auth-test-wif-scope-rejected.ts create mode 100644 examples/clients/typescript/auth-test-wif-wrong-audience.ts create mode 100644 examples/clients/typescript/wif-broken-clients.ts create mode 100644 src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts create mode 100644 src/scenarios/client/auth/helpers/createWorkloadJwt.ts create mode 100644 src/scenarios/client/auth/wif-jwt-bearer.ts create mode 100644 src/seps/sep-1933.yaml 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 2c9e842..ff6dd35 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -19,6 +19,13 @@ 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'; @@ -990,6 +997,107 @@ async function runMRTRClient(serverUrl: string): Promise { 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/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/src/scenarios/client/auth/enterprise-managed-authorization.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts index 2310011..eff5e25 100644 --- a/src/scenarios/client/auth/enterprise-managed-authorization.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -4,6 +4,7 @@ import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; 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'; @@ -88,7 +89,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { // Start auth server with JWT bearer grant support only // Token exchange is handled by IdP const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { - grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + grantTypesSupported: [JWT_BEARER_GRANT_TYPE], tokenEndpointAuthMethodsSupported: ['client_secret_basic'], tokenVerifier, onTokenRequest: async ({ @@ -99,7 +100,7 @@ export class EnterpriseManagedAuthorizationScenario 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, 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/index.test.ts b/src/scenarios/client/auth/index.test.ts index e4b2fe5..fbb50a2 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -1,13 +1,22 @@ import { authScenariosList, backcompatScenariosList, - draftScenariosList + 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'; @@ -265,3 +274,68 @@ describe('Negative tests', () => { }); }); }); + +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 + }); + }); +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index f29c972..11e6358 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -24,6 +24,7 @@ import { import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization'; +import { WifJwtBearerScenario } from './wif-jwt-bearer'; import { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario @@ -79,5 +80,6 @@ export const draftScenariosList: Scenario[] = [ new IssParameterWrongIssuerScenario(), new IssParameterUnexpectedScenario(), new IssParameterNormalizedVariantScenario(), - new MetadataIssuerMismatchScenario() + new MetadataIssuerMismatchScenario(), + new WifJwtBearerScenario() ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 681c3e4..0cc0e07 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -109,5 +109,9 @@ export const SpecReferences: { [key: string]: SpecReference } = { 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' } }; 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..5c65fba --- /dev/null +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -0,0 +1,321 @@ +import * as jose from 'jose'; +import type { + Scenario, + ConformanceCheck, + ScenarioUrls, + SpecVersion +} from '../../../types'; +import type { ScenarioContext } from '../../../mock-server'; +import { DRAFT_PROTOCOL_VERSION } from '../../../types'; +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'; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } 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/schemas/context.ts b/src/schemas/context.ts index 12c72af..cea338a 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -31,6 +31,13 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [ 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/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.' From 5ad96dbfbc09767b56538fed7f72d983149a0472 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:56:28 +0100 Subject: [PATCH 184/226] fix(client): accept the draft protocol version the json-schema-ref-no-deref mock advertises (#398) The scenario's hand-rolled server/discover handler advertises supportedVersions: ['2026-07-28'], but every other method is delegated to StreamableHTTPServerTransport from the pinned SDK (^1.29.0), whose version whitelist tops out at 2025-11-25. A SEP-2575 client that honors the negotiated version then gets HTTP 400 on tools/list, and the sep-2106-no-network-ref-deref check can never run. Extend the existing server/discover workaround to the version check: rewrite an incoming MCP-Protocol-Version: 2026-07-28 header to the SDK's LATEST_PROTOCOL_VERSION before delegating (patching rawHeaders too, since the SDK's Node adapter rebuilds its web-standard Request from rawHeaders). Add a stateless-draft-client regression test. Fixes #397 Claude-Session: https://claude.ai/code/session_01N4TdyLB13XSsPwNScYHf4C Co-authored-by: Claude --- .../client/json-schema-ref-deref.test.ts | 39 +++++++++++++++++++ src/scenarios/client/json-schema-ref-deref.ts | 20 +++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/scenarios/client/json-schema-ref-deref.test.ts b/src/scenarios/client/json-schema-ref-deref.test.ts index a309193..8445f1b 100644 --- a/src/scenarios/client/json-schema-ref-deref.test.ts +++ b/src/scenarios/client/json-schema-ref-deref.test.ts @@ -8,6 +8,8 @@ import { } 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 @@ -71,6 +73,36 @@ async function dereferencingClient(serverUrl: string): Promise { 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(); @@ -83,6 +115,13 @@ describe('json-schema-ref-no-deref (SEP-2106)', () => { ); }); + 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), diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts index ebf1367..aff791d 100644 --- a/src/scenarios/client/json-schema-ref-deref.ts +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -1,7 +1,10 @@ import type { ScenarioContext } from '../../mock-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 { + 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'; @@ -132,6 +135,21 @@ The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at } }); } + // 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(), () => { From 6e726734f57f3fcdf8dc66485b03e1b95bb22030 Mon Sep 17 00:00:00 2001 From: PieterKas <90690777+PieterKas@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:13:02 +0100 Subject: [PATCH 185/226] Client Auth: DPoP Sender-Constrained Tokens (SEP-1932) (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add DPoP client conformance scenario + shared DPoP helpers (SEP-1932, #368) Client-anchored packaging: carries the shared DPoP foundation (dpopProof / dpopToken helpers, the createAuthServer DPoP core, and sep-1932.yaml) together with the client scenario, so it can merge first; the server (#369) and authorization-server (#370) scenarios are follow-ups that depend on it. DPoP is treated as a draft/not-yet-official feature (mirroring the WIF SEP-1933 scenario): the scenario's source is `introducedIn: DRAFT_PROTOCOL_VERSION` and it is registered in draftScenariosList (informational, not scored). It is NOT registered in EXTENSION_IDS, since the ext-auth DPoP extension is still on a branch rather than merged. Client checks (the two extension MUSTs, RFC 9449 §7.1 / §4.2-4.3): - sep-1932-client-dpop-auth-scheme — token presented with the DPoP scheme. - sep-1932-client-fresh-proof — a fresh, well-formed DPoP proof per request. The test MCP server judges the client via an opt-in DPoP middleware (dpopResourceAuth) passed through createServer's authMiddleware hook; a compliant example client (covered by the Client Draft Scenarios loop) plus deliberately- broken bearer/replay variants drive the pass/fail acceptance tests. Co-Authored-By: Claude Opus 4.8 * client/dpop: split into nonce-less baseline + nonce-required scenario Server-provided nonces are optional in RFC 9449 (AS §8 "MAY", RS §9 "can also choose"), and the nonce-less flow is the common case. The single auth/dpop scenario hard-wired nonce enforcement on both the test AS and MCP server, so a plain nonce-less proof was never accepted end-to-end and a client that only does plain proofs was never exercised on its happy path. Parameterize DPoPClientScenario with `requireNonce` and register it twice: - auth/dpop (requireNonce=false) — nonce-less baseline; emits the three baseline checks (token-request-proof, dpop-auth-scheme, fresh-proof). - auth/dpop-nonce (requireNonce=true) — prior behavior; adds as-nonce and rs-nonce (five checks). Check IDs are reused (no new IDs, no sep-1932.yaml change); the two nonce checks are emitted only by auth/dpop-nonce, so traceability stays intact. Add auth-test-dpop-no-nonce.ts (nonce-incapable but otherwise compliant) asserting SUCCESS against auth/dpop — proof that a client with no nonce handling still completes DPoP when the server does not require a nonce. Baseline negatives stay on auth/dpop (nonce-independent); the two nonce negatives are re-pointed to auth/dpop-nonce. Co-Authored-By: Claude Opus 4.8 * client/dpop: round-4 nonce-surface correctness fixes Address review findings on the auth/dpop-nonce posture: - Record the token-request proof observation BEFORE the §8 nonce challenge, so a client that is challenged but does not retry is no longer mis-reported by token-request-proof as "never completed a token request" (its proof was just validated). (#2) - Record the resource-request jti/replay on ANY valid proof (before the §9 nonce gate), so fresh-proof is never asserted on zero evidence and a client that never honours the RS nonce fails ONLY rs-nonce. (#5) - Require challengeIssued && honored for as-nonce/rs-nonce SUCCESS, closing a vacuous-pass path (a client that pre-sends the nonce is never challenged). (#3a) - Gate the AS nonce observation on the authorization_code exchange, matching recordTokenRequestProof (a refresh exchange must not satisfy §8). (#3c) - Collapse duplicate shared token-flow check IDs (token-request, pkce-*) that the §8 round-trip re-POST would otherwise emit twice. (#4) - Example client: retry the token request only on a use_dpop_nonce error code, not any 400 carrying a DPoP-Nonce header (consistent with the RS path). (#6) - Fix the DpopTokenRequestObservation docstring ("last write wins" → sticky-failure). (#7) Adds an expectedSuccessSlugs option to the client test helper and tightens the two nonce negative tests to pin the now-clean behavior (token-request-proof SUCCESS for no-as-nonce; only rs-nonce fails for no-rs-nonce). Co-Authored-By: Claude Opus 4.8 * client/dpop: round-5 fixes — dedupe masking, wording, replay decision - dedupeSharedChecks was masking failures: it kept the last occurrence of a duplicate check ID regardless of status and ran in BOTH postures, so a FAILURE on a challenged POST (or an earlier attempt in a restarted flow) was silently dropped. Replace with an exported, unit-tested collapseDuplicateChecks that prefers the MOST-SEVERE occurrence, and only apply it in the nonce posture (the baseline keeps genuinely distinct repeated attempts). (R5 #1) - Reword the token-request-proof description: it can be SUCCESS for a client that presented a valid proof but never obtained a token (challenged, no retry), so it no longer claims "obtaining a DPoP-bound access token". (R5 #2) - Reword the as-nonce/rs-nonce failure messages to cover a client that DID retry but whose retry proof was rejected (not only "did not retry"). (R5 #3) - Document the deliberate replay decision: recording jti on challenged requests means re-sending the identical proof across the challenge/retry boundary trips replay detection — a genuine RFC 9449 §4.2 jti-uniqueness violation. (R5 #5) Adds src/scenarios/client/auth/dpop.test.ts unit-testing collapseDuplicateChecks (prefers failures, keeps INFO, preserves order) — the dedupe fix is now pinned. Co-Authored-By: Claude Opus 4.8 * client/dpop: round-6 polish — pin collapse wiring; docstring - Add an integration assertion that the §8/§9 double-POST is collapsed to one token-request/pkce-* entry each in auth/dpop-nonce, so the requireNonce gating of collapseDuplicateChecks can't be silently deleted or inverted (previously mutation-survivable). runClientAgainstScenario now returns the emitted checks so callers can make occurrence-level assertions (backward-compatible). - Docstring: note the severity ladder ranks SKIPPED below SUCCESS, and that equal-severity ties keep the last occurrence. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../typescript/auth-test-dpop-bearer.ts | 21 + .../typescript/auth-test-dpop-no-as-nonce.ts | 22 ++ .../typescript/auth-test-dpop-no-nonce.ts | 25 ++ .../typescript/auth-test-dpop-no-rs-nonce.ts | 22 ++ .../auth-test-dpop-no-token-proof.ts | 27 ++ .../typescript/auth-test-dpop-replay.ts | 21 + examples/clients/typescript/auth-test-dpop.ts | 20 + .../clients/typescript/everything-client.ts | 8 + .../typescript/helpers/dpopClientFlow.ts | 234 +++++++++++ src/scenarios/client/auth/dpop.test.ts | 85 ++++ src/scenarios/client/auth/dpop.ts | 362 ++++++++++++++++++ .../client/auth/helpers/createAuthServer.ts | 315 ++++++++++++++- .../client/auth/helpers/dpopProof.test.ts | 180 +++++++++ .../client/auth/helpers/dpopProof.ts | 178 +++++++++ .../auth/helpers/dpopResourceAuth.test.ts | 239 ++++++++++++ .../client/auth/helpers/dpopResourceAuth.ts | 279 ++++++++++++++ .../client/auth/helpers/dpopToken.test.ts | 158 ++++++++ .../client/auth/helpers/dpopToken.ts | 110 ++++++ .../validateDpopProofAtTokenEndpoint.test.ts | 193 ++++++++++ src/scenarios/client/auth/index.test.ts | 104 +++++ src/scenarios/client/auth/index.ts | 5 +- src/scenarios/client/auth/spec-references.ts | 33 ++ .../client/auth/test_helpers/testClient.ts | 28 +- src/seps/sep-1932.yaml | 48 +++ 24 files changed, 2713 insertions(+), 4 deletions(-) create mode 100644 examples/clients/typescript/auth-test-dpop-bearer.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-as-nonce.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-nonce.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts create mode 100644 examples/clients/typescript/auth-test-dpop-no-token-proof.ts create mode 100644 examples/clients/typescript/auth-test-dpop-replay.ts create mode 100644 examples/clients/typescript/auth-test-dpop.ts create mode 100644 examples/clients/typescript/helpers/dpopClientFlow.ts create mode 100644 src/scenarios/client/auth/dpop.test.ts create mode 100644 src/scenarios/client/auth/dpop.ts create mode 100644 src/scenarios/client/auth/helpers/dpopProof.test.ts create mode 100644 src/scenarios/client/auth/helpers/dpopProof.ts create mode 100644 src/scenarios/client/auth/helpers/dpopResourceAuth.test.ts create mode 100644 src/scenarios/client/auth/helpers/dpopResourceAuth.ts create mode 100644 src/scenarios/client/auth/helpers/dpopToken.test.ts create mode 100644 src/scenarios/client/auth/helpers/dpopToken.ts create mode 100644 src/scenarios/client/auth/helpers/validateDpopProofAtTokenEndpoint.test.ts create mode 100644 src/seps/sep-1932.yaml 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/everything-client.ts b/examples/clients/typescript/everything-client.ts index ff6dd35..c91d0b8 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -42,6 +42,7 @@ import { } 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'; /** @@ -854,6 +855,13 @@ registerScenario( runEnterpriseManagedAuthorization ); +// ============================================================================ +// DPoP client conformance (SEP-1932) +// ============================================================================ + +registerScenario('auth/dpop', dpopClient); +registerScenario('auth/dpop-nonce', dpopClient); + // ============================================================================ // MRTR client conformance (SEP-2322) // ============================================================================ 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/src/scenarios/client/auth/dpop.test.ts b/src/scenarios/client/auth/dpop.test.ts new file mode 100644 index 0000000..5818e0d --- /dev/null +++ b/src/scenarios/client/auth/dpop.test.ts @@ -0,0 +1,85 @@ +import { collapseDuplicateChecks } from './dpop'; +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 (DPoP nonce-posture shared-check dedupe)', () => { + 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/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts new file mode 100644 index 0000000..1d7b37b --- /dev/null +++ b/src/scenarios/client/auth/dpop.ts @@ -0,0 +1,362 @@ +import type { ScenarioContext } from '../../../mock-server'; +import type { + Scenario, + ConformanceCheck, + CheckStatus, + SpecReference +} from '../../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } 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'; + +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 + }; +} + +/** + * 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 (for the nonce round-trip that is the retry's diagnostic, which is + * status-equivalent to the first). Per-request INFO log entries are always kept. + * + * The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so + * the shared token-flow conformance checks (`token-request`, `pkce-*`) are + * appended twice; this reports each once without hiding a failure recorded on + * either attempt. Exported so the behaviour is unit-tested directly. + */ +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); +} + +export class DPoPClientScenario implements Scenario { + readonly name: string; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } 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/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index cbcbd70..162f93a 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -6,6 +6,12 @@ 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. @@ -20,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[]; @@ -31,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; @@ -63,6 +186,34 @@ export interface AuthServerOptions { * 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; @@ -110,6 +261,10 @@ export function createAuthServer( issParameterSupported = true, issInRedirect = 'correct', metadataIssuer, + dpopSigningAlgValuesSupported, + dpopMisbehavior, + dpopTokenRequestObs, + dpopRequireNonce = false, tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -120,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`, @@ -185,7 +368,21 @@ export function createAuthServer( ...(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 @@ -370,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; 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/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 fbb50a2..f37bf92 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -28,6 +28,13 @@ import { runClient as noAppTypeClient } from '../../../../examples/clients/types import { runClient as noIssValidationClient } from '../../../../examples/clients/typescript/auth-test'; 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 { getHandler } from '../../../../examples/clients/typescript/everything-client'; import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger'; import { DRAFT_PROTOCOL_VERSION } from '../../../types'; @@ -339,3 +346,100 @@ describe('WIF JWT-bearer negative tests', () => { }); }); }); + +// DPoP (SEP-1932): the compliant paths for both postures (auth/dpop and +// auth/dpop-nonce) are covered by the Client Draft 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 Draft Scenarios loop +// above (auth/dpop is in draftScenariosList). +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); + }); +}); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 11e6358..a46a872 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -25,6 +25,7 @@ import { ResourceMismatchScenario } from './resource-mismatch'; import { PreRegistrationScenario } from './pre-registration'; import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization'; import { WifJwtBearerScenario } from './wif-jwt-bearer'; +import { DPoPClientScenario } from './dpop'; import { OfflineAccessScopeScenario, OfflineAccessNotSupportedScenario @@ -81,5 +82,7 @@ export const draftScenariosList: Scenario[] = [ new IssParameterUnexpectedScenario(), new IssParameterNormalizedVariantScenario(), new MetadataIssuerMismatchScenario(), - new WifJwtBearerScenario() + new WifJwtBearerScenario(), + new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case) + new DPoPClientScenario(true) // auth/dpop-nonce — server-required nonce (§8/§9) ]; diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 0cc0e07..972417b 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -113,5 +113,38 @@ export const SpecReferences: { [key: string]: SpecReference } = { 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 8790a7b..e21459a 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -1,6 +1,6 @@ import { getScenario } from '../../../index'; import { testScenarioContext } from '../../../../mock-server/testing'; -import type { SpecVersion } from '../../../../types'; +import type { SpecVersion, ConformanceCheck } from '../../../../types'; import { spawn } from 'child_process'; const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete @@ -87,6 +87,13 @@ 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 @@ -100,9 +107,10 @@ export async function runClientAgainstScenario( clientRunner: ClientRunner, scenarioName: string, options: RunClientOptions = {} -): Promise { +): Promise { const { expectedFailureSlugs = [], + expectedSuccessSlugs = [], allowClientError = false, specVersion } = options; @@ -119,6 +127,10 @@ export async function runClientAgainstScenario( 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 @@ -143,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) { @@ -152,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 @@ -198,4 +220,6 @@ export async function runClientAgainstScenario( // Stop the scenario server await scenario.stop(); } + + return collected; } 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.' From ce25103b1baa6e0653e0b7bf4f79de385ea7a116 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:44:27 +0100 Subject: [PATCH 186/226] client/auth: register dpop and wif scenarios as extensions (#401) Move the DPoP scenarios (auth/dpop, auth/dpop-nonce) and the WIF JWT-bearer scenario (auth/wif-jwt-bearer) off the draft spec timeline and register them as the io.modelcontextprotocol/auth/dpop and io.modelcontextprotocol/auth/wif protocol extensions (SEP-2133 reverse-DNS ids), so they run under --suite extensions rather than at --spec-version draft. Claude-Session: https://claude.ai/code/session_012E7BriGaLimJxQr7B7ChiM Co-authored-by: Claude --- src/scenarios/client/auth/dpop.ts | 6 ++++-- src/scenarios/client/auth/index.test.ts | 6 +++--- src/scenarios/client/auth/index.ts | 10 +++++----- src/scenarios/client/auth/wif-jwt-bearer.ts | 13 ++++--------- src/types.ts | 2 ++ 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index 1d7b37b..e993369 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -5,7 +5,7 @@ import type { CheckStatus, SpecReference } from '../../../types'; -import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; +import { ScenarioUrls } from '../../../types'; import { createAuthServer, type DpopTokenRequestObservation @@ -145,7 +145,9 @@ export function collapseDuplicateChecks( export class DPoPClientScenario implements Scenario { readonly name: string; - readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + readonly source = { + extensionId: 'io.modelcontextprotocol/auth/dpop' + } as const; readonly description: string; private authServer = new ServerLifecycle(); diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index f37bf92..57ff27a 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -348,7 +348,7 @@ describe('WIF JWT-bearer negative tests', () => { }); // DPoP (SEP-1932): the compliant paths for both postures (auth/dpop and -// auth/dpop-nonce) are covered by the Client Draft Scenarios loop above; these +// 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 @@ -419,8 +419,8 @@ describe('DPoP client negative tests (SEP-1932)', () => { // 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 Draft Scenarios loop -// above (auth/dpop is in draftScenariosList). +// 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); diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index a46a872..7daec39 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -66,7 +66,10 @@ export const backcompatScenariosList: Scenario[] = [ export const extensionScenariosList: Scenario[] = [ new ClientCredentialsJwtScenario(), new ClientCredentialsBasicScenario(), - new EnterpriseManagedAuthorizationScenario() + 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) @@ -81,8 +84,5 @@ export const draftScenariosList: Scenario[] = [ new IssParameterWrongIssuerScenario(), new IssParameterUnexpectedScenario(), new IssParameterNormalizedVariantScenario(), - new MetadataIssuerMismatchScenario(), - new WifJwtBearerScenario(), - new DPoPClientScenario(false), // auth/dpop — nonce-less baseline (common case) - new DPoPClientScenario(true) // auth/dpop-nonce — server-required nonce (§8/§9) + new MetadataIssuerMismatchScenario() ]; diff --git a/src/scenarios/client/auth/wif-jwt-bearer.ts b/src/scenarios/client/auth/wif-jwt-bearer.ts index 5c65fba..4db0bd9 100644 --- a/src/scenarios/client/auth/wif-jwt-bearer.ts +++ b/src/scenarios/client/auth/wif-jwt-bearer.ts @@ -1,12 +1,6 @@ import * as jose from 'jose'; -import type { - Scenario, - ConformanceCheck, - ScenarioUrls, - SpecVersion -} from '../../../types'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import type { ScenarioContext } from '../../../mock-server'; -import { DRAFT_PROTOCOL_VERSION } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; import { MockTokenVerifier } from './helpers/mockTokenVerifier'; @@ -26,8 +20,9 @@ const WIF_CLIENT_ID = 'conformance-wif-workload'; export class WifJwtBearerScenario implements Scenario { name = 'auth/wif-jwt-bearer'; - specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; - readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + 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, ' + diff --git a/src/types.ts b/src/types.ts index eaa364b..a6e0225 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,8 @@ export type ScenarioSpecTag = SpecVersion | 'extension'; 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' ] as const; export type ExtensionId = (typeof EXTENSION_IDS)[number]; From d1c0b9591786726d8a4bec05306eb103ba6894ff Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:03:39 +0100 Subject: [PATCH 187/226] Align SEP-2575 checks with spec PR #3002: serverInfo in result _meta, clientInfo optional (#403) Spec PR #3002 (part of the final 2026-07-28 revision) moved serverInfo from the DiscoverResult body to _meta['io.modelcontextprotocol/serverInfo'] and demoted the request envelope's clientInfo from required to SHOULD. The checks still asserted the old shape, failing SDKs that follow the final revision. - Vendor schema types at spec commit 71e30695. - server-stateless: drop the missing-clientInfo rejection case and assert the opposite (a clientInfo-less request must be served, new check sep-2575-request-meta-client-info-optional); discover no longer requires body serverInfo; new SHOULD-level check sep-2575-server-identifies-in-result-meta (WARNING when the _meta identity is absent, SKIPPED when discover itself failed). - request-metadata (client side): the clientInfo SHOULD moves out of the MUST check into its own id sep-2575-client-sends-client-info (WARNING on absence), so requirement levels never blend in one check id. - Mock servers and the everything-server example emit the _meta identity and accept clientInfo-less requests. --- .../servers/typescript/everything-server.ts | 12 +- src/mock-server/mock-server.test.ts | 5 +- src/mock-server/stateless.ts | 20 ++- src/scenarios/client/request-metadata.ts | 27 +++- src/scenarios/server/stateless.ts | 100 ++++++++++--- src/spec-types/2025-03-26.ts | 4 +- src/spec-types/2025-06-18.ts | 26 +--- src/spec-types/2025-11-25.ts | 28 +--- src/spec-types/SOURCE | 2 +- src/spec-types/draft.ts | 133 ++++++++++++------ 10 files changed, 237 insertions(+), 120 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 39be2f0..f01c89f 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1266,11 +1266,11 @@ app.post('/mcp', async (req, res) => { // Per-Request Metadata Integrity Checks (Fields verification). // A request missing any required `_meta` field is malformed: -32602 and, - // on HTTP, status 400 Bad Request. + // 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/clientInfo'] || !meta['io.modelcontextprotocol/clientCapabilities'] ) { return res.status(400).json({ @@ -1372,7 +1372,13 @@ app.post('/mcp', async (req, res) => { // served on this path, so the capability must be declared too. resources: {} }, - serverInfo: { name: 'everything-stateless-server', version: '1.0.0' } + // Spec PR #3002: server identity lives in the result `_meta`. + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'everything-stateless-server', + version: '1.0.0' + } + } } }); } diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts index 2d26cf5..3c3a1d7 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -252,7 +252,10 @@ describe('createServerStateless', () => { ); expect(status).toBe(200); expect(body.result.supportedVersions).toEqual(STATELESS_SPEC_VERSIONS); - expect(body.result.serverInfo.name).toBe('conformance-mock-server'); + // 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(); } diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index 2a69f98..9968d41 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -1,8 +1,9 @@ /** * Stateless mock server: 2026-x lifecycle (SEP-2575). * - * No initialize handshake. Validates `_meta` (protocolVersion / clientInfo / - * clientCapabilities) and the `MCP-Protocol-Version` header on every request, + * 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. */ @@ -14,9 +15,13 @@ import type { MockServer, RequestHandlers } from './index'; import { STATELESS_SPEC_VERSIONS } from '../connection/select'; 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/clientInfo', 'io.modelcontextprotocol/clientCapabilities' ] as const; @@ -156,7 +161,14 @@ export function validateStatelessRequest( result: withRequiredDraftResultFields(method, { supportedVersions, capabilities, - serverInfo: { name: 'conformance-mock-server', version: '1.0.0' } + // 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' + } + } }) } }; diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 61dc5e1..2d1d5dc 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -32,6 +32,7 @@ const STATUS_SEVERITY: Record = { 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', @@ -161,17 +162,19 @@ export class RequestMetadataScenario implements Scenario { // 2. "Every client request MUST include the following // io.modelcontextprotocol/* fields in _meta: protocolVersion, - // clientInfo, clientCapabilities." + // 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 && hasClientInfo && hasCapabilities; + const metaIsValid = metaVersion && hasCapabilities; this.addOrUpdateCheck({ id: 'sep-2575-client-populates-meta', name: 'ClientPopulatesMeta', description: - 'Client populates _meta on every request with all three required fields', + 'Client populates _meta on every request with the required fields (protocolVersion, clientCapabilities)', status: metaIsValid ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), specReferences: [ @@ -183,6 +186,24 @@ export class RequestMetadataScenario implements Scenario { 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 diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index 917e343..849437a 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -29,13 +29,14 @@ export class ServerStatelessScenario implements ClientScenario { **Server Implementation Requirements:** **Endpoints**: -- \`server/discover\`: Returns supportedVersions, capabilities, and serverInfo metadata. +- \`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 (5 Checks)** - - Rejects requests missing \`_meta\` or lacking structural required internal subfields (\`protocolVersion\`, \`clientInfo\`, \`clientCapabilities\`) with a JSON-RPC \`-32602 Invalid params\` error signature and an HTTP status code \`400 Bad Request\`. +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. @@ -332,20 +333,8 @@ export class ServerStatelessScenario implements ClientScenario { }, rpcId: 102 }, - { - slug: 'missing-client-info', - description: - 'Rejects request with _meta missing io.modelcontextprotocol/clientInfo', - params: { - _meta: { - 'io.modelcontextprotocol/protocolVersion': - validMeta['io.modelcontextprotocol/protocolVersion'], - 'io.modelcontextprotocol/clientCapabilities': - validMeta['io.modelcontextprotocol/clientCapabilities'] - } - }, - rpcId: 103 - }, + // 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: @@ -411,6 +400,45 @@ export class ServerStatelessScenario implements ClientScenario { ); } + // 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) // ========================================== @@ -448,10 +476,12 @@ export class ServerStatelessScenario implements ClientScenario { () => { 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 || - !discoverResult?.serverInfo + !discoverResult?.capabilities ) { return { error: 'Missing mandatory fields in discover response setup', @@ -462,6 +492,38 @@ export class ServerStatelessScenario implements ClientScenario { } ); + 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', diff --git a/src/spec-types/2025-03-26.ts b/src/spec-types/2025-03-26.ts index a4b3a9d..6da0f59 100644 --- a/src/spec-types/2025-03-26.ts +++ b/src/spec-types/2025-03-26.ts @@ -1234,9 +1234,7 @@ export type ClientResult = EmptyResult | CreateMessageResult | ListRootsResult; /* Server messages */ export type ServerRequest = - | PingRequest - | CreateMessageRequest - | ListRootsRequest; + PingRequest | CreateMessageRequest | ListRootsRequest; export type ServerNotification = | CancelledNotification diff --git a/src/spec-types/2025-06-18.ts b/src/spec-types/2025-06-18.ts index 8778ec0..0df081e 100644 --- a/src/spec-types/2025-06-18.ts +++ b/src/spec-types/2025-06-18.ts @@ -6,10 +6,7 @@ * @category JSON-RPC */ export type JSONRPCMessage = - | JSONRPCRequest - | JSONRPCNotification - | JSONRPCResponse - | JSONRPCError; + JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError; /** @internal */ export const LATEST_PROTOCOL_VERSION = "2025-06-18"; @@ -1134,11 +1131,7 @@ export interface Annotations { * @category Content */ export type ContentBlock = - | TextContent - | ImageContent - | AudioContent - | ResourceLink - | EmbeddedResource; + TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; /** * Text provided to or from an LLM. @@ -1490,10 +1483,7 @@ export interface ElicitRequest extends Request { * @category `elicitation/create` */ export type PrimitiveSchemaDefinition = - | StringSchema - | NumberSchema - | BooleanSchema - | EnumSchema; + StringSchema | NumberSchema | BooleanSchema | EnumSchema; /** * @category `elicitation/create` @@ -1586,18 +1576,12 @@ export type ClientNotification = /** @internal */ export type ClientResult = - | EmptyResult - | CreateMessageResult - | ListRootsResult - | ElicitResult; + EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult; /* Server messages */ /** @internal */ export type ServerRequest = - | PingRequest - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest; + PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest; /** @internal */ export type ServerNotification = diff --git a/src/spec-types/2025-11-25.ts b/src/spec-types/2025-11-25.ts index 332c3f0..b734ae3 100644 --- a/src/spec-types/2025-11-25.ts +++ b/src/spec-types/2025-11-25.ts @@ -6,9 +6,7 @@ * @category JSON-RPC */ export type JSONRPCMessage = - | JSONRPCRequest - | JSONRPCNotification - | JSONRPCResponse; + JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; /** @internal */ export const LATEST_PROTOCOL_VERSION = "2025-11-25"; @@ -1740,11 +1738,7 @@ export interface Annotations { * @category Content */ export type ContentBlock = - | TextContent - | ImageContent - | AudioContent - | ResourceLink - | EmbeddedResource; + TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; /** * Text provided to or from an LLM. @@ -2219,8 +2213,7 @@ export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { * @category `elicitation/create` */ export type ElicitRequestParams = - | ElicitRequestFormParams - | ElicitRequestURLParams; + ElicitRequestFormParams | ElicitRequestURLParams; /** * A request from the server to elicit additional information from the user via the client. @@ -2239,10 +2232,7 @@ export interface ElicitRequest extends JSONRPCRequest { * @category `elicitation/create` */ export type PrimitiveSchemaDefinition = - | StringSchema - | NumberSchema - | BooleanSchema - | EnumSchema; + StringSchema | NumberSchema | BooleanSchema | EnumSchema; /** * @category `elicitation/create` @@ -2343,8 +2333,7 @@ export interface TitledSingleSelectEnumSchema { */ // Combined single selection enumeration export type SingleSelectEnumSchema = - | UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema; + UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema; /** * Schema for multiple-selection enumeration without display titles for options. @@ -2437,8 +2426,7 @@ export interface TitledMultiSelectEnumSchema { */ // Combined multiple selection enumeration export type MultiSelectEnumSchema = - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema; + UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema; /** * Use TitledSingleSelectEnumSchema instead. @@ -2464,9 +2452,7 @@ export interface LegacyTitledEnumSchema { */ // Union type for all enum schemas export type EnumSchema = - | SingleSelectEnumSchema - | MultiSelectEnumSchema - | LegacyTitledEnumSchema; + SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema; /** * The client's response to an elicitation request. diff --git a/src/spec-types/SOURCE b/src/spec-types/SOURCE index 33d35e9..a4891c9 100644 --- a/src/spec-types/SOURCE +++ b/src/spec-types/SOURCE @@ -1 +1 @@ -modelcontextprotocol@f817239f4d6b1efff2c4dfc2f7af85c985d73076 +modelcontextprotocol@71e306956a4959c9655e5036be215d41986596e6 diff --git a/src/spec-types/draft.ts b/src/spec-types/draft.ts index c48f9d7..110485f 100644 --- a/src/spec-types/draft.ts +++ b/src/spec-types/draft.ts @@ -4,12 +4,7 @@ * @category Common Types */ export type JSONValue = - | string - | number - | boolean - | null - | JSONObject - | JSONArray; + string | number | boolean | null | JSONObject | JSONArray; /** * @category Common Types @@ -29,9 +24,7 @@ export type JSONArray = JSONValue[]; * @category JSON-RPC */ export type JSONRPCMessage = - | JSONRPCRequest - | JSONRPCNotification - | JSONRPCResponse; + JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; /** @internal */ export const LATEST_PROTOCOL_VERSION = "2026-07-28"; @@ -82,12 +75,19 @@ export interface RequestMetaObject extends MetaObject { */ "io.modelcontextprotocol/protocolVersion": string; /** - * Identifies the client software making the request. Required. + * 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; + "io.modelcontextprotocol/clientInfo"?: Implementation; /** * The client's capabilities for this specific request. Required. * @@ -133,6 +133,30 @@ export interface NotificationMetaObject extends MetaObject { "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. * @@ -197,7 +221,7 @@ export type ResultType = "complete" | "input_required" | string; * @category Common Types */ export interface Result { - _meta?: MetaObject; + _meta?: ResultMetaObject; /** * Indicates the type of the result, which allows the client to determine * how to parse the result object. @@ -511,15 +535,11 @@ export type EmptyResult = Result; /** @internal */ export type InputRequest = - | CreateMessageRequest - | ListRootsRequest - | ElicitRequest; + CreateMessageRequest | ListRootsRequest | ElicitRequest; /** @internal */ export type InputResponse = - | CreateMessageResult - | ListRootsResult - | ElicitResult; + CreateMessageResult | ListRootsResult | ElicitResult; /** * A map of server-initiated requests that the client must fulfill. @@ -665,10 +685,6 @@ export interface DiscoverResult extends CacheableResult { * The capabilities of the server. */ capabilities: ServerCapabilities; - /** - * Information about the server software implementation. - */ - serverInfo: Implementation; /** * Natural-language guidance describing the server and its features. * @@ -1300,6 +1316,40 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest { 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. * @@ -1316,10 +1366,16 @@ export interface SubscriptionsAcknowledgedNotificationParams extends Notificatio } /** - * Sent by the server as the first message on a - * {@link SubscriptionsListenRequest | subscriptions/listen} stream to acknowledge - * that the subscription has been established and to report which notification - * types it agreed to honor. + * 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} @@ -2234,11 +2290,7 @@ export interface Annotations { * @category Content */ export type ContentBlock = - | TextContent - | ImageContent - | AudioContent - | ResourceLink - | EmbeddedResource; + TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; /** * Text provided to or from an LLM. @@ -2778,8 +2830,7 @@ export interface ElicitRequestURLParams { * @category `elicitation/create` */ export type ElicitRequestParams = - | ElicitRequestFormParams - | ElicitRequestURLParams; + ElicitRequestFormParams | ElicitRequestURLParams; /** * A request from the server to elicit additional information from the user via the client. @@ -2801,10 +2852,7 @@ export interface ElicitRequest { * @category `elicitation/create` */ export type PrimitiveSchemaDefinition = - | StringSchema - | NumberSchema - | BooleanSchema - | EnumSchema; + StringSchema | NumberSchema | BooleanSchema | EnumSchema; /** * @example Email input schema @@ -2929,8 +2977,7 @@ export interface TitledSingleSelectEnumSchema { */ // Combined single selection enumeration export type SingleSelectEnumSchema = - | UntitledSingleSelectEnumSchema - | TitledSingleSelectEnumSchema; + UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema; /** * Schema for multiple-selection enumeration without display titles for options. @@ -3029,8 +3076,7 @@ export interface TitledMultiSelectEnumSchema { */ // Combined multiple selection enumeration export type MultiSelectEnumSchema = - | UntitledMultiSelectEnumSchema - | TitledMultiSelectEnumSchema; + UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema; /** * Use {@link TitledSingleSelectEnumSchema} instead. @@ -3056,9 +3102,7 @@ export interface LegacyTitledEnumSchema { */ // Union type for all enum schemas export type EnumSchema = - | SingleSelectEnumSchema - | MultiSelectEnumSchema - | LegacyTitledEnumSchema; + SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema; /** * The result returned by the client for an {@link ElicitRequest| elicitation/create} request. @@ -3134,6 +3178,7 @@ export type ServerResult = | ListResourceTemplatesResult | ListResourcesResult | ReadResourceResult + | SubscriptionsListenResult | CallToolResult | ListToolsResult | InputRequiredResult; From da56f6631c83bec2b3b7e40a82f392ae360b790c Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:20:32 +0100 Subject: [PATCH 188/226] Allow expected-failures entries to name a single check (#406) A baseline entry names a scenario, but a scenario is many checks: server-stateless is 29. Excusing the one or two a spec change breaks means baselining the whole scenario, which stops enforcing the rest. An entry may now be ':', in which case every failing check in that scenario is judged on its own. Bare scenario entries are unchanged, and YAML parses '- a:b' as a plain string, so every existing baseline keeps working untouched. loadExpectedFailures now owns the entry grammar and returns parsed BaselineEntry values, so evaluateBaseline only matches and cannot throw. Rejecting a bad entry and interpreting a good one no longer live in separate layers. A check id matches all of its occurrences, since ids repeat within a run; they collapse to one verdict, most-severe first. Absent and SKIPPED are tolerated, matching how the runner already treats them as green. Move collapseDuplicateChecks to src/checks/collapse.ts with its tests: expected-failures.ts needs the reducer and cannot import dpop.ts's auth server stack. No behavior change. Reject two configs that previously coerced to an entry matching nothing: a mapping ('- scenario: check-id', with a space) and an empty list item. --- README.md | 34 ++ .../dpop.test.ts => checks/collapse.test.ts} | 6 +- src/checks/collapse.ts | 35 ++ src/expected-failures.test.ts | 329 +++++++++++++++++- src/expected-failures.ts | 222 +++++++++--- src/scenarios/client/auth/dpop.ts | 33 +- 6 files changed, 564 insertions(+), 95 deletions(-) rename src/{scenarios/client/auth/dpop.test.ts => checks/collapse.test.ts} (92%) create mode 100644 src/checks/collapse.ts diff --git a/README.md b/README.md index a10ce3d..e4cd70f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,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. diff --git a/src/scenarios/client/auth/dpop.test.ts b/src/checks/collapse.test.ts similarity index 92% rename from src/scenarios/client/auth/dpop.test.ts rename to src/checks/collapse.test.ts index 5818e0d..d590d46 100644 --- a/src/scenarios/client/auth/dpop.test.ts +++ b/src/checks/collapse.test.ts @@ -1,5 +1,5 @@ -import { collapseDuplicateChecks } from './dpop'; -import type { ConformanceCheck, CheckStatus } from '../../../types'; +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 { @@ -13,7 +13,7 @@ function chk(id: string, status: CheckStatus, tag?: string): ConformanceCheck { }; } -describe('collapseDuplicateChecks (DPoP nonce-posture shared-check dedupe)', () => { +describe('collapseDuplicateChecks', () => { it('collapses duplicate SUCCESS ids to a single entry', () => { const out = collapseDuplicateChecks([ chk('token-request', 'SUCCESS'), 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/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/scenarios/client/auth/dpop.ts b/src/scenarios/client/auth/dpop.ts index e993369..8b16ba5 100644 --- a/src/scenarios/client/auth/dpop.ts +++ b/src/scenarios/client/auth/dpop.ts @@ -18,6 +18,7 @@ import { type DpopClientObservations } from './helpers/dpopResourceAuth'; import { SpecReferences } from './spec-references'; +import { collapseDuplicateChecks } from '../../../checks/collapse'; const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; @@ -111,38 +112,6 @@ function newTokenReqObs(): DpopTokenRequestObservation { }; } -/** - * 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 (for the nonce round-trip that is the retry's diagnostic, which is - * status-equivalent to the first). Per-request INFO log entries are always kept. - * - * The RFC 9449 §8/§9 nonce round-trip re-POSTs /token (challenge → retry), so - * the shared token-flow conformance checks (`token-request`, `pkce-*`) are - * appended twice; this reports each once without hiding a failure recorded on - * either attempt. Exported so the behaviour is unit-tested directly. - */ -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); -} - export class DPoPClientScenario implements Scenario { readonly name: string; readonly source = { From a865118206d4d8cc8dbc5f5201607839281d0c3b Mon Sep 17 00:00:00 2001 From: thomas Date: Wed, 22 Jul 2026 23:04:40 -0700 Subject: [PATCH 189/226] fix request metadata HTTP method handling (#409) --- src/scenarios/client/request-metadata.test.ts | 47 ++++++++++++++++++- src/scenarios/client/request-metadata.ts | 29 +++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index 567d1ca..0227d3f 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -6,7 +6,10 @@ import { } from './auth/test_helpers/testClient'; import { getHandler } from '../../../examples/clients/typescript/everything-client'; import { getScenario } from '../index'; -import { DECLARED_CHECK_IDS } from './request-metadata'; +import { + DECLARED_CHECK_IDS, + RequestMetadataScenario +} from './request-metadata'; // A bad client that does not send _meta async function badClient(serverUrl: string) { @@ -246,6 +249,48 @@ describe('request-metadata client scenario — client never connects', () => { }); }); +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); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 2d1d5dc..54673b0 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -129,12 +129,39 @@ export class RequestMetadataScenario implements Scenario { 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', () => { - const request = JSON.parse(body); + 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 From 468e53547d9d6f98cd92737bdf4e5cc7cb3c4f74 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 27 Jul 2026 12:35:42 +0200 Subject: [PATCH 190/226] chore: bump version to 0.2.0-alpha.10 (#413) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3701aab..715b4db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.9", + "version": "0.2.0-alpha.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.9", + "version": "0.2.0-alpha.10", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 7deb70d..0b31b21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.9", + "version": "0.2.0-alpha.10", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From a9896553900a2ef61787b57adfcbbe936a8ab1f9 Mon Sep 17 00:00:00 2001 From: Josh Abbott Date: Mon, 27 Jul 2026 06:45:48 -0400 Subject: [PATCH 191/226] fix(server-stateless): declare the elicitation capability on the streaming-probe request (#383) --- src/scenarios/server/stateless.test.ts | 129 +++++++++++++++++++++++++ src/scenarios/server/stateless.ts | 11 ++- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/scenarios/server/stateless.test.ts b/src/scenarios/server/stateless.test.ts index f8175d8..9bfd0b5 100644 --- a/src/scenarios/server/stateless.test.ts +++ b/src/scenarios/server/stateless.test.ts @@ -628,6 +628,135 @@ describe('Stateless Server Scenario Negative Tests', () => { 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 ( diff --git a/src/scenarios/server/stateless.ts b/src/scenarios/server/stateless.ts index 849437a..618dc0c 100644 --- a/src/scenarios/server/stateless.ts +++ b/src/scenarios/server/stateless.ts @@ -889,7 +889,16 @@ export class ServerStatelessScenario implements ClientScenario { { name: 'test_streaming_elicitation', arguments: {}, - _meta: validMeta + // 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 From 19a97f0465d6e8187f378045095094cda2d29c03 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:59:17 +0100 Subject: [PATCH 192/226] fix: use negotiated protocol version in sse-multiple-streams raw POSTs (#415) The stateful server-sse-multiple-streams scenario hard-coded mcp-protocol-version: 2025-03-26 on its three raw concurrent POSTs instead of the version negotiated during initialize. A server that enforces the negotiated session version correctly returns 400 to all three POSTs, so the scenario false-failed before exercising the multi-stream behavior under test. Read transport.protocolVersion after client.connect() and use it for the raw POST headers, falling back to the run's spec version if the transport does not expose one. Add a regression test driving the scenario against a fixture server that 400s any request whose MCP-Protocol-Version header does not match what it negotiated. Fixes #412 Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz Co-authored-by: Claude --- .../server/sse-multiple-streams.test.ts | 183 ++++++++++++++++++ src/scenarios/server/sse-multiple-streams.ts | 6 +- 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/scenarios/server/sse-multiple-streams.test.ts 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 23f7e79..d2eb493 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -37,6 +37,7 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { const stateless = specVersion === DRAFT_PROTOCOL_VERSION; let sessionId: string | undefined; + let negotiatedProtocolVersion: string | undefined; let client: Client | undefined; let transport: StreamableHTTPClientTransport | undefined; @@ -59,8 +60,9 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { transport = new StreamableHTTPClientTransport(new URL(serverUrl)); await client.connect(transport); - // Extract session ID from 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({ @@ -96,7 +98,7 @@ export class ServerSSEMultipleStreamsScenario implements ClientScenario { 'Content-Type': 'application/json', Accept: 'text/event-stream, application/json', 'mcp-session-id': sessionId!, - 'mcp-protocol-version': '2025-03-26' + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion }; const requestParams = stateless ? { From 31119a05f08c0535e71200937b1ecf67e9b9b7db Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:00:07 +0100 Subject: [PATCH 193/226] fix(everything-server): send sampling/elicitation requests via extra.sendRequest (#414) Tool handlers issued sampling/createMessage and elicitation/create via mcpServer.server.request(), which carries no relatedRequestId. The SDK's Streamable HTTP transport routes such requests to the standalone GET SSE stream; if the client hasn't opened it yet, the request is silently parked in the event store and the scenario times out after 60s. Switch test_sampling, test_elicitation, test_elicitation_sep1034_defaults, and test_elicitation_sep1330_enums to the handler's extra.sendRequest(), which sets relatedRequestId so the request rides the originating tools/call POST SSE stream. Fixes #407 Claude-Session: https://claude.ai/code/session_01Qz5WunPTnDHVot4C2J6sFz Co-authored-by: Claude --- .../servers/typescript/everything-server.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index f01c89f..bbce036 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -539,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: { @@ -599,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: { @@ -651,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: { @@ -726,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: { From 0c8530224147e02a101196d5b39287f2f2131d54 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:21:11 +0100 Subject: [PATCH 194/226] fix: use negotiated protocol version in server-sse-polling raw requests (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use negotiated protocol version in sse-polling raw requests The server-sse-polling scenario hard-coded mcp-protocol-version: 2025-11-25 on its raw tools/call POST and Last-Event-ID reconnection GET instead of the version negotiated during initialize — the same bug class as #412. A server that enforces the negotiated session version rejects those requests once it negotiates any other version. Read transport.protocolVersion after client.connect() and use it for both raw request headers, falling back to the run's spec version, mirroring the sse-multiple-streams fix (#415). Add a regression test whose fixture server down-negotiates to 2025-06-18 and 400s any request whose MCP-Protocol-Version header does not match, so a fix that hard-codes the latest version cannot pass. Follow-up to #415. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz * test: harden sse-polling regression fixture per review panel Enforce session id alongside protocol version in the fixture, record Last-Event-ID presence per GET so the SDK's automatic standalone GET cannot satisfy the reconnect assertion, normalize the header type instead of casting, return a descriptive 400 body on the GET path, add Allow to 405s, use the spec-shaped empty-data priming event, and read sessionId via the SDK's public getter in the scenario. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0178cz8nAyFRHQyq9cg2XgZz --------- Co-authored-by: Claude --- src/scenarios/server/sse-polling.test.ts | 235 +++++++++++++++++++++++ src/scenarios/server/sse-polling.ts | 12 +- 2 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/scenarios/server/sse-polling.test.ts 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 d197d3f..a15d5e9 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -80,10 +80,11 @@ export class ServerSSEPollingScenario implements ClientScenario { 'Test server SSE polling via test_reconnection tool that closes stream mid-call (SEP-1699)'; async run(ctx: RunContext): Promise { - const { serverUrl } = ctx; + const { serverUrl, specVersion } = ctx; const checks: ConformanceCheck[] = []; let sessionId: string | undefined; + let negotiatedProtocolVersion: string | undefined; let client: Client | undefined; let transport: StreamableHTTPClientTransport | undefined; @@ -105,8 +106,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({ @@ -138,7 +140,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-11-25' + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion }, body: JSON.stringify({ jsonrpc: '2.0', @@ -417,7 +419,7 @@ export class ServerSSEPollingScenario implements ClientScenario { headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-11-25', + 'mcp-protocol-version': negotiatedProtocolVersion ?? specVersion, 'last-event-id': lastEventId } }); From 2a705a811e667f95d9eeddf4f90fb727e87bae5f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 27 Jul 2026 15:54:45 +0200 Subject: [PATCH 195/226] feat(sdk-runner): expand KNOWN_SDKS to all tier 1 SDKs (#417) * feat(sdk-runner): expand KNOWN_SDKS to all tier 1 SDKs - go-sdk: point at the relocated conformance/ fixtures (the old examples/server/conformance path is gone on main), add the everything-client fixture that now exists upstream, and wire conformance/baseline.yml as the expected-failures baseline. - python-sdk: new entry for main, which targets the 2026-07-28 revision (same uv workspace layout as the v1.x entry). - csharp-sdk: new entry driving the ConformanceClient / ConformanceServer fixtures via the .NET 10 SDK. The client takes the scenario as argv[0], so the command bridges MCP_CONFORMANCE_SCENARIO into argv; the server exposes the SEP-2575 stateless lifecycle at /stateless. * docs: match the sdk override example's lead-in to its content The example now shows field overrides on a built-in entry rather than a one-off ref, so the introducing sentence shouldn't promise a ref override. --- README.md | 11 +++--- src/sdk-runner/known-sdks.ts | 60 +++++++++++++++++++++++++++---- src/sdk-runner/sdk-runner.test.ts | 29 +++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e4cd70f..8fa48af 100644 --- a/README.md +++ b/README.md @@ -281,13 +281,14 @@ 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). Overriding for a one-off ref: +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). Overriding built-in fields for a one-off run: ```bash -npm start -- sdk owner/go-sdk@some-branch \ - --mode client \ - --build-cmd 'go build -tags mcp_go_client_oauth -o ./.conformance-client ./conformance/everything-client' \ - --client-cmd './.conformance-client' +# e.g. a 2026-07-28 run against go-sdk's server — the built-in server command +# pins -stateless=false (what the dated-spec suites need), so override it to +# get the SEP-2575 stateless lifecycle: +npm start -- sdk go-sdk --mode server --suite all --spec-version 2026-07-28 \ + --server-cmd './.conformance-server -http=localhost:3000' ``` To add a new SDK to the matrix, add an entry to `KNOWN_SDKS`. diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts index 8a6b30f..0e02c29 100644 --- a/src/sdk-runner/known-sdks.ts +++ b/src/sdk-runner/known-sdks.ts @@ -38,17 +38,44 @@ export const KNOWN_SDKS: Record = { }, 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; -stateless=false pins the stateful transport, which + // the dated-spec (`active` suite) scenarios need — for a 2026-07-28 / + // SEP-2575 run, override with --server-cmd './.conformance-server -http=localhost:3000' + // to get the stateless lifecycle. 'go-sdk': { - build: 'go build -o ./.conformance-server ./examples/server/conformance', - // Upstream go-sdk has no client conformance fixture yet (see go-sdk#859). + 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=:3000', + command: './.conformance-server -http=localhost:3000 -stateless=false', url: 'http://localhost:3000' - } + }, + expectedFailures: 'conformance/baseline.yml' + }, + // 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: expected-failures.yml + // covers runs at the latest dated spec (the default here), and + // expected-failures.2026-07-28.yml covers --spec-version 2026-07-28 runs — + // pass the latter via --expected-failures when targeting the new revision. + 'python-sdk': { + 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' }, // v1.x — the stable, published line of the python-sdk, analogous to - // typescript-sdk-v1 (v2/main is mid-refactor and noisy). Clones the - // python-sdk repo, defaulting to the `v1.x` branch, and targets the latest + // 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. @@ -68,6 +95,27 @@ export const KNOWN_SDKS: Record = { url: 'http://localhost:3000/mcp' }, expectedFailures: '.github/actions/conformance/expected-failures.yml' + }, + // 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 — for a 2026-07-28 run, + // override with --server-url http://localhost:3000/stateless. + '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: { + command: + 'dotnet artifacts/bin/ModelContextProtocol.ConformanceServer/Release/net10.0/ModelContextProtocol.ConformanceServer.dll --urls http://localhost:3000', + url: 'http://localhost:3000' + } } }; diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts index 6c6353e..f7363fa 100644 --- a/src/sdk-runner/sdk-runner.test.ts +++ b/src/sdk-runner/sdk-runner.test.ts @@ -104,6 +104,35 @@ describe('lookupBuiltinConfig', () => { 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('every built-in entry validates against SdkConfigSchema', () => { for (const [name, cfg] of Object.entries(KNOWN_SDKS)) { expect(() => SdkConfigSchema.parse(cfg), name).not.toThrow(); From 89c2c0452e792f4483353f4bf7d69cee32d7eb05 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:58:43 +0100 Subject: [PATCH 196/226] Validate all wire messages against the per-version spec JSON schema (#399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Vendor schema/{version}/schema.json alongside schema.ts sync-schema now copies the spec repo's JSON Schema for each version into src/spec-types/{version}.schema.json (same pinned SHA recorded in SOURCE: modelcontextprotocol@f817239f4d6b1efff2c4dfc2f7af85c985d73076). These are the inputs for runtime wire-message validation. Vendored verbatim, so they join schema.ts in .prettierignore. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY * Validate all wire messages against the per-version spec JSON schema Every JSON-RPC message the harness sends or receives is now validated (ajv) against the vendored schema.json for the run's spec version, at the wire choke points: sendStatelessRequest (outbound request, JSON response, SSE events), connectStateful (request/result/notifications), and both mock servers (inbound client requests, outbound responses/handler results). Beyond the JSONRPCMessage envelope, messages are validated against the most specific definition the schema has: typed requests/notifications by method const, typed error responses by error.code const (e.g. -32021 -> MissingRequiredClientCapabilityError, the #376 hallucination), and typed results via the XxxRequest -> XxxResult pair, with SEP-2322 InputRequiredResult discriminated on resultType. Violations are recorded per scenario and surfaced by the runners as a synthetic wire-schema-valid check (FAILURE when the implementation under test sent an invalid message) plus a wire-schema-harness-error check when the invalid message was harness-authored. A vitest-wide afterEach guard fails any self-test that recorded violations, so hallucinated fixtures can't ship (#376, #271). Opt-outs are explicit and greppable: per-call skipValidation for deliberately malformed traffic, takeWireViolations() drains in tests that exercise deliberately broken fixtures. Offenders the new validation caught, now fixed: - everything-server stateless path omitted resultType (and the caching hints on server/discover) required by the draft schema - sep-2164-empty-contents fixture omitted resultType/ttlMs/cacheScope (its intended violation, empty contents, is schema-valid prose-only) - connection/mock-server unit-test fixtures used schema-invalid stubs (tools/call without name, ListToolsResult without required members, Tool without inputSchema, ProgressNotification without progressToken, ElicitRequest without message/requestedSchema) - http-standard-headers test mocks returned bare { tools: [] } results - input-required-result-validate-input deliberately sends malformed inputResponses; now marked skipValidation Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY * Validate the stateful wire at the SDK transport, close review-panel gaps Address the review-panel findings on the wire-schema validation PR: - Stateful wire: hook the SDK transport inside connectToServer, wrapping send/onmessage so every raw wire message in both directions is validated for the scenario's spec version -- including the initialize handshake, server->client requests (elicitation/sampling), and the harness's responses to them. Request ids are correlated per direction so responses validate against their typed result definition (e.g. an outbound ElicitResult). The reconstructed stand-ins in connectStateful (fake id: 0 envelopes, Zod-reparsed results) are removed; the hook sees the real bytes. The four scenarios that call connectToServer directly (elicitation-defaults, elicitation-enums, tools, lifecycle) now pass ctx.specVersion and get validation for free; the runner surfaces their violations through the existing recorder. Known remaining gap (documented in the module doc): the client-auth scenarios' bespoke express mock is not instrumented. - skipValidation now skips only the request direction; the implementation's response to a deliberately malformed request is still validated, with a narrow carve-out tolerating the JSON-RPC 2.0 id: null an error response must carry when the request could not be processed (the MCP schema's RequestId forbids null). Covered by new unit tests pinning both the still-validated response and the narrowness of the carve-out. - negative-mrtr.test.ts: the file-wide drain now asserts every recorded violation is implementation-origin (the deliberately broken fixture) and throws on any harness-origin violation. - everything-server: hoist the cacheable-methods set to module scope and replace the per-request res.json monkey-patch with an explicit sendStatelessJson(res, method, payload) helper applied at each dispatch site. - Pin the extracted dispatch maps (methodDefs/errorDefs, e.g. -32021 -> MissingRequiredClientCapabilityError under the draft) per spec version in wire-schema.test.ts, so a schema sync that breaks the const extraction fails loudly instead of degrading to envelope-only validation. Also note the batch branch's requestMethod forwarding limitation. Offender the transport hook flushed out: the everything-server's test_elicitation_sep1034_defaults tool sent a number-typed elicitation field with default: 95.5, which the 2025-11-25 schema forbids (NumberSchema.default is typed integer there; widened to number only in the draft). The fixture and the scenario's expected default are now the integer-valued 95, with comments explaining why. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY * Condense comments to three lines max Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JHNrpU3Vr6Q1rPZiji7zLY * Re-vendor spec schemas at pinned SHA 71e30695 Regenerate src/spec-types/*.schema.json via npm run sync-schema at the SHA already recorded in SOURCE. The previously vendored draft schema predated spec PR #3002, so it still required DiscoverResult.serverInfo at the top level; the mock server now correctly reports serverInfo via result _meta, which the stale schema rejected. * Scope the wire-violation recorder per scenario for parallel suite runs Client --suite runs execute scenarios concurrently over what was a module-global violation recorder, so scenarios could steal or wipe each other's recorded violations. Give each suite scenario its own recorder via AsyncLocalStorage; code outside a scope (single-scenario runs, the sequential server suite, vitest hooks) falls back to the global recorder. Also condense a four-line comment in connection/stateful.ts to three lines. * Patch the 2025-11-25 NumberSchema generator bug as a load-time erratum The released 2025-11-25 schema.json types NumberSchema minimum/maximum/default as integer, contradicting its own schema.ts (default?: number) — a typescript-json-schema artifact, fixed for draft in modelcontextprotocol#2710 and proposed for 2025-11-25 in modelcontextprotocol#3139. Patch the schema at load time so SDK fixtures with fractional number defaults (all tier-1 SDKs send 95.5) are not flagged; restore the sep-1034 scenario and example-server fixture to 95.5. Delete the erratum when #3139 lands and the schemas are re-vendored. Also scope the sequential server-suite loop with withWireRecorder so a connection leaked by a failed scenario cannot attribute late traffic to the next scenario. --------- Co-authored-by: Claude Co-authored-by: Paul Carleton --- .prettierignore | 7 +- .../servers/typescript/everything-server.ts | 102 +- .../typescript/sep-2164-empty-contents.ts | 28 +- package-lock.json | 8 +- package.json | 2 + scripts/sync-schema.ts | 10 +- src/connection/connection.test.ts | 36 +- src/connection/sdk-client.ts | 93 +- src/connection/select.ts | 8 +- src/connection/stateful.ts | 21 +- src/connection/stateless.test.ts | 17 +- src/connection/stateless.ts | 50 +- src/index.ts | 33 +- src/mock-server/mock-server.test.ts | 45 +- src/mock-server/select.ts | 2 +- src/mock-server/stateful.ts | 23 +- src/mock-server/stateless.ts | 38 +- src/runner/client.ts | 8 + src/runner/server.ts | 6 + src/scenarios/server/elicitation-defaults.ts | 2 +- src/scenarios/server/elicitation-enums.ts | 2 +- .../server/http-standard-headers.test.ts | 17 +- .../server/input-required-result-helpers.ts | 7 +- src/scenarios/server/input-required-result.ts | 41 +- src/scenarios/server/lifecycle.test.ts | 6 +- src/scenarios/server/lifecycle.ts | 2 +- src/scenarios/server/negative-mrtr.test.ts | 19 + src/scenarios/server/negative.test.ts | 5 + src/scenarios/server/tools.ts | 6 +- src/spec-types/2025-03-26.schema.json | 2139 +++++++++ src/spec-types/2025-06-18.schema.json | 2517 ++++++++++ src/spec-types/2025-11-25.schema.json | 4058 +++++++++++++++++ src/spec-types/README.md | 11 +- src/spec-types/draft.schema.json | 3942 ++++++++++++++++ src/validation/vitest-hooks.ts | 23 + src/validation/wire-schema.test.ts | 509 +++ src/validation/wire-schema.ts | 420 ++ vitest.config.ts | 2 + 38 files changed, 14136 insertions(+), 129 deletions(-) create mode 100644 src/spec-types/2025-03-26.schema.json create mode 100644 src/spec-types/2025-06-18.schema.json create mode 100644 src/spec-types/2025-11-25.schema.json create mode 100644 src/spec-types/draft.schema.json create mode 100644 src/validation/vitest-hooks.ts create mode 100644 src/validation/wire-schema.test.ts create mode 100644 src/validation/wire-schema.ts diff --git a/.prettierignore b/.prettierignore index 959f18f..a354d83 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,10 +4,11 @@ # output (and the refresh workflow's `git diff` check). src/seps/traceability.json -# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts via -# `npm run sync-schema`. Keep byte-identical with upstream so the SOURCE pin -# is meaningful and re-syncing produces a clean diff. +# 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/ diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index bbce036..e7a6878 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1235,6 +1235,36 @@ const LEGACY_SESSION_PROTOCOL_VERSIONS = [ '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' +]); + +/** Send 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 sendStatelessJson( + res: import('express').Response, + method: string, + payload: { result?: Record; [key: string]: unknown } +): import('express').Response { + 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 res.json(payload); +} + // Handle POST requests - stateful mode app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; @@ -1360,7 +1390,7 @@ app.post('/mcp', async (req, res) => { } if (method === 'server/discover') { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1390,7 +1420,7 @@ app.post('/mcp', async (req, res) => { { method: 'tools/list', params: {} }, ResultSchema as any )) as { tools: any[]; [k: string]: unknown }; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1466,7 +1496,7 @@ app.post('/mcp', async (req, res) => { } }); } catch (e: any) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, error: { code: e.code ?? -32603, message: e.message, data: e.data } @@ -1484,7 +1514,7 @@ app.post('/mcp', async (req, res) => { { method: 'prompts/list', params: {} }, ResultSchema as any )) as { prompts: any[]; [k: string]: unknown }; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1501,7 +1531,7 @@ app.post('/mcp', async (req, res) => { } }); } catch (e: any) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, error: { code: e.code ?? -32603, message: e.message, data: e.data } @@ -1522,7 +1552,7 @@ app.post('/mcp', async (req, res) => { inputResponses['user_context'], 'context' ); - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1538,7 +1568,7 @@ app.post('/mcp', async (req, res) => { } }); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1572,7 +1602,7 @@ app.post('/mcp', async (req, res) => { { method: 'resources/list', params: {} }, ResultSchema as any )) as { resources: any[]; [k: string]: unknown }; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1591,7 +1621,7 @@ app.post('/mcp', async (req, res) => { } }); } catch (e: any) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, error: { code: e.code ?? -32603, message: e.message, data: e.data } @@ -1608,7 +1638,7 @@ app.post('/mcp', async (req, res) => { { method: 'resources/templates/list', params: {} }, ResultSchema as any )) as { resourceTemplates: any[]; [k: string]: unknown }; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1618,7 +1648,7 @@ app.post('/mcp', async (req, res) => { } }); } catch (e: any) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, error: { code: e.code ?? -32603, message: e.message, data: e.data } @@ -1631,7 +1661,7 @@ app.post('/mcp', async (req, res) => { if (method === 'resources/read') { const uri = params.uri as string | undefined; if (uri === 'test://stateless-static-text') { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1675,7 +1705,7 @@ app.post('/mcp', async (req, res) => { } }); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'Success' }] } @@ -1687,13 +1717,13 @@ app.post('/mcp', async (req, res) => { if (name === 'test_input_required_result_elicitation') { if (inputResponses?.['user_name']) { const userName = getMrtInputText(inputResponses['user_name'], 'name'); - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `Hello, ${userName}!` }] } }); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1722,7 +1752,7 @@ app.post('/mcp', async (req, res) => { unknown >; const content = sample.content as Record | undefined; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1735,7 +1765,7 @@ app.post('/mcp', async (req, res) => { } }); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1770,7 +1800,7 @@ app.post('/mcp', async (req, res) => { const roots = Array.isArray(rootsResult.roots) ? rootsResult.roots : []; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1778,7 +1808,7 @@ app.post('/mcp', async (req, res) => { } }); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1796,7 +1826,7 @@ app.post('/mcp', async (req, res) => { const ok = (inputResponses['confirm'] as Record) ?.content as Record | undefined; if (state.kind === 'request-state' && ok?.ok === true) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1807,7 +1837,7 @@ app.post('/mcp', async (req, res) => { }); } } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1860,7 +1890,7 @@ app.post('/mcp', async (req, res) => { const roots = Array.isArray(rootsResult.roots) ? rootsResult.roots : []; - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1874,7 +1904,7 @@ app.post('/mcp', async (req, res) => { }); } } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1915,7 +1945,7 @@ app.post('/mcp', async (req, res) => { if (name === 'test_input_required_result_multi_round') { if (!requestState) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1940,7 +1970,7 @@ app.post('/mcp', async (req, res) => { const state = JSON.parse(requestState) as Record; if (state.round === 1 && inputResponses?.['step1']) { const userName = getMrtInputText(inputResponses['step1'], 'name'); - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1970,7 +2000,7 @@ app.post('/mcp', async (req, res) => { const userName = typeof state.name === 'string' ? state.name : 'friend'; const color = getMrtInputText(inputResponses['step2'], 'color'); - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -1984,7 +2014,7 @@ app.post('/mcp', async (req, res) => { }); } // Fallback: restart - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -2011,7 +2041,7 @@ app.post('/mcp', async (req, res) => { if (requestState) { const verified = verifyMrtState(requestState); if (!verified) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, error: { @@ -2021,7 +2051,7 @@ app.post('/mcp', async (req, res) => { }); } if (verified.kind === 'tamper-test' && inputResponses?.['confirm']) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -2032,7 +2062,7 @@ app.post('/mcp', async (req, res) => { }); } } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -2093,7 +2123,7 @@ app.post('/mcp', async (req, res) => { } if (inputResponses && Object.keys(inputResponses).length > 0) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -2107,7 +2137,7 @@ app.post('/mcp', async (req, res) => { }); } if (Object.keys(inputRequests).length === 0) { - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -2117,7 +2147,7 @@ app.post('/mcp', async (req, res) => { } }); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { @@ -2197,7 +2227,7 @@ app.post('/mcp', async (req, res) => { } else { notifyListenStreams('prompts', 'notifications/prompts/list_changed'); } - return res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'Mutation triggered' }] } @@ -2255,14 +2285,14 @@ app.post('/mcp', async (req, res) => { { method, params }, ResultSchema as any ); - return res.json({ jsonrpc: '2.0', id, result }); + 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 res.json({ + return sendStatelessJson(res, method, { jsonrpc: '2.0', id, error: { code: e.code ?? -32603, message: e.message, data } diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts index 464b68e..06fc311 100644 --- a/examples/servers/typescript/sep-2164-empty-contents.ts +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -7,6 +7,9 @@ * 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'; @@ -25,16 +28,37 @@ app.post('/mcp', (req, res) => { 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: { resources: [] } }); + 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: { contents: [] } }); + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + contents: [] + } + }); default: return res.status(404).json({ jsonrpc: '2.0', diff --git a/package-lock.json b/package-lock.json index 715b4db..ef3d6ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@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.8", "express": "^5.1.0", @@ -2083,9 +2085,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "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", diff --git a/package.json b/package.json index 0b31b21..e59d9f0 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "dependencies": { "@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.8", "express": "^5.1.0", diff --git a/scripts/sync-schema.ts b/scripts/sync-schema.ts index 5aa9e7f..a5d41a0 100644 --- a/scripts/sync-schema.ts +++ b/scripts/sync-schema.ts @@ -1,7 +1,8 @@ #!/usr/bin/env -S npx tsx /** - * Vendor schema/{version}/schema.ts from the modelcontextprotocol spec repo - * into src/spec-types/{version}.ts at a pinned SHA. + * 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 -- */ @@ -39,6 +40,11 @@ try { 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( diff --git a/src/connection/connection.test.ts b/src/connection/connection.test.ts index 6c77607..fa3a17d 100644 --- a/src/connection/connection.test.ts +++ b/src/connection/connection.test.ts @@ -3,6 +3,7 @@ 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', () => { @@ -37,7 +38,16 @@ describe('connectStateless', () => { it('injects required _meta keys and MCP-Protocol-Version header', async () => { mockFetch.mockResolvedValue( - jsonResponse({ jsonrpc: '2.0', id: 1, result: { ok: true } }) + 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'); @@ -76,6 +86,8 @@ describe('connectStateless', () => { ); 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 () => { @@ -92,12 +104,15 @@ describe('connectStateless', () => { it('parses SSE: collects notifications and returns final result (LF)', async () => { mockFetch.mockResolvedValue( sseResponse([ - 'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progress":1}}\n\n', - 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"done":true}}\n\n' + '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', {}); + 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'); @@ -106,21 +121,26 @@ describe('connectStateless', () => { it('parses SSE with CRLF line endings', async () => { mockFetch.mockResolvedValue( sseResponse([ - 'event: message\r\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\r\n\r\n' + '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', {}); + 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":{}}\n\n' + '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', {})).rejects.toThrow(/MRTR/); + await expect( + conn.request('tools/call', { name: 'echo', arguments: {} }) + ).rejects.toThrow(/MRTR/); }); }); diff --git a/src/connection/sdk-client.ts b/src/connection/sdk-client.ts index 3556ca7..3928f1a 100644 --- a/src/connection/sdk-client.ts +++ b/src/connection/sdk-client.ts @@ -6,9 +6,17 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { LoggingMessageNotificationSchema, - ProgressNotificationSchema + ProgressNotificationSchema, + type JSONRPCMessage, + type MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { + validateWireMessage, + type WireOrigin +} from '../validation/wire-schema'; import type { ConnectOptions } from './index'; const DEFAULT_CLIENT_INFO = { @@ -26,21 +34,100 @@ export interface MCPClientConnection { 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); validate each element with + // its own classification, then the batch envelope itself. + 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)}`}'`; + } + validateWireMessage(specVersion, m, { origin, context, requestMethod }); + } + if (Array.isArray(message)) { + 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`. + * 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`. */ export async function connectToServer( serverUrl: string, - opts: ConnectOptions = {} + opts: ConnectOptions = {}, + specVersion: SpecVersion = LATEST_SPEC_VERSION ): 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); diff --git a/src/connection/select.ts b/src/connection/select.ts index 592461c..6e51560 100644 --- a/src/connection/select.ts +++ b/src/connection/select.ts @@ -43,11 +43,11 @@ export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] = 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) - ? connectStateful - : // Pass the version through so stateless requests declare the spec - // version the run was invoked with (matters under --force). - (serverUrl, opts) => connectStateless(serverUrl, specVersion, opts); + ? (serverUrl, opts) => connectStateful(serverUrl, opts, specVersion) + : (serverUrl, opts) => connectStateless(serverUrl, specVersion, opts); } /** diff --git a/src/connection/stateful.ts b/src/connection/stateful.ts index 0fa9e20..31286ee 100644 --- a/src/connection/stateful.ts +++ b/src/connection/stateful.ts @@ -15,23 +15,29 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { connectToServer } from './sdk-client'; import type { JSONRPCNotification } from '../spec-types/2025-11-25'; +import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; import { JsonRpcError, type Connection, type ConnectOptions } from './index'; export async function connectStateful( serverUrl: string, - opts: ConnectOptions = {} + opts: ConnectOptions = {}, + specVersion: SpecVersion = LATEST_SPEC_VERSION ): Promise { - const { client, close } = await connectToServer(serverUrl, opts); + // 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 JSONRPCNotification wire shape, as - // connectStateless provides. - notifications.push({ + // 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); + } 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 @@ -73,7 +79,8 @@ export async function connectStateful( ); } try { - return (await client.request({ method, params }, ResultSchema)) as R; + 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 diff --git a/src/connection/stateless.test.ts b/src/connection/stateless.test.ts index 43b935e..7943696 100644 --- a/src/connection/stateless.test.ts +++ b/src/connection/stateless.test.ts @@ -13,6 +13,7 @@ import { 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', () => { @@ -87,7 +88,12 @@ describe('sendStatelessRequest', () => { JSON.stringify({ jsonrpc: '2.0', id: request.id, - result: { tools: [] } + result: { + tools: [], + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + } }) ); }); @@ -100,7 +106,12 @@ describe('sendStatelessRequest', () => { 'tools/list' ); expect(response.status).toBe(200); - expect(response.body?.result).toEqual({ tools: [] }); + expect(response.body?.result).toEqual({ + tools: [], + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private' + }); } finally { await new Promise((resolve) => server.close(() => resolve())); } @@ -151,6 +162,8 @@ describe('connectStateless', () => { 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 index 9ed7dac..38890ac 100644 --- a/src/connection/stateless.ts +++ b/src/connection/stateless.ts @@ -22,6 +22,7 @@ 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 { @@ -242,19 +243,47 @@ export async function sendStatelessRequest( 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 body = JSON.stringify({ + 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( @@ -275,6 +304,13 @@ export async function sendStatelessRequest( // 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, @@ -286,11 +322,19 @@ export async function sendStatelessRequest( 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: text ? (JSON.parse(text) as JsonRpcResponse) : undefined + body: parsed }; } catch { return { status: res.status, headers: res.headers, contentType, text }; diff --git a/src/index.ts b/src/index.ts index b82093c..788e470 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import { ServerOptionsSchema } from './schemas'; import type { AuthorizationServerOptions } from './schemas'; +import { withWireRecorder } from './validation/wire-schema'; import { loadExpectedFailures, evaluateBaseline, @@ -159,13 +160,17 @@ program const results = await Promise.all( scenarios.map(async (scenarioName) => { try { - const result = await runConformanceTest( - options.command, - scenarioName, - timeout, - outputDir, - specVersionFilter, - options.force ?? false + // 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, + options.force ?? false + ) ); return { scenario: scenarioName, @@ -452,11 +457,15 @@ program for (const scenarioName of scenarios) { console.log(`\n=== Running scenario: ${scenarioName} ===`); try { - const result = await runServerConformanceTest( - validated.url, - scenarioName, - outputDir, - specVersionFilter + // 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 + ) ); allResults.push({ scenario: scenarioName, checks: result.checks }); } catch (error) { diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts index 3c3a1d7..23cb3ae 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -9,6 +9,7 @@ import { } from './stateless'; import { STATELESS_SPEC_VERSIONS } from '../connection/select'; import { DRAFT_PROTOCOL_VERSION } from '../types'; +import { takeWireViolations } from '../validation/wire-schema'; const meta = { 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, @@ -180,9 +181,33 @@ describe('withRequiredDraftResultFields', () => { }); describe('createServerFor', () => { - it('returns stateful for dated 2025-x versions', () => { - expect(createServerFor('2025-06-18')).toBe(createServerStateful); - expect(createServerFor('2025-11-25')).toBe(createServerStateful); + 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)({}); @@ -232,6 +257,8 @@ describe('createServerStateless', () => { ); 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(); } @@ -307,7 +334,9 @@ describe('createServerStateless', () => { it('routes to handlers and records requests', async () => { const srv = await createServerStateless({ - 'tools/list': () => ({ tools: [{ name: 'x' }] }) + 'tools/list': () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' } }] + }) }); try { const { body } = await post( @@ -338,6 +367,8 @@ describe('createServerStateless', () => { ); 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(); } @@ -365,7 +396,9 @@ describe('createServerStateless', () => { it('stamps the draft-required result members onto handler results', async () => { const srv = await createServerStateless({ - 'tools/list': () => ({ tools: [{ name: 'x' }] }), + 'tools/list': () => ({ + tools: [{ name: 'x', inputSchema: { type: 'object' } }] + }), 'tools/call': () => ({ content: [{ type: 'text', text: 'ok' }] }) }); try { @@ -415,7 +448,7 @@ describe('createServerStateless', () => { jsonrpc: '2.0', id: 1, method: 'tools/call', - params: { _meta: meta } + params: { _meta: meta, name: 'x' } }, headers ); diff --git a/src/mock-server/select.ts b/src/mock-server/select.ts index 3458647..2a836f1 100644 --- a/src/mock-server/select.ts +++ b/src/mock-server/select.ts @@ -8,6 +8,6 @@ export function createServerFor( specVersion: SpecVersion ): (handlers: RequestHandlers) => Promise { return isStatefulVersion(specVersion) - ? createServerStateful + ? (handlers) => createServerStateful(handlers, specVersion) : (handlers) => createServerStateless(handlers, specVersion); } diff --git a/src/mock-server/stateful.ts b/src/mock-server/stateful.ts index 8dab541..773bca9 100644 --- a/src/mock-server/stateful.ts +++ b/src/mock-server/stateful.ts @@ -13,6 +13,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { JSONRPCRequest } from '../spec-types/2025-11-25'; +import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { validateWireMessage } from '../validation/wire-schema'; import type { MockServer, RequestHandlers } from './index'; const CAPABILITY_BY_PREFIX: Record = { @@ -40,7 +42,8 @@ export function capabilitiesFromHandlers( } export async function createServerStateful( - handlers: RequestHandlers + handlers: RequestHandlers, + specVersion: SpecVersion = LATEST_SPEC_VERSION ): Promise { const recorded: JSONRPCRequest[] = []; const capabilities = capabilitiesFromHandlers(handlers); @@ -61,10 +64,22 @@ export async function createServerStateful( }); server.setRequestHandler(schema, async (request) => { try { - return (await handler( + 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( @@ -85,6 +100,10 @@ export async function createServerStateful( // preamble) at the HTTP layer so unregistered methods are captured too, // matching the stateless impl and the MockServer.recorded contract. const body = req.body; + validateWireMessage(specVersion, body, { + origin: 'implementation', + context: `client request '${body?.method ?? '(unknown)'}' to stateful mock` + }); if ( body?.method && body.method !== 'initialize' && diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index 9968d41..89c4f50 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -9,10 +9,11 @@ */ import express from 'express'; -import type { SpecVersion } from '../types'; +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'; /** @@ -190,6 +191,7 @@ export async function createServerStateless( const supportedVersions: readonly string[] = specVersion ? [specVersion] : STATELESS_SPEC_VERSIONS; + const wireVersion = specVersion ?? DRAFT_PROTOCOL_VERSION; const app = express(); app.use(express.json()); @@ -200,16 +202,32 @@ export async function createServerStateless( // requests are captured too, matching the stateful impl and the // MockServer.recorded contract. const body = req.body as Record | 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) => { + validateWireMessage(wireVersion, payload, { + 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 res.status(v.status).json(v.body); + 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) => - res.status(status).json({ jsonrpc: '2.0', id, error: { code, message } }); + sendJson(status, { jsonrpc: '2.0', id, error: { code, message } }); const handler = handlers[method]; if (!handler) { @@ -217,11 +235,15 @@ export async function createServerStateless( } try { const result = await handler(params, req.body as JSONRPCRequest); - return res.json({ - jsonrpc: '2.0', - id, - result: withRequiredDraftResultFields(method, result) - }); + 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)); } diff --git a/src/runner/client.ts b/src/runner/client.ts index 2f9898c..af10657 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -10,6 +10,10 @@ import { } 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 { @@ -182,6 +186,7 @@ export async function runConformanceTest( }; console.error(`Starting scenario: ${scenarioName}`); + resetWireValidation(); const urls = await scenario.start(ctx); console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); @@ -215,6 +220,7 @@ export async function runConformanceTest( } const checks = scenario.getChecks(); + checks.push(...wireSchemaChecks(resolvedVersion)); if (resultDir) { await fs.writeFile( @@ -366,6 +372,7 @@ export async function runInteractiveMode( }; console.log(`Starting scenario: ${scenarioName}`); + resetWireValidation(); const urls = await scenario.start(ctx); console.log(`Server URL: ${urls.serverUrl}`); @@ -375,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.ts b/src/runner/server.ts index d19f340..a2c624a 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -8,6 +8,10 @@ import { } 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'; /** @@ -93,7 +97,9 @@ export async function runServerConformanceTest( specVersion: resolvedSpecVersion, connect: (opts) => connectFor(resolvedSpecVersion)(serverUrl, opts) }; + resetWireValidation(); const checks = await scenario.run(ctx); + checks.push(...wireSchemaChecks(resolvedSpecVersion)); if (resultDir) { await fs.writeFile( diff --git a/src/scenarios/server/elicitation-defaults.ts b/src/scenarios/server/elicitation-defaults.ts index f515cd7..20ed8ee 100644 --- a/src/scenarios/server/elicitation-defaults.ts +++ b/src/scenarios/server/elicitation-defaults.ts @@ -46,7 +46,7 @@ Implement a tool named \`test_elicitation_sep1034_defaults\` (no arguments) that 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 8b15475..ac04860 100644 --- a/src/scenarios/server/elicitation-enums.ts +++ b/src/scenarios/server/elicitation-enums.ts @@ -47,7 +47,7 @@ Implement a tool named \`test_elicitation_sep1330_enums\` (no arguments) that re 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 index 6ef5efd..056d3b3 100644 --- a/src/scenarios/server/http-standard-headers.test.ts +++ b/src/scenarios/server/http-standard-headers.test.ts @@ -56,6 +56,9 @@ describe('http-custom-header-server-validation — missing fixture policy', () = jsonrpc: '2.0', id: reqBody.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: 'plain_tool', @@ -99,6 +102,9 @@ describe('http-custom-header-server-validation — missing fixture policy', () = jsonrpc: '2.0', id: reqBody.id, result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', tools: [ { name: 'numeric_only', @@ -169,7 +175,16 @@ describe('http-header-validation — zero-tools Mcp-Name cases', () => { if (body.method === 'tools/list') { return { status: 200, - body: { jsonrpc: '2.0', id: body.id, result: { tools: [] } } + body: { + jsonrpc: '2.0', + id: body.id, + result: { + resultType: 'complete', + ttlMs: 0, + cacheScope: 'private', + tools: [] + } + } }; } return { diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index a89e847..a845a8f 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -22,9 +22,12 @@ import { sendStatelessRequest, JsonRpcResponse } from '../../connection'; export async function sendRpc( serverUrl: string, method: string, - params?: Record + params?: Record, + options?: { skipValidation?: boolean } ): Promise { - const response = await sendStatelessRequest(serverUrl, method, params); + 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'})` diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 566b182..66efb3f 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -1567,14 +1567,21 @@ and return a JSON-RPC error or a new InputRequiredResult.`; const checks: ConformanceCheck[] = []; try { - // Send inputResponses with invalid structure (number instead of object for the response) - const resp = await sendRpc(serverUrl, 'tools/call', { - name: 'test_input_required_result_elicitation', - arguments: {}, - inputResponses: { - user_name: 12345 as unknown as Record - } - }); + // 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[] = []; @@ -1599,12 +1606,18 @@ and return a JSON-RPC error or a new InputRequiredResult.`; details: { result: resp.result, error: resp.error } }); - // Also test: send completely malformed inputResponses (null) - const resp2 = await sendRpc(serverUrl, 'tools/call', { - name: 'test_input_required_result_elicitation', - arguments: {}, - inputResponses: null as unknown as Record - }); + // 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[] = []; diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts index d787fe8..899d63b 100644 --- a/src/scenarios/server/lifecycle.test.ts +++ b/src/scenarios/server/lifecycle.test.ts @@ -31,7 +31,11 @@ describe('ServerInitializeScenario', () => { testContext(serverUrl) ); - expect(connectToServer).toHaveBeenCalledWith(serverUrl); + expect(connectToServer).toHaveBeenCalledWith( + serverUrl, + {}, + testContext(serverUrl).specVersion + ); expect(closeMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledWith( serverUrl, diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 61fac0b..2e2e0c9 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -45,7 +45,7 @@ and validates session ID format if one is assigned.`; 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 diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts index 840c592..3022840 100644 --- a/src/scenarios/server/negative-mrtr.test.ts +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -15,6 +15,25 @@ import { InputRequiredResultUnsupportedMethodsScenario, InputRequiredResultTamperedStateScenario } 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) => { diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 583c5eb..0d24827 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -9,6 +9,7 @@ import { 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) => { @@ -158,6 +159,10 @@ describe('Server scenario negative tests', () => { 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); }); diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index 8846485..f18df3c 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -485,7 +485,7 @@ Implement tool \`test_tool_with_logging\` with no arguments. 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 @@ -813,7 +813,7 @@ Implement tool \`test_sampling\` with argument: const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); let samplingRequested = false; connection.client.setRequestHandler( @@ -950,7 +950,7 @@ Implement tool \`test_elicitation\` with argument: const checks: ConformanceCheck[] = []; try { - const connection = await connectToServer(serverUrl); + const connection = await connectToServer(serverUrl, {}, ctx.specVersion); let elicitationRequested = false; connection.client.setRequestHandler( 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-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-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/README.md b/src/spec-types/README.md index 77580c0..f131874 100644 --- a/src/spec-types/README.md +++ b/src/spec-types/README.md @@ -1,14 +1,19 @@ # spec-types -Vendored copies of `schema/{version}/schema.ts` from the +Vendored copies of `schema/{version}/schema.ts` and +`schema/{version}/schema.json` from the [modelcontextprotocol](https://github.com/modelcontextprotocol/modelcontextprotocol) spec repository. -These are the canonical TypeScript types for each protocol version. The -conformance suite imports types from here rather than from +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 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/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..1c4f763 --- /dev/null +++ b/src/validation/wire-schema.test.ts @@ -0,0 +1,509 @@ +import { createServer, type Server } from 'http'; +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 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([]); + }); +}); diff --git a/src/validation/wire-schema.ts b/src/validation/wire-schema.ts new file mode 100644 index 0000000..b7e2b25 --- /dev/null +++ b/src/validation/wire-schema.ts @@ -0,0 +1,420 @@ +// 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' +]); + +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 bugs in released (frozen) generated spec schemas at load time + * so validation matches the schema.ts source of truth. Each erratum cites the + * upstream fix; delete it once the dated schema.json is corrected upstream + * and re-vendored. */ +function applySchemaErrata( + specVersion: SpecVersion, + schema: Record +): Record { + if (specVersion !== '2025-11-25') return schema; + // 2025-11-25 generates NumberSchema minimum/maximum/default as `integer`, + // contradicting its own schema.ts (`default?: number`). Fixed for draft in + // modelcontextprotocol#2710, which left the released schema.json as-is. + 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. + const inputRequired = + (msg.result as Record | null)?.resultType === + 'input_required' && 'InputRequiredResult' in spec.defs; + const resultDefName = inputRequired + ? 'InputRequiredResult' + : requestMethod !== undefined + ? spec.resultDefs.get(requestMethod) + : undefined; + if (resultDefName) { + const typed = validateAgainst(resultDefName, msg.result).map( + (e) => `${e} (result of '${requestMethod}')` + ); + 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/vitest.config.ts b/vitest.config.ts index bd0c67a..946044f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,8 @@ 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/**', From 5cc567c39912bc6105b14287d42abed2956e7955 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 27 Jul 2026 20:32:17 +0100 Subject: [PATCH 197/226] Fix wire-validation attribution bugs and extend the schema erratum to 2025-06-18 (#421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the #399 review: - Stateless mock: validate error replies with a placeholder id so the JSON-RPC-mandated id:null is not recorded as a harness violation, and skip inbound validation when express left the body unparsed (wrong or missing Content-Type) — both were false positives blamed on the wrong party. Same unparsed-body guard in the stateful mock. - Stateful mock: validate client traffic against the protocol version the client declares via MCP-Protocol-Version, falling back to 2025-03-26 when the header is absent (it only exists from 2025-06-18, whose spec says to assume 2025-03-26), instead of always using the run version. - sdk-client: validate batches once as a whole array instead of also per element, which double-recorded every violation. - Make specVersion required on connectToServer/connectStateful/ createServerStateful; add isSpecVersion to types.ts. - Extend the NumberSchema erratum to 2025-06-18 (same generator bug on minimum/maximum, modelcontextprotocol#3139) and add a tripwire test that forces erratum deletion once the schemas are re-vendored. - Document the wire-schema-valid / wire-schema-harness-error check IDs in the README. --- README.md | 18 ++++++ src/connection/sdk-client.ts | 17 ++++-- src/connection/select.ts | 2 +- src/connection/stateful.ts | 6 +- src/mock-server/mock-server.test.ts | 93 +++++++++++++++++++++++++---- src/mock-server/stateful.ts | 29 +++++++-- src/mock-server/stateless.ts | 24 ++++++-- src/types.ts | 5 ++ src/validation/wire-schema.test.ts | 29 +++++++++ src/validation/wire-schema.ts | 17 +++--- 10 files changed, 201 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8fa48af..d1c9154 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,24 @@ 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. + ## 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. diff --git a/src/connection/sdk-client.ts b/src/connection/sdk-client.ts index 3928f1a..d008050 100644 --- a/src/connection/sdk-client.ts +++ b/src/connection/sdk-client.ts @@ -12,7 +12,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; -import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { type SpecVersion } from '../types'; import { validateWireMessage, type WireOrigin @@ -51,8 +51,8 @@ function instrumentTransport( origin === 'harness' ? harnessRequests : implementationRequests; const peerRequests = origin === 'harness' ? implementationRequests : harnessRequests; - // `send` accepts arrays (2025-03-26 batches); validate each element with - // its own classification, then the batch envelope itself. + // `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, @@ -73,9 +73,14 @@ function instrumentTransport( if (id !== undefined) peerRequests.delete(id); context = `stateful response to '${requestMethod ?? `id ${String(id)}`}'`; } - validateWireMessage(specVersion, m, { origin, context, requestMethod }); + 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' @@ -119,8 +124,8 @@ function instrumentTransport( */ export async function connectToServer( serverUrl: string, - opts: ConnectOptions = {}, - specVersion: SpecVersion = LATEST_SPEC_VERSION + opts: ConnectOptions, + specVersion: SpecVersion ): Promise { const client = new Client(opts.clientInfo ?? DEFAULT_CLIENT_INFO, { capabilities: opts.capabilities ?? DEFAULT_CAPABILITIES diff --git a/src/connection/select.ts b/src/connection/select.ts index 6e51560..4667900 100644 --- a/src/connection/select.ts +++ b/src/connection/select.ts @@ -46,7 +46,7 @@ export function connectFor( // 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) => connectStateful(serverUrl, opts ?? {}, specVersion) : (serverUrl, opts) => connectStateless(serverUrl, specVersion, opts); } diff --git a/src/connection/stateful.ts b/src/connection/stateful.ts index 31286ee..3361f28 100644 --- a/src/connection/stateful.ts +++ b/src/connection/stateful.ts @@ -15,13 +15,13 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { connectToServer } from './sdk-client'; import type { JSONRPCNotification } from '../spec-types/2025-11-25'; -import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { type SpecVersion } from '../types'; import { JsonRpcError, type Connection, type ConnectOptions } from './index'; export async function connectStateful( serverUrl: string, - opts: ConnectOptions = {}, - specVersion: SpecVersion = LATEST_SPEC_VERSION + 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 diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts index 23cb3ae..43ecca6 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -8,7 +8,7 @@ import { CACHEABLE_RESULT_METHODS } from './stateless'; import { STATELESS_SPEC_VERSIONS } from '../connection/select'; -import { DRAFT_PROTOCOL_VERSION } from '../types'; +import { LATEST_SPEC_VERSION, DRAFT_PROTOCOL_VERSION } from '../types'; import { takeWireViolations } from '../validation/wire-schema'; const meta = { @@ -500,9 +500,10 @@ describe('createServerStateful', () => { } it('accepts initialize and routes to handlers, recording non-preamble', async () => { - const srv = await createServerStateful({ - 'tools/list': () => ({ tools: [] }) - }); + 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. @@ -524,9 +525,10 @@ describe('createServerStateful', () => { }); it('derives capabilities from handler keys; non-tools handler does not 500 initialize', async () => { - const srv = await createServerStateful({ - 'prompts/list': () => ({ prompts: [] }) - }); + const srv = await createServerStateful( + { 'prompts/list': () => ({ prompts: [] }) }, + LATEST_SPEC_VERSION + ); try { const { status, contentType } = await postInit(srv.url); expect(status).toBe(200); @@ -537,9 +539,10 @@ describe('createServerStateful', () => { }); it('records requests for unregistered methods (parity with stateless)', async () => { - const srv = await createServerStateful({ - 'tools/list': () => ({ tools: [] }) - }); + const srv = await createServerStateful( + { 'tools/list': () => ({ tools: [] }) }, + LATEST_SPEC_VERSION + ); try { const { Client } = await import('@modelcontextprotocol/sdk/client/index.js'); @@ -565,3 +568,73 @@ describe('createServerStateful', () => { } }); }); + +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/stateful.ts b/src/mock-server/stateful.ts index 773bca9..0a80b7f 100644 --- a/src/mock-server/stateful.ts +++ b/src/mock-server/stateful.ts @@ -13,7 +13,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { JSONRPCRequest } from '../spec-types/2025-11-25'; -import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { isSpecVersion, type SpecVersion } from '../types'; import { validateWireMessage } from '../validation/wire-schema'; import type { MockServer, RequestHandlers } from './index'; @@ -43,7 +43,7 @@ export function capabilitiesFromHandlers( export async function createServerStateful( handlers: RequestHandlers, - specVersion: SpecVersion = LATEST_SPEC_VERSION + specVersion: SpecVersion ): Promise { const recorded: JSONRPCRequest[] = []; const capabilities = capabilitiesFromHandlers(handlers); @@ -100,10 +100,27 @@ export async function createServerStateful( // preamble) at the HTTP layer so unregistered methods are captured too, // matching the stateless impl and the MockServer.recorded contract. const body = req.body; - validateWireMessage(specVersion, body, { - origin: 'implementation', - context: `client request '${body?.method ?? '(unknown)'}' to stateful mock` - }); + // 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' && diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index 89c4f50..3d06c86 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -202,15 +202,29 @@ export async function createServerStateless( // requests are captured too, matching the stateful impl and the // MockServer.recorded contract. const body = req.body as Record | undefined; - validateWireMessage(wireVersion, req.body, { - origin: 'implementation', - context: `client request '${body?.method ?? '(unknown)'}' to stateless mock` - }); + // 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) => { - validateWireMessage(wireVersion, payload, { + // 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 diff --git a/src/types.ts b/src/types.ts index a6e0225..0867e24 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,6 +69,11 @@ const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [ 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 diff --git a/src/validation/wire-schema.test.ts b/src/validation/wire-schema.test.ts index 1c4f763..98fc4d7 100644 --- a/src/validation/wire-schema.test.ts +++ b/src/validation/wire-schema.test.ts @@ -1,4 +1,6 @@ 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 { @@ -506,4 +508,31 @@ describe('schema errata', () => { ) ).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 index b7e2b25..8b45849 100644 --- a/src/validation/wire-schema.ts +++ b/src/validation/wire-schema.ts @@ -75,18 +75,19 @@ interface CompiledSpec { const compiledSpecs = new Map(); -/** Patch known bugs in released (frozen) generated spec schemas at load time - * so validation matches the schema.ts source of truth. Each erratum cites the - * upstream fix; delete it once the dated schema.json is corrected upstream - * and re-vendored. */ +/** 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') return schema; - // 2025-11-25 generates NumberSchema minimum/maximum/default as `integer`, - // contradicting its own schema.ts (`default?: number`). Fixed for draft in - // modelcontextprotocol#2710, which left the released schema.json as-is. + 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, From 49103de6ed70804e940637bf3e9e29e4a3f54e64 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 28 Jul 2026 10:56:25 +0100 Subject: [PATCH 198/226] feat(sdk-runner): add rust-sdk + per-spec-version config overlays (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sdk-runner): add rust-sdk to KNOWN_SDKS The mcp-conformance package's fixture bins follow the harness contract directly (URL argv + MCP_CONFORMANCE_SCENARIO env), and the server selects the SEP-2575 stateless lifecycle via STATELESS=1. Requires the repo's pinned Rust toolchain (rustup installs it on demand). * feat(sdk-runner): per-spec-version config overlays (specOverrides) Several KNOWN_SDKS entries needed a different invocation when targeting 2026-07-28 — go-sdk's -stateless flag, csharp-sdk's /stateless URL, rust-sdk's STATELESS=1 env, python-sdk's per-revision baseline — and each carried a comment telling the caller which flag to paste. Encode those variants in the entry instead: specOverrides maps a canonical spec version to a partial config that is merged over the base entry when a run targets that version (--spec-version, after 'draft' alias resolution, or the entry's own specVersion default). Explicit CLI flags still win. Keys are validated against the known spec versions at schema level so a 'draft' key or a typo'd date fails loudly instead of silently never matching. * style: prettier fixes in sdk-runner tests * refactor(sdk-runner): reuse isSpecVersion for specOverrides key validation Reuses the canonical spec-version list from #421 instead of keeping a separate copy; SPEC_VERSION_TIMELINE is now exported so the validation message can enumerate the valid keys. --- README.md | 23 ++++++--- src/sdk-runner/config.ts | 84 ++++++++++++++++++++++++++++++- src/sdk-runner/index.ts | 19 ++++--- src/sdk-runner/known-sdks.ts | 60 +++++++++++++++++----- src/sdk-runner/sdk-runner.test.ts | 81 ++++++++++++++++++++++++++++- src/types.ts | 2 +- 6 files changed, 241 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d1c9154..c320f48 100644 --- a/README.md +++ b/README.md @@ -299,14 +299,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). Overriding built-in fields for a one-off run: +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 -# e.g. a 2026-07-28 run against go-sdk's server — the built-in server command -# pins -stateless=false (what the dated-spec suites need), so override it to -# get the SEP-2575 stateless lifecycle: -npm start -- sdk go-sdk --mode server --suite all --spec-version 2026-07-28 \ - --server-cmd './.conformance-server -http=localhost:3000' +# 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`. diff --git a/src/sdk-runner/config.ts b/src/sdk-runner/config.ts index 1f8a9e5..edcd77a 100644 --- a/src/sdk-runner/config.ts +++ b/src/sdk-runner/config.ts @@ -1,4 +1,32 @@ 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 @@ -23,7 +51,61 @@ export const SdkConfigSchema = z.object({ // 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() + 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 index d4ac74d..cfe959c 100644 --- a/src/sdk-runner/index.ts +++ b/src/sdk-runner/index.ts @@ -1,9 +1,10 @@ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { Command, Option } from 'commander'; -import { SdkConfig } from './config'; +import { SdkConfig, resolveConfigForSpec } from './config'; import { parseSdkSpec, ensureCheckout } from './checkout'; import { lookupBuiltinConfig, knownSdkNames } from './known-sdks'; +import { resolveSpecVersion } from '../scenarios'; type Mode = 'client' | 'server'; @@ -190,7 +191,17 @@ export function createSdkCommand(): Command { spec?.name ?? path.basename(path.resolve(options.path!)); // Resolution: CLI flag > built-in entry (KNOWN_SDKS). - const builtinConfig: SdkConfig = lookupBuiltinConfig(sdkName) ?? {}; + 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. + const requestedSpec = 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). @@ -223,10 +234,6 @@ export function createSdkCommand(): Command { const output = options.output ? path.resolve(options.output) : undefined; - // Explicit flag wins over the per-SDK default. - const specVersion: string | undefined = - options.specVersion ?? builtinConfig.specVersion; - if (buildCmd && !options.skipBuild) { console.error(`[sdk] Building: ${buildCmd}`); await execShell(buildCmd, dir); diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts index 0e02c29..9c43067 100644 --- a/src/sdk-runner/known-sdks.ts +++ b/src/sdk-runner/known-sdks.ts @@ -40,10 +40,9 @@ export const KNOWN_SDKS: Record = { }, // Fixtures live under conformance/ (everything-client + everything-server, // mirroring scripts/{client,server}-conformance.sh). The server's -stateless - // flag defaults to true; -stateless=false pins the stateful transport, which - // the dated-spec (`active` suite) scenarios need — for a 2026-07-28 / - // SEP-2575 run, override with --server-cmd './.conformance-server -http=localhost:3000' - // to get the stateless lifecycle. + // 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', @@ -54,14 +53,17 @@ export const KNOWN_SDKS: Record = { command: './.conformance-server -http=localhost:3000 -stateless=false', url: 'http://localhost:3000' }, - expectedFailures: 'conformance/baseline.yml' + 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: expected-failures.yml - // covers runs at the latest dated spec (the default here), and - // expected-failures.2026-07-28.yml covers --spec-version 2026-07-28 runs — - // pass the latter via --expected-failures when targeting the new revision. + // workspace package). Two baselines exist upstream, one per spec target; + // the 2026-07-28 override picks the matching one. 'python-sdk': { build: 'uv sync --frozen --all-extras --all-packages', client: { @@ -71,7 +73,13 @@ export const KNOWN_SDKS: Record = { command: 'uv run --frozen mcp-everything-server --port 3000', url: 'http://localhost:3000/mcp' }, - expectedFailures: '.github/actions/conformance/expected-failures.yml' + 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` @@ -96,6 +104,29 @@ export const KNOWN_SDKS: Record = { }, 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. + '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' + }, + specOverrides: { + '2026-07-28': { + server: { + command: 'STATELESS=1 PORT=3000 ./target/debug/conformance-server' + } + } + } + }, // 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 @@ -103,8 +134,8 @@ export const KNOWN_SDKS: Record = { // 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 — for a 2026-07-28 run, - // override with --server-url http://localhost:3000/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', @@ -115,6 +146,11 @@ export const KNOWN_SDKS: Record = { command: 'dotnet artifacts/bin/ModelContextProtocol.ConformanceServer/Release/net10.0/ModelContextProtocol.ConformanceServer.dll --urls http://localhost:3000', url: 'http://localhost:3000' + }, + specOverrides: { + '2026-07-28': { + server: { url: 'http://localhost:3000/stateless' } + } } } }; diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts index f7363fa..7ce0f79 100644 --- a/src/sdk-runner/sdk-runner.test.ts +++ b/src/sdk-runner/sdk-runner.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { parseSdkSpec } from './checkout'; -import { SdkConfigSchema } from './config'; 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)', () => { @@ -74,7 +74,7 @@ describe('lookupBuiltinConfig', () => { }); it('returns null for unknown SDKs', () => { - expect(lookupBuiltinConfig('rust-sdk')).toBeNull(); + expect(lookupBuiltinConfig('swift-sdk')).toBeNull(); }); it('exposes python-sdk-v1 with repo + defaultRef + specVersion and both commands', () => { @@ -133,9 +133,86 @@ describe('lookupBuiltinConfig', () => { 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('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('prefixes STATELESS=1 for rust-sdk at 2026-07-28', () => { + const resolved = resolveConfigForSpec(KNOWN_SDKS['rust-sdk'], '2026-07-28'); + expect(resolved.server?.command).toBe( + 'STATELESS=1 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/types.ts b/src/types.ts index 0867e24..5960945 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,7 +64,7 @@ export const NEGOTIABLE_PROTOCOL_VERSIONS: readonly string[] = [ export type SpecVersion = DatedSpecVersion | typeof DRAFT_PROTOCOL_VERSION; /** Spec versions in timeline order, dated revisions followed by the draft. */ -const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [ +export const SPEC_VERSION_TIMELINE: readonly SpecVersion[] = [ ...DATED_SPEC_VERSIONS, DRAFT_PROTOCOL_VERSION ]; From c5af5673ba57e4a8a6ea8b476b58c254031e6dc0 Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Sat, 1 Aug 2026 01:24:13 +0900 Subject: [PATCH 199/226] fix: send HTTP DELETE to terminate server sessions after each scenario (#316) * fix: send HTTP DELETE to terminate server sessions after each scenario The conformance test harness opened sessions against servers under test but never issued an HTTP DELETE to terminate them, leaving a dangling session on the server after each scenario. This violates the "hermetic" expectation called out in the Streamable HTTP transport spec. - `connection/sdk-client.connectToServer` now calls `transport.terminateSession()` before `client.close()`. A 405 response (DELETE not supported) is tolerated because the spec allows the server to refuse. - A new `terminateSessionRaw` helper handles scenarios that bypass the SDK client and open sessions via raw `fetch` (`lifecycle.ts`, `http-standard-headers.ts`). - `sse-polling.ts` and `sse-multiple-streams.ts`, which manage their own transport, now terminate the session in their existing `finally` block before closing the client. - `lifecycle.test.ts` gains two cases asserting that DELETE is sent with the issued session ID and that no DELETE is sent when the server did not assign one. Verified manually against `examples/servers/typescript/everything-server.ts` that running a server scenario now causes the server to log `Received session termination request for session `. Fixes #79 Signed-off-by: Satoshi Ito * Use negotiated protocol version for teardown DELETE and bound it Review follow-ups on the session-termination change: - terminateSessionRaw: abort the DELETE after 5s so a wedged server cannot stall suite teardown. - terminateSessionBestEffort: shared SDK-path teardown helper that delegates to terminateSessionRaw with the transport's negotiated protocol version, so the socket is truly released at the bound and the version-mismatch 400 on strict servers (the failure mode of issue #79) goes away. Used by connectToServer close() and both SSE scenarios, replacing their inline try/catch blocks. - lifecycle.ts: the raw session-id probe and its teardown DELETE now use ctx.specVersion instead of a hardcoded 2025-11-25, matching the negotiated-version convention from #415/#416. - Add sdk-client.test.ts regression tests asserting close() sends a DELETE with the issued session ID (and none without one), so a bad future merge cannot silently drop the fix. --------- Signed-off-by: Satoshi Ito Co-authored-by: Paul Carleton --- src/connection/sdk-client.test.ts | 77 ++++++++++++++++++++ src/connection/sdk-client.ts | 54 ++++++++++++++ src/scenarios/server/lifecycle.test.ts | 44 ++++++++++- src/scenarios/server/lifecycle.ts | 15 +++- src/scenarios/server/sse-multiple-streams.ts | 7 +- src/scenarios/server/sse-polling.ts | 7 +- 6 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 src/connection/sdk-client.test.ts 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 index d008050..0d7575d 100644 --- a/src/connection/sdk-client.ts +++ b/src/connection/sdk-client.ts @@ -121,6 +121,11 @@ function instrumentTransport( * 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, @@ -139,11 +144,60 @@ export async function connectToServer( 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) */ diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts index 899d63b..ab3628a 100644 --- a/src/scenarios/server/lifecycle.test.ts +++ b/src/scenarios/server/lifecycle.test.ts @@ -2,9 +2,14 @@ import { testContext } from '../../connection/testing'; import { ServerInitializeScenario } from './lifecycle'; import { connectToServer } from '../../connection/sdk-client'; -vi.mock('../../connection/sdk-client', () => ({ - connectToServer: vi.fn() -})); +vi.mock('../../connection/sdk-client', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + connectToServer: vi.fn() + }; +}); describe('ServerInitializeScenario', () => { const serverUrl = 'http://localhost:3000/mcp'; @@ -102,4 +107,37 @@ describe('ServerInitializeScenario', () => { } }); }); + + 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); + }); }); diff --git a/src/scenarios/server/lifecycle.ts b/src/scenarios/server/lifecycle.ts index 2e2e0c9..7be721b 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -8,7 +8,10 @@ import { DRAFT_PROTOCOL_VERSION } from '../../types'; import type { RunContext } from '../../connection'; -import { connectToServer } from '../../connection/sdk-client'; +import { + connectToServer, + terminateSessionRaw +} from '../../connection/sdk-client'; const VISIBLE_ASCII_REGEX = /^[\x21-\x7E]+$/; @@ -91,20 +94,21 @@ and validates session ID format if one is assigned.`; // 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': '2025-11-25' + 'mcp-protocol-version': ctx.specVersion }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { - protocolVersion: '2025-11-25', + protocolVersion: ctx.specVersion, capabilities: {}, clientInfo: { name: 'conformance-session-id-test', @@ -115,6 +119,7 @@ and validates session ID format if one is assigned.`; }); const sessionId = response.headers.get('mcp-session-id'); + rawSessionId = sessionId; if (!sessionId) { checks.push({ @@ -170,6 +175,10 @@ and validates session ID format if one is assigned.`; 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/sse-multiple-streams.ts b/src/scenarios/server/sse-multiple-streams.ts index d2eb493..91bd24c 100644 --- a/src/scenarios/server/sse-multiple-streams.ts +++ b/src/scenarios/server/sse-multiple-streams.ts @@ -18,6 +18,7 @@ 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'; @@ -293,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.ts b/src/scenarios/server/sse-polling.ts index a15d5e9..280fe79 100644 --- a/src/scenarios/server/sse-polling.ts +++ b/src/scenarios/server/sse-polling.ts @@ -17,6 +17,7 @@ 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 => { @@ -598,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(); From 3531a6480341f3cc520f4d7a3a517bc825191e31 Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Sat, 1 Aug 2026 01:51:20 +0900 Subject: [PATCH 200/226] feat: add session-lifecycle conformance scenario for streamable HTTP (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add session-lifecycle conformance scenario for streamable HTTP This commits a `server-session-lifecycle` scenario that verifies the server honors the two RFC 2119 statements the Streamable HTTP transport spec places on session termination: 1. After receiving an HTTP DELETE bearing the issued Mcp-Session-Id, the server accepts it (2xx) or signals that explicit termination is not supported (405). 2. Subsequent requests bearing the terminated session ID MUST get HTTP 404 Not Found. The scenario inlines a raw `fetch` initialize/terminate/probe flow so the DELETE is the test action (not background cleanup). Stateless servers that never issue a session ID are reported as INFO, and the lifecycle checks are SKIPPED. Servers that return 405 on DELETE skip both checks without flagging a failure — the spec allows servers to refuse explicit termination. Refs #79 Signed-off-by: Satoshi Ito * fix: address review feedback on session-lifecycle scenario - Adapt run() to the RunContext signature introduced in #318. - Send the protocol version the server negotiated in the initialize result on follow-up requests instead of hardcoding 2025-11-25, falling back to the requested spec version (the sse-multiple-streams pattern from #415/#416). Parsing the initialize body is bounded by a 5s timeout, and every fetch shares one AbortController aborted when the scenario ends, so a server answering with a never-ending SSE stream can neither hang the run nor leak a held-open connection. - Fail the scenario when initialize itself fails; previously a broken server (401/500) was reported as stateless and passed. - Check the initialized notification response and fail when it is rejected, emitting SKIPPED placeholders for the lifecycle checks so per-check aggregation keeps its rows. - Report unexpected DELETE statuses as WARNING instead of FAILURE (the spec never pins a success status for session termination). - Treat 404 on DELETE as an already-terminated session only when the follow-up request confirms the session is gone; a 404 from a server whose session still responds (likely no DELETE route) is a WARNING with the 404 check skipped, rather than a misattributed failure. - Exclude the scenario from the draft spec version via removedIn: sessions were removed entirely by SEP-2575. --------- Signed-off-by: Satoshi Ito Co-authored-by: Paul Carleton --- .../servers/typescript/everything-server.ts | 20 +- src/scenarios/index.ts | 2 + .../server/session-lifecycle.test.ts | 314 +++++++++++++ src/scenarios/server/session-lifecycle.ts | 415 ++++++++++++++++++ 4 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 src/scenarios/server/session-lifecycle.test.ts create mode 100644 src/scenarios/server/session-lifecycle.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index e7a6878..d7de222 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -2366,6 +2366,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', @@ -2425,11 +2437,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/src/scenarios/index.ts b/src/scenarios/index.ts index db1584e..8081b81 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -18,6 +18,7 @@ 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 { @@ -156,6 +157,7 @@ const pendingClientScenariosList: ClientScenario[] = [ const allClientScenariosList: ClientScenario[] = [ // Lifecycle scenarios new ServerInitializeScenario(), + new SessionLifecycleScenario(), new ServerStatelessScenario(), // Utilities scenarios 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 } }); +} From 81eb1c3edaed87d7fd585d7b80186da7a2960660 Mon Sep 17 00:00:00 2001 From: Satoshi Ito Date: Sat, 1 Aug 2026 02:24:05 +0900 Subject: [PATCH 201/226] feat: add client-side JSON Schema 2020-12 preservation scenario (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add client-side JSON Schema 2020-12 preservation scenario The mock server, using the version-aware `MockServer` abstraction from #321, first advertises a focal tool with a detailed inputSchema and also offers a permissive `json_schema_echo` tool. The client under test retrieves the inputSchema via `tools/list`, then sends the same inputSchema back to the server using the echo tool, completing the round-trip. The scenario diffs from the echoed schema against the canonical fixture. It emits eight per-keyword conformance checks. SEP-2106 vocabulary checks are soft-gated using the existing sep2106KeywordCheckStatus helper. They only flip to FAILURE during DRAFT-2026-v1 runs. Signed-off-by: Satoshi Ito * Drive draft-target preservation tests through the stateless lifecycle The stateful test helper passed createServerStateful bare, which no longer compiles now that it takes a second specVersion argument, and forcing the stateful mock at the draft version trips wire-schema validation (draft results require the SEP-2549 resultType/ttlMs/cacheScope members). Use testScenarioContext(DRAFT_PROTOCOL_VERSION), which selects the stateless mock server, and drive it with sendStatelessRequest, matching the pattern in json-schema-ref-deref.test.ts. * Reword client keyword-preservation rows as issue-derived expectations SEP-2106 §1 contains no normative preservation sentence, so the six new sep-2106.yaml rows should not attribute a spec MUST to it. Reword the remaining rows as expectations derived from issue #101. Also drop the three rows whose check IDs are not sep-prefixed: the traceability manifest only collects IDs matching /^sep-\d+-/, so those rows could never be marked tested. * Replace stale DRAFT-2026-v1 references with the 2026-07-28 draft name The draft protocol identifier was renamed in #331. Interpolate DRAFT_PROTOCOL_VERSION into the SKIPPED errorMessage suffix (matching the server-side scenario) and update the header comment. --------- Signed-off-by: Satoshi Ito Co-authored-by: Paul Carleton --- .../clients/typescript/everything-client.ts | 63 +++ .../json-schema-2020-12-preservation.test.ts | 271 ++++++++++++ .../json-schema-2020-12-preservation.ts | 403 ++++++++++++++++++ src/scenarios/index.ts | 6 +- src/scenarios/server/json-schema-2020-12.ts | 88 ++-- src/seps/sep-2106.yaml | 19 + 6 files changed, 812 insertions(+), 38 deletions(-) create mode 100644 src/scenarios/client/json-schema-2020-12-preservation.test.ts create mode 100644 src/scenarios/client/json-schema-2020-12-preservation.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index c91d0b8..111f97e 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -228,6 +228,69 @@ async function runListToolsOnlyClient(serverUrl: string): Promise { 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) // ============================================================================ 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/index.ts b/src/scenarios/index.ts index 8081b81..03cf55f 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -119,6 +119,7 @@ import { 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[] = [ @@ -313,7 +314,10 @@ const scenariosList: Scenario[] = [ new HttpInvalidToolHeadersScenario(), // JSON Schema network $ref dereferencing (SEP-2106) - new JsonSchemaRefDerefScenario() + new JsonSchemaRefDerefScenario(), + + // JSON Schema 2020-12 client-side keyword preservation (SEP-1613, SEP-2106) + new JsonSchema2020_12PreservationScenario() ]; // Core scenarios (tier 1 requirements) diff --git a/src/scenarios/server/json-schema-2020-12.ts b/src/scenarios/server/json-schema-2020-12.ts index 23c4e22..d1487a5 100644 --- a/src/scenarios/server/json-schema-2020-12.ts +++ b/src/scenarios/server/json-schema-2020-12.ts @@ -20,8 +20,48 @@ import { 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. @@ -55,41 +95,15 @@ export class JsonSchema2020_12Scenario implements ClientScenario { 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": { - "$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 - } -} +${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 (SEP-1613), and that the composition (\`allOf\`/\`anyOf\`), conditional (\`if\`/\`then\`/\`else\`), and \`$anchor\` keywords are preserved (SEP-2106), in the tool listing response.`; diff --git a/src/seps/sep-2106.yaml b/src/seps/sep-2106.yaml index 870484e..6fc7793 100644 --- a/src/seps/sep-2106.yaml +++ b/src/seps/sep-2106.yaml @@ -4,6 +4,25 @@ 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 From 232a9014457eaf4c59559916e3d616d2f7f28f05 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:55:10 +0100 Subject: [PATCH 202/226] Freeze conformance requirements per spec revision, run each at its wire (#447) There is no way to answer "which scenarios must my implementation pass to conform to spec revision X". The suite accumulates scenarios continuously, so what an implementation is measured against keeps growing after the revision shipped, and it can drop below 100% while standing still. --spec-version does not answer it: it filters on which revision a scenario targets, not on when the scenario became a requirement. Add requirements/.yaml naming the scenarios a revision requires, and --requirements on the client, server, list and sdk commands to run exactly that set. The revision also fixes the wire version its scenarios speak: 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 belonging to two revisions runs once under each and one run does not cover the other. Passing no flag keeps today's behaviour of running everything. tier-check gains --sdk / --sdk-path : for each claimed revision it resolves that revision's build, server invocation and client command from the sdk-runner's KNOWN_SDKS specOverrides, manages the server, runs both legs with cwd = the SDK checkout, and merges. Every claimed revision must pass for Tier 1. This is what makes multi-revision tiering correct for SDKs whose wire era is fixed per server process (go-sdk only speaks >= 2026-07-28 when constructed stateless) or per endpoint (csharp-sdk serves it at /stateless): one --conformance-server-url cannot describe them, and their config already does. The single-URL mode remains for SDKs not in the config. Ship two sets. 2026-07-28 is a snapshot of 0.2.0-alpha.10, published the day before that revision shipped. 2025-11-25 is a reconstruction from the same anchor and its header says so: the release current on its ship date predates spec-version awareness entirely. not_scored entries run and are reported with a reason (extension per SEP-1730, or added-after-release) but never affect a pass rate or the exit code, and --expected-failures judges scored scenarios only. Reports label every entry with its revision, and JSON passed/failed/total describe the scored set so they always agree with pass_rate. tier-check's exit code now gates on the machine-checkable half: nonzero when a scored scenario fails or a leg could not be measured. A child run that cannot happen at all (a set naming a renamed scenario, an unreachable server) reports as not measured rather than as 0% against the implementation. Conformance runs to completion before the GitHub checks, so an API failure can no longer interrupt a leg and orphan a managed server, and the legs spawn their child asynchronously: the previous synchronous exec blocked the event loop, the parent stopped draining the managed server's log pipes, and a chatty server (rust-sdk's per-request tracing) froze on a full pipe and was scored as failing. Driving every tier-1 SDK through the new path also fixed three configs: csharp-sdk's server now launches from its build-output directory so ASP.NET finds appsettings.json (its AllowedHosts filter, which dns-rebinding-protection tests, silently never loaded from the repo root); rust-sdk's STATELESS=1 override is removed because its CI runs one default server for both revisions and the forced mode fails its SEP-2575 scenarios; python-sdk's build mirrors its CI's two targeted syncs because --all-packages is unbuildable on main. withManagedServer now waits for its child to exit before returning, so consecutive runs on one port cannot race a dying predecessor. --- .claude/skills/mcp-sdk-tier-audit/README.md | 127 ++---- .claude/skills/mcp-sdk-tier-audit/SKILL.md | 33 +- README.md | 148 ++++++- package.json | 3 +- requirements/2025-11-25.yaml | 129 ++++++ requirements/2026-07-28.yaml | 197 +++++++++ src/index.ts | 301 ++++++++++++-- src/requirements.test.ts | 163 ++++++++ src/requirements.ts | 252 ++++++++++++ src/sdk-runner/index.ts | 86 +++- src/sdk-runner/known-sdks.ts | 25 +- src/sdk-runner/sdk-runner.test.ts | 16 +- .../checks/test-conformance-results.ts | 262 ++++++++++-- src/tier-check/index.ts | 385 +++++++++++++++--- src/tier-check/output.ts | 348 +++++++++++----- src/tier-check/types.ts | 28 ++ 16 files changed, 2141 insertions(+), 362 deletions(-) create mode 100644 requirements/2025-11-25.yaml create mode 100644 requirements/2026-07-28.yaml create mode 100644 src/requirements.test.ts create mode 100644 src/requirements.ts 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 5da0fbd..8033050 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] [--branch ]' +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, 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,9 @@ 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. @@ -57,15 +60,35 @@ 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 ``` +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. @@ -117,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% (date-versioned scenarios only; `draft` and `extension` are informational and not scored) -- Client conformance test pass rate == 100% (date-versioned scenarios only; `draft` and `extension` are informational and not scored) +- 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 diff --git a/README.md b/README.md index c320f48..d5c4cb3 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ npx @modelcontextprotocol/conformance client --command "" --scen - `--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 @@ -83,6 +84,7 @@ 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; 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 @@ -116,6 +118,74 @@ 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. @@ -326,26 +396,86 @@ Clones are cached under `.sdk-under-test/` and reused (fetched) on subsequent ru ## 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): +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/package.json b/package.json index e59d9f0..f624354 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "prepare": "npm run build" }, "files": [ - "dist" + "dist", + "requirements" ], "bin": { "conformance": "dist/index.js" 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/src/index.ts b/src/index.ts index 788e470..4fdb5bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,16 @@ import { } 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, @@ -59,6 +69,123 @@ import packageJson from '../package.json'; // 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, @@ -109,18 +236,36 @@ program '--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); @@ -138,23 +283,37 @@ program 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( @@ -169,7 +328,9 @@ program timeout, outputDir, specVersionFilter, - options.force ?? false + // a requirement set decides membership, so its choice outranks a + // scenario's own applicability window at the pinned revision + options.force || Boolean(requirements) ) ); return { @@ -256,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 @@ -369,17 +551,35 @@ program '--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 (JSON instead of pretty print)') - .action(async (options) => { + .action(async (options, cmd) => { try { // Validate options with Zod const validated = ServerOptionsSchema.parse(options); 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) { @@ -421,8 +621,16 @@ 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 @@ -439,7 +647,7 @@ program process.exit(1); } - if (specVersionFilter) { + if (specVersionFilter && !requirements) { scenarios = filterScenariosBySpecVersion( scenarios, specVersionFilter, @@ -448,7 +656,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[] }[] = @@ -464,7 +672,10 @@ program validated.url, scenarioName, outputDir, - specVersionFilter + specVersionFilter, + // a requirement set decides membership, so its choice outranks a + // scenario's own applicability window at the pinned revision + Boolean(requirements) ) ); allResults.push({ scenario: scenarioName, checks: result.checks }); @@ -494,15 +705,28 @@ 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) { @@ -700,7 +924,9 @@ 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') @@ -708,11 +934,22 @@ program '--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.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) 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/sdk-runner/index.ts b/src/sdk-runner/index.ts index cfe959c..bcd5b20 100644 --- a/src/sdk-runner/index.ts +++ b/src/sdk-runner/index.ts @@ -8,7 +8,7 @@ import { resolveSpecVersion } from '../scenarios'; type Mode = 'client' | 'server'; -function execShell(command: string, cwd: string): Promise { +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); @@ -59,7 +59,7 @@ async function waitForReady(url: string, timeoutMs: number): Promise { ); } -async function withManagedServer( +export async function withManagedServer( command: string, cwd: string, url: string, @@ -102,14 +102,28 @@ async function withManagedServer( } finally { stopping = true; console.error(`[sdk] Stopping server`); - if (process.platform !== 'win32' && child.pid) { - try { - process.kill(-child.pid, 'SIGTERM'); - } catch { - child.kill('SIGTERM'); + 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); } - } else { - child.kill('SIGTERM'); + }; + 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)]); } } } @@ -121,14 +135,23 @@ function passThrough(options: { verbose?: boolean; output?: string; specVersion?: string; + requirements?: string; }): string[] { const args: string[] = []; - if (options.scenario) args.push('--scenario', options.scenario); - else if (options.suite) args.push('--suite', options.suite); + // 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.specVersion) args.push('--spec-version', options.specVersion); + if (!options.requirements && options.specVersion) + args.push('--spec-version', options.specVersion); return args; } @@ -173,6 +196,10 @@ export function createSdkCommand(): Command { '--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 { @@ -197,7 +224,20 @@ export function createSdkCommand(): Command { // 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. - const requestedSpec = options.specVersion ?? baseConfig.specVersion; + 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; @@ -224,9 +264,13 @@ export function createSdkCommand(): Command { 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 + : builtinConfig.expectedFailures && !options.requirements ? path.resolve(dir, builtinConfig.expectedFailures) : undefined; // Resolve -o to an absolute path so it lands where the user expects, @@ -257,11 +301,14 @@ export function createSdkCommand(): Command { clientCmd, ...passThrough({ scenario: options.scenario, - suite: options.suite ?? 'all', + suite: options.requirements + ? undefined + : (options.suite ?? 'all'), timeout: options.timeout, verbose: options.verbose, output, - specVersion + specVersion, + requirements: options.requirements }) ]; if (expectedFailures) @@ -283,10 +330,13 @@ export function createSdkCommand(): Command { // 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.suite ?? 'active', + suite: options.requirements + ? undefined + : (options.suite ?? 'active'), verbose: options.verbose, output, - specVersion + specVersion, + requirements: options.requirements }) ]; if (expectedFailures) diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts index 9c43067..cafd337 100644 --- a/src/sdk-runner/known-sdks.ts +++ b/src/sdk-runner/known-sdks.ts @@ -65,7 +65,12 @@ export const KNOWN_SDKS: Record = { // workspace package). Two baselines exist upstream, one per spec target; // the 2026-07-28 override picks the matching one. 'python-sdk': { - build: 'uv sync --frozen --all-extras --all-packages', + // 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' }, @@ -110,6 +115,11 @@ export const KNOWN_SDKS: Record = { // 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: { @@ -118,13 +128,6 @@ export const KNOWN_SDKS: Record = { server: { command: 'PORT=3000 ./target/debug/conformance-server', url: 'http://localhost:3000/mcp' - }, - specOverrides: { - '2026-07-28': { - server: { - command: 'STATELESS=1 PORT=3000 ./target/debug/conformance-server' - } - } } }, // Fixtures live in tests/ModelContextProtocol.ConformanceClient and @@ -143,8 +146,12 @@ export const KNOWN_SDKS: Record = { 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: - 'dotnet artifacts/bin/ModelContextProtocol.ConformanceServer/Release/net10.0/ModelContextProtocol.ConformanceServer.dll --urls http://localhost:3000', + "bash -c 'cd artifacts/bin/ModelContextProtocol.ConformanceServer/Release/net10.0 && exec dotnet ModelContextProtocol.ConformanceServer.dll --urls http://localhost:3000'", url: 'http://localhost:3000' }, specOverrides: { diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts index 7ce0f79..0eb051e 100644 --- a/src/sdk-runner/sdk-runner.test.ts +++ b/src/sdk-runner/sdk-runner.test.ts @@ -202,11 +202,17 @@ describe('resolveConfigForSpec', () => { expect(resolved.server?.command).toContain('mcp-everything-server'); }); - it('prefixes STATELESS=1 for rust-sdk at 2026-07-28', () => { - const resolved = resolveConfigForSpec(KNOWN_SDKS['rust-sdk'], '2026-07-28'); - expect(resolved.server?.command).toBe( - 'STATELESS=1 PORT=3000 ./target/debug/conformance-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', () => { diff --git a/src/tier-check/checks/test-conformance-results.ts b/src/tier-check/checks/test-conformance-results.ts index ba16be2..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 { execFileSync } 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'; @@ -10,6 +11,12 @@ import { listClientScenariosForSpec, getScenarioSpecVersions } from '../../scenarios'; +import { + notScoredScenarios, + RequirementSet, + scenariosToRun, + scoredScenarios +} from '../../requirements'; import { ConformanceCheck, DRAFT_PROTOCOL_VERSION, @@ -115,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) => { @@ -127,38 +141,56 @@ 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 } : {}) }); } } - // pass_rate only counts tier-scoring scenarios (date-versioned, not draft/extension). - // passed/failed/total reflect ALL scenarios for full reporting; pass_rate and status - // reflect only tier-scoring scenarios for tier logic. - const tierDetails = result.details.filter((d) => - isTierScoring(d.specVersions) - ); + // 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 @@ -170,13 +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 { @@ -191,24 +344,37 @@ export async function checkConformance(options: { const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-server-')); const args = [ - process.argv[1], + resolvePath(process.argv[1]), 'server', '--url', options.serverUrl, '-o', outputDir ]; - if (options.specVersion) { + if (options.requirements) { + args.push('--requirements', options.requirements.revision); + } else if (options.specVersion) { args.push('--spec-version', options.specVersion); } - try { - execFileSync(process.execPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000 - }); - } catch { - // Non-zero exit is expected when tests fail — results are still in outputDir + 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()); @@ -218,11 +384,7 @@ export async function checkConformance(options: { ) : [...activeScenarios]; - return reconcileWithExpected( - parseOutputDir(outputDir), - expectedScenarios, - 'server' - ); + return reconcileWithExpected(parsedServer, expectedScenarios, 'server'); } /** @@ -232,6 +394,15 @@ 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 { @@ -246,31 +417,48 @@ export async function checkClientConformance(options: { const outputDir = mkdtempSync(join(tmpdir(), 'tier-check-client-')); const args = [ - process.argv[1], + resolvePath(process.argv[1]), 'client', '--command', options.clientCmd, - '--suite', - 'all', '-o', outputDir ]; - if (options.specVersion) { - args.push('--spec-version', options.specVersion); + 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)); } - try { - execFileSync(process.execPath, args, { - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 120_000 - }); - } catch { - // Non-zero exit is expected when tests fail — results are still in outputDir + 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(parseOutputDir(outputDir), expectedScenarios); + return reconcileWithExpected(parsedClient, expectedScenarios); } diff --git a/src/tier-check/index.ts b/src/tier-check/index.ts index 1cd32c1..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'; @@ -14,6 +15,87 @@ 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('/'); @@ -25,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') @@ -53,7 +153,86 @@ export function createTierCheckCommand(): Command { '--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; @@ -61,6 +240,44 @@ export function createTierCheckCommand(): Command { ? 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 { @@ -83,61 +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, specVersion - }).then((r) => { - console.error(' ✓ Server Conformance'); - return r; - }), - checkClientConformance({ + }); + clientConformancePromise = checkClientConformance({ clientCmd: options.clientCmd, skip: options.skipConformance || !options.clientCmd, - specVersion - }).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; - }) - ]); + 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, @@ -157,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 }; @@ -171,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 ab0d2fb..95686c2 100644 --- a/src/tier-check/output.ts +++ b/src/tier-check/output.ts @@ -105,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); } @@ -118,6 +199,10 @@ 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'); @@ -129,50 +214,54 @@ export function formatMarkdown(scorecard: TierScorecard): string { c.client_conformance as ConformanceResult ); - // 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] - ]; - - 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( - '_* 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) { + if (scorecard.requirements_revisions) { + lines.push(...requirementsSummary(scorecard)); + } else { + // Tier-scoring matrix lines.push(''); - lines.push('_Informational (not scored for tier):_'); - lines.push(''); - lines.push(`| | ${INFO_SPEC_VERSIONS.join(' | ')} |`); - lines.push(`|---|${INFO_SPEC_VERSIONS.map(() => '---|').join('')}`); + 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] + ]; + for (const [label, row] of mdRows) { - const hasData = INFO_SPEC_VERSIONS.some((v) => { + lines.push( + `| ${label} | ${TIER_SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} | ${formatRate(row.tierUnique)} |` + ); + } + + lines.push(''); + lines.push( + '_* 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 (!hasData) continue; - lines.push( - `| ${label} | ${INFO_SPEC_VERSIONS.map((v) => formatCell(row.cells.get(v))).join(' | ')} |` - ); + }) + ); + 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(''); @@ -228,80 +317,141 @@ 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); - - // 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 : ''; + if (scorecard.requirements_revisions) { 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}` + ` ${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 (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}` - ); + 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); - // 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`); + // Tier-scoring matrix (date-versioned specs only) console.log( - ` ${COLORS.DIM}${lp('', lw + 2)} ${INFO_SPEC_VERSIONS.map((v) => rp(v, vw)).join(' ')}${COLORS.RESET}` + ` ${COLORS.DIM}${lp('', lw + 2)} ${TIER_SPEC_VERSIONS.map((v) => rp(v, vw)).join(' ')} ${rp('All*', tw)}${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 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( - ` ${b}${lp(label, lw)}${r} ${INFO_SPEC_VERSIONS.map((v) => rp(formatCell(row.cells.get(v)), vw)).join(' ')}` + ` ${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( diff --git a/src/tier-check/types.ts b/src/tier-check/types.ts index eacbcbe..5016a26 100644 --- a/src/tier-check/types.ts +++ b/src/tier-check/types.ts @@ -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?: 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; From c321dd32035556e6769d3724a8ee97d87c3faaac Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:58:45 +0100 Subject: [PATCH 203/226] chore: bump version to 0.2.0-alpha.11 (#448) No-Verification-Needed: version bump only, no code change --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef3d6ea..d11fdfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.10", + "version": "0.2.0-alpha.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.10", + "version": "0.2.0-alpha.11", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index f624354..af723a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/conformance", - "version": "0.2.0-alpha.10", + "version": "0.2.0-alpha.11", "type": "module", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 74edef34d674f563537be8c6587cebaa58e830ca Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:35:02 +0100 Subject: [PATCH 204/226] fix(tier-check): count an SDK release in the 24h before a spec release as tracking it (#458) Lockstep SDKs publish in the hours before the spec release is tagged (for 2026-07-28: python-sdk 3h early, go-sdk 3.5h, typescript-sdk the evening before), so "first SDK release at or after the spec" read them as not having released and would fail them 30 days later. Allow a 24h lead. --- src/tier-check/checks/spec-tracking.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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) { From 7160fc8f158ffc066946d28741365d495638a9ae Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:20:04 +0100 Subject: [PATCH 205/226] Add CODEOWNERS for the authorization-server conformance area (#489) Routes review of /src/scenarios/authorization-server/ and /src/runner/authorization-server.ts to the conformance-auth team (managed in modelcontextprotocol/access). Claude-Session: https://claude.ai/code/session_01FDRFtxQbYLnBcpwndGXF7W Co-authored-by: Claude --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/CODEOWNERS 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 From c8dab0c3b45016d6a4ff3e75ce54ee0ab0b1326a Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Mon, 7 Sep 2026 02:43:45 +0900 Subject: [PATCH 206/226] feat: add ruby-sdk to the known SDKs registry (#457) Registers modelcontextprotocol/ruby-sdk in KNOWN_SDKS so `tier-check --sdk-path` and `sdk ruby-sdk` manage the build and per-revision runs themselves, instead of requiring the --conformance-server-url/--client-cmd fallback for unknown SDKs. This is the registry path the Ruby SDK's SEP-1730 Tier 1 application (modelcontextprotocol/modelcontextprotocol#3247) was scored through: with the entry in place, reviewers can reproduce the submission with a single `tier-check --sdk-path --requirements 2025-11-25,2026-07-28`, the same way every other registered SDK is checked. The entry drives the same fixtures the SDK's own CI runs through `rake conformance`: `conformance/server.rb` behind the `conformance:server` rake task (PORT from the environment; PORT=3000 matches the registry's port convention) and `conformance/client.rb`, which reads MCP_CONFORMANCE_SCENARIO from the environment and the server URL from argv, per the runner's contract. The baseline is `conformance/expected_failures.yml`. One dual-era server serves every revision from the same /mcp endpoint (stateful dated-spec handshake and the SEP-2575 stateless lifecycle side by side), so the entry needs no specOverrides. Testing: `npm test` (525 passing, including a new known-sdks entry test), `npm run check`, and `npm run build` pass. The entry has already carried four full end-to-end audit runs: the 2026-08-14 and 2026-08-15 tier-check runs against a local ruby-sdk checkout (`--sdk-path` with `--requirements 2025-11-25,2026-07-28`) each built the SDK, started the server on PORT=3000, and completed both legs at both revisions, producing the 67/67 server and 50/50 client results submitted in modelcontextprotocol/modelcontextprotocol#3247. Individually, `sdk ruby-sdk --path --mode server --requirements 2025-11-25` exits 0 with 78/78 checks, and `--mode client --requirements 2025-11-25` exits 0 with every scored scenario passing (failures confined to not_scored extensions). --- .claude/skills/mcp-sdk-tier-audit/SKILL.md | 2 +- src/sdk-runner/known-sdks.ts | 19 +++++++++++++++++++ src/sdk-runner/sdk-runner.test.ts | 13 +++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.claude/skills/mcp-sdk-tier-audit/SKILL.md b/.claude/skills/mcp-sdk-tier-audit/SKILL.md index 8033050..509df1d 100644 --- a/.claude/skills/mcp-sdk-tier-audit/SKILL.md +++ b/.claude/skills/mcp-sdk-tier-audit/SKILL.md @@ -26,7 +26,7 @@ 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. -**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, 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. +**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: diff --git a/src/sdk-runner/known-sdks.ts b/src/sdk-runner/known-sdks.ts index cafd337..843590d 100644 --- a/src/sdk-runner/known-sdks.ts +++ b/src/sdk-runner/known-sdks.ts @@ -159,6 +159,25 @@ export const KNOWN_SDKS: Record = { 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' } }; diff --git a/src/sdk-runner/sdk-runner.test.ts b/src/sdk-runner/sdk-runner.test.ts index 0eb051e..76fd7e5 100644 --- a/src/sdk-runner/sdk-runner.test.ts +++ b/src/sdk-runner/sdk-runner.test.ts @@ -141,6 +141,19 @@ describe('lookupBuiltinConfig', () => { 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(); From 4ff474c20d9983134a2d7d078da2e468b859d90e Mon Sep 17 00:00:00 2001 From: Josh Abbott Date: Sun, 6 Sep 2026 13:44:29 -0400 Subject: [PATCH 207/226] fix(auth): add issuer to pre-registration context (#423) --- .../clients/typescript/everything-client.ts | 4 +++ src/scenarios/client/auth/index.test.ts | 33 +++++++++++++++++++ src/scenarios/client/auth/pre-registration.ts | 13 ++++++-- src/schemas/context.ts | 3 +- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 111f97e..b3ccbed 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -741,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, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 57ff27a..3ba876e 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -38,6 +38,8 @@ import { runClient as dpopClient } from '../../../../examples/clients/typescript 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'); @@ -112,6 +114,37 @@ describe('Client Draft Scenarios', () => { } }); +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); diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index 11f8269..7e4ff54 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -13,7 +13,12 @@ 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 @@ -123,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/schemas/context.ts b/src/schemas/context.ts index cea338a..ecfe524 100644 --- a/src/schemas/context.ts +++ b/src/schemas/context.ts @@ -21,7 +21,8 @@ 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/enterprise-managed-authorization'), From 7a702f5a114a629aaea0b3833db2e49b4933a4a3 Mon Sep 17 00:00:00 2001 From: Yusufhan Sacak <67666222+JosephDoUrden@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:50:05 +0300 Subject: [PATCH 208/226] fix(http-base): return valid empty results for unhandled list methods (#476) The BaseHttpScenario generic fallback answered any unrouted request with only the required draft result fields, so a standard list method a scenario does not route came back without its required list member. http-standard-headers advertises resources but has no resources/templates/list route; the reply's missing resourceTemplates fails schema validation (draft and 2025-11-25 both require it), and strictly-validating clients drop the connection before the scenario's header checks run. Merge schema-valid empty results for the six list-shaped methods (tools, resources, resource templates, prompts, roots, tasks) into the fallback, keyed by a ReadonlyMap so method names that collide with Object.prototype ("constructor", "toString", ...) miss instead of poisoning the reply with a prototype function. tasks/list has a typed result only at 2025-11-25; 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. Tests pin the fixed behaviour on all three http scenario classes, the prototype-name lookups, and the spec schema's rejection of the pre-fix payload. Fixes #474 --- .../client/draft-result-fields.test.ts | 167 +++++++++++++++++- src/scenarios/client/http-base.ts | 31 +++- 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts index 1fdc6d4..27f64de 100644 --- a/src/scenarios/client/draft-result-fields.test.ts +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -1,11 +1,14 @@ 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'; @@ -134,7 +137,8 @@ describe('http-standard-headers mock results (2026-07-28)', () => { cacheable: true }, // Not explicitly handled by the scenario — exercises the method-aware - // generic fallback, which must still add the caching hints. + // 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 }, { @@ -171,6 +175,167 @@ describe('http-standard-headers mock results (2026-07-28)', () => { }); }); +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(); diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts index 3e8cbe8..6751ded 100644 --- a/src/scenarios/client/http-base.ts +++ b/src/scenarios/client/http-base.ts @@ -20,6 +20,29 @@ import { 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; @@ -181,8 +204,12 @@ export abstract class BaseHttpScenario implements Scenario { 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. - result: withRequiredDraftResultFields(request.method, {}) + // 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) ?? {} + ) }); } } From 1573ad5fc857d569ef405296e5d61de3ee6f921c Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 6 Sep 2026 18:53:40 +0100 Subject: [PATCH 209/226] fix(everything-server): normalize streamed tool responses (#462) Signed-off-by: lucarlig --- .../servers/typescript/everything-server.ts | 22 +++-- src/scenarios/server/all-scenarios.test.ts | 97 ++++++++++++------- 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index d7de222..c76c1f5 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1246,14 +1246,13 @@ const STATELESS_CACHEABLE_METHODS: ReadonlySet = new Set([ 'resources/read' ]); -/** Send a stateless (draft) JSON-RPC response. Draft results MUST carry `resultType` +/** 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 sendStatelessJson( - res: import('express').Response, +function normalizeStatelessResponse( method: string, payload: { result?: Record; [key: string]: unknown } -): import('express').Response { +): { result?: Record; [key: string]: unknown } { const result = payload.result; if (result && typeof result === 'object' && !Array.isArray(result)) { result.resultType ??= 'complete'; @@ -1262,7 +1261,16 @@ function sendStatelessJson( result.cacheScope ??= 'private'; } } - return res.json(payload); + 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 @@ -2257,7 +2265,9 @@ app.post('/mcp', async (req, res) => { ResultSchema as any ); for (const n of dispatch.drainNotifications()) write(n); - write({ jsonrpc: '2.0', id, result }); + write( + normalizeStatelessResponse(method, { jsonrpc: '2.0', id, result }) + ); } catch (e: any) { for (const n of dispatch.drainNotifications()) write(n); write({ diff --git a/src/scenarios/server/all-scenarios.test.ts b/src/scenarios/server/all-scenarios.test.ts index 68387d7..bce7845 100644 --- a/src/scenarios/server/all-scenarios.test.ts +++ b/src/scenarios/server/all-scenarios.test.ts @@ -7,7 +7,11 @@ import { listDraftClientScenarios, listPendingClientScenarios } from '../index'; -import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; +import { + DRAFT_PROTOCOL_VERSION, + LATEST_SPEC_VERSION, + type SpecVersion +} from '../../types'; import path from 'path'; function getFreePort(): Promise { @@ -132,40 +136,67 @@ describe('Server Scenarios', () => { ...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`); - } - - // Draft-only scenarios expect the draft (stateless) connection, so - // derive the spec version from the scenario's declared source. - const specVersion = - 'introducedIn' in scenario.source && - scenario.source.introducedIn === DRAFT_PROTOCOL_VERSION - ? DRAFT_PROTOCOL_VERSION - : LATEST_SPEC_VERSION; - - const checks = await scenario.run(testContext(serverUrl, specVersion)); - - // 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); + } }); From 50edb98c26c7fb065ef97f6817fd3638da09d93f Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Sun, 6 Sep 2026 19:59:04 +0200 Subject: [PATCH 210/226] fix(lifecycle): bound the session-id probe and release its response body (#455) The visible-ASCII session-id check sends a raw initialize POST outside the SDK transport, because the transport does not expose the MCP-Session-Id response header. That fetch carried no abort signal and its body was never read or cancelled. A server that accepts the connection and never responds hung the scenario here, before teardown, so the DELETE bound added in #316 never came into play. And since the probe's `Accept` includes `text/event-stream`, a server answering with an open SSE stream left the body unconsumed and leaked the connection for the rest of the run. Bounding the probe at 5s matches the existing session-termination bound. #427 is the structural fix for the hang class; this is still worth having for fast failure and for attributing the failure to this check rather than to whatever the runner-level timeout happens to interrupt. Fixes #428 Co-authored-by: Avi Seth --- src/scenarios/server/lifecycle.test.ts | 44 ++++++++++++++++++++++++++ src/scenarios/server/lifecycle.ts | 13 +++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts index ab3628a..8183516 100644 --- a/src/scenarios/server/lifecycle.test.ts +++ b/src/scenarios/server/lifecycle.test.ts @@ -140,4 +140,48 @@ describe('ServerInitializeScenario', () => { ); 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 7be721b..85101b2 100644 --- a/src/scenarios/server/lifecycle.ts +++ b/src/scenarios/server/lifecycle.ts @@ -15,6 +15,11 @@ import { 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', @@ -115,12 +120,18 @@ and validates session ID format if one is assigned.`; 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', From 48561a1aeccd943f84da545d2cd29e940835f6b2 Mon Sep 17 00:00:00 2001 From: marcus-kepler-92 Date: Mon, 7 Sep 2026 02:29:21 +0800 Subject: [PATCH 211/226] fix(input-required-result): report the capability check as untestable when nothing is requested (#442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `input-required-result-capability-check` guarded its elicitation scan with `isInputRequiredResult(result) && result.inputRequests`. When a server answered without `inputRequests`, that arm was skipped, and the following `isCompleteResult` arm is false by construction for an input_required result, so nothing was recorded and the check passed. An empty `inputRequests` object passed too: the arm was taken, but the loop had nothing to scan. `inputRequests` is optional — a result carrying only `requestState` still satisfies "at least one of inputRequests or requestState" — so such a server has not violated "servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities". It has not exercised it either. Reporting SUCCESS makes an unverified MUST read as green, which is the failure mode #248 and #372 established should be reported as a failure instead. So report it through `notTestable()` with `details.untestable`, matching the convention in AGENTS.md and the call sites in stateless.ts, rather than claiming a violation the server did not commit. Servers that legitimately have nothing to ask for now go red on this check; per #248 the expected-failures baseline is the escape hatch. Adds the case to sep-2322-mrtr-broken-server so the existing MRTR negative suite covers it. The fixture returns a conformant envelope (`requestState` present, no input requests) rather than a bare one, so it isolates this check rather than also tripping sep-2322-request-state-incomplete. Verified against --spec-version 2026-07-28: everything-server 2/2 passed sep-2322-mrtr-broken-server FAILURE Not testable: server returned no inputRequests, so the capability restriction was never exercised Refs #439 Co-authored-by: Marcus <238969485+marcus-kepler-92@users.noreply.github.com> --- .../typescript/sep-2322-mrtr-broken-server.ts | 20 ++++++++++++++++++ src/scenarios/server/input-required-result.ts | 21 ++++++++++++++++--- src/scenarios/server/negative-mrtr.test.ts | 17 ++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts index 49fcdb6..72cf602 100644 --- a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -8,6 +8,11 @@ * 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. */ @@ -54,6 +59,11 @@ handlers['tools/list'] = () => ({ 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: {} } } ] }); @@ -94,6 +104,16 @@ handlers['tools/call'] = (params) => { }; } + 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 diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 66efb3f..64ad4ba 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -22,6 +22,7 @@ import { mockListRootsResponse, MRTR_SPEC_REFERENCES } from './input-required-result-helpers'; +import { notTestable } from '../untestable'; // ─── A1: Basic Elicitation ──────────────────────────────────────────────────── @@ -1424,14 +1425,28 @@ Only include inputRequests for methods the client supports. For example, if the 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) && result.inputRequests) { + } 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 Object.entries(result.inputRequests)) { + for (const [key, req] of inputRequests) { if (req.method === 'elicitation/create') { errors.push( `Server included elicitation/create inputRequest (key: "${key}") ` + @@ -1454,7 +1469,7 @@ Only include inputRequests for methods the client supports. For example, if the timestamp: new Date().toISOString(), errorMessage: errors.length > 0 ? errors.join('; ') : undefined, specReferences: MRTR_SPEC_REFERENCES, - details: { result } + details: untestable ? { result, untestable: true } : { result } }); } catch (error) { checks.push({ diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts index 3022840..871ad73 100644 --- a/src/scenarios/server/negative-mrtr.test.ts +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -13,7 +13,8 @@ import path from 'path'; import { InputRequiredResultResultTypeScenario, InputRequiredResultUnsupportedMethodsScenario, - InputRequiredResultTamperedStateScenario + InputRequiredResultTamperedStateScenario, + InputRequiredResultCapabilityCheckScenario } from './input-required-result'; import { formatWireViolation, @@ -142,4 +143,18 @@ describe('SEP-2322 MRTR negative tests', () => { 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); }); From 6b6e41a86d1e9d80257b2cd841d5a2743ef29b46 Mon Sep 17 00:00:00 2001 From: hwkiem <39803077+hwkiem@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:10:54 -0700 Subject: [PATCH 212/226] fix(tier-check): strip package-name prefixes from monorepo release tags (#463) Version detection stripped only a leading `v`, so the monorepo tags Cargo workspaces publish (`rmcp-v3.0.1`, `rmcp-macros-v3.0.1`) parsed to NaN and scored `stable_release: fail`. rust-sdk was reported as having no stable release despite a non-prerelease `rmcp-v3.0.1`. Match the version at the end of the tag instead, so any package-name prefix is ignored while bare (`1.2.3`), `v`-prefixed and prerelease (`v2.0.0-rc1`) tags parse as they did before. Tags carrying no recognizable version are returned unchanged rather than dropped, so they still surface in the check output. Fixes #425 --- src/tier-check/checks/release.test.ts | 119 ++++++++++++++++++++++++++ src/tier-check/checks/release.ts | 15 +++- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/tier-check/checks/release.test.ts 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; +} From 31c5e7aa5501a07e17cb36c2000b561bb7a43a47 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Mon, 7 Sep 2026 00:44:00 +0530 Subject: [PATCH 213/226] fix(client/auth): bind negative-check verdicts to an observed prerequisite (#483) A negative check that reads only the final verdict scores SUCCESS whenever the client fails to reach the requirement at all: "declined" and "never got far enough to decide" are the same observation. auth/resource-mismatch decided on !authorizationRequestMade alone, so a client that threw before making a single HTTP request passed it. Gate each verdict on the prerequisite the harness already records: prm-pathbased-requested for the PRM resource comparison, and authorization-server-metadata for the RFC 9207 iss comparisons, which SEP-2468 conditions on the issuer recorded from the validated metadata document. When the prerequisite is not observed the requirement was never exercised, so report it via notTestable() (#248) rather than SUCCESS. auth/metadata-issuer-mismatch already gated on the metadata fetch but reported the unreached case as a plain violation; it now reports untestable too. All six checks record propertyReached and stopReason in details. Adds an inert client and a vitest case, so the checks are shown to catch something and not merely to avoid false-positiving. --- .../clients/typescript/auth-test-inert.ts | 22 ++ src/scenarios/client/auth/index.test.ts | 34 ++ src/scenarios/client/auth/issuer-parameter.ts | 297 +++++++++++++----- .../client/auth/resource-mismatch.ts | 69 ++-- 4 files changed, 320 insertions(+), 102 deletions(-) create mode 100644 examples/clients/typescript/auth-test-inert.ts 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/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 3ba876e..b919f0e 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -26,6 +26,7 @@ import { runClient as noPkceClient } from '../../../../examples/clients/typescri 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'; @@ -476,3 +477,36 @@ describe('DPoP client nonce-less baseline (SEP-1932)', () => { 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/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index b55e974..85773e2 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -6,6 +6,7 @@ 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 = [ @@ -13,6 +14,93 @@ const metadataSpecRefs = [ 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) * @@ -217,23 +305,26 @@ export class IssParameterSupportedMissingScenario implements Scenario { if ( !this.checks.some((c) => c.id === 'sep-2468-client-reject-missing-iss') ) { - const correctlyRejected = this.authReached && !this.tokenRequestMade; - this.checks.push({ - id: 'sep-2468-client-reject-missing-iss', - name: 'Client rejects missing iss when required', - description: correctlyRejected - ? 'Client correctly rejected authorization response missing required iss parameter' - : 'Client MUST reject authorization response when server advertised iss support but iss is absent from redirect', - status: correctlyRejected ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - serverAdvertisedSupport: true, - issSentInRedirect: false, + 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 - } - }); + tokenRequestMade: this.tokenRequestMade, + observations: { + serverAdvertisedSupport: true, + issSentInRedirect: false + }, + timestamp + }) + ); } return this.checks; @@ -304,23 +395,26 @@ export class IssParameterWrongIssuerScenario implements Scenario { if ( !this.checks.some((c) => c.id === 'sep-2468-client-compare-iss-supported') ) { - const correctlyRejected = this.authReached && !this.tokenRequestMade; - this.checks.push({ - id: 'sep-2468-client-compare-iss-supported', - name: 'Client rejects mismatched iss', - description: correctlyRejected - ? 'Client correctly rejected authorization response with mismatched iss parameter' - : 'Client MUST reject authorization response when iss does not match the authorization server issuer', - status: correctlyRejected ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - serverAdvertisedSupport: true, - issSentInRedirect: 'https://evil.example.com', + 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 - } - }); + tokenRequestMade: this.tokenRequestMade, + observations: { + serverAdvertisedSupport: true, + issSentInRedirect: 'https://evil.example.com' + }, + timestamp + }) + ); } return this.checks; @@ -394,23 +488,26 @@ export class IssParameterUnexpectedScenario implements Scenario { (c) => c.id === 'sep-2468-client-compare-iss-unadvertised' ) ) { - const correctlyRejected = this.authReached && !this.tokenRequestMade; - this.checks.push({ - id: 'sep-2468-client-compare-iss-unadvertised', - name: 'Client compares unadvertised iss and rejects mismatch', - description: correctlyRejected - ? 'Client correctly compared unadvertised iss against recorded issuer and rejected the mismatch' - : 'Client MUST compare a present iss against the recorded issuer regardless of metadata advertisement, and reject on mismatch', - status: correctlyRejected ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - serverAdvertisedSupport: false, - issSentInRedirect: 'https://evil.example.com', + 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 - } - }); + tokenRequestMade: this.tokenRequestMade, + observations: { + serverAdvertisedSupport: false, + issSentInRedirect: 'https://evil.example.com' + }, + timestamp + }) + ); } return this.checks; @@ -483,23 +580,26 @@ export class IssParameterNormalizedVariantScenario implements Scenario { const timestamp = new Date().toISOString(); if (!this.checks.some((c) => c.id === 'sep-2468-client-no-normalization')) { - const correctlyRejected = this.authReached && !this.tokenRequestMade; - this.checks.push({ - id: 'sep-2468-client-no-normalization', - name: 'Client compares iss without URL normalization', - description: correctlyRejected - ? 'Client rejected an iss value that only matches the recorded issuer after URL normalization' - : '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', - status: correctlyRejected ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: specRefs, - details: { - recordedIssuer: this.authServer.getUrl(), - issSentInRedirect: `${this.authServer.getUrl()}/`, + 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 - } - }); + tokenRequestMade: this.tokenRequestMade, + observations: { + recordedIssuer: this.authServer.getUrl(), + issSentInRedirect: `${this.authServer.getUrl()}/` + }, + timestamp + }) + ); } return this.checks; @@ -589,26 +689,55 @@ export class MetadataIssuerMismatchScenario implements Scenario { const metadataRequested = this.checks.some( (c) => c.id === 'authorization-server-metadata' ); - const correctlyRejected = - metadataRequested && !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' - : metadataRequested - ? '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' - : 'Client never retrieved the authorization server metadata document, so issuer validation could not be observed', - status: correctlyRejected ? 'SUCCESS' : 'FAILURE', - timestamp, - specReferences: metadataSpecRefs, - details: { - expectedIssuer: this.authServer.getUrl(), - metadataIssuer: 'https://attacker.example.com', - metadataRequested, - metadataEndpointsUsed: this.metadataEndpointsUsed - } - }); + 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/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index 2e8b02f..a5cc157 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -6,6 +6,7 @@ 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 @@ -90,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; From 88ac3e595f9b2bedc5b34d7319eaf8957772ddd1 Mon Sep 17 00:00:00 2001 From: Avi Seth Date: Sun, 6 Sep 2026 21:14:58 +0200 Subject: [PATCH 214/226] fix(runner): bound server scenarios with a per-scenario timeout (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client runner has had a 30s per-scenario timeout since it was written, but `runServerConformanceTest` awaited `scenario.run(ctx)` with no bound. A server under test that accepts the TCP connection and then never answers an initialize POST, a tools call, or an SSE read stalled the entire suite run, not just its own scenario. #316 bounded the session-termination DELETEs in teardown, but that is a call-site fix: covering the rest that way needs the same treatment on every raw fetch in every scenario. Bounding at the runner makes those per-call bounds defense-in-depth instead of the only protection. `--timeout` now applies to server mode too, with the same 30s default as client mode. On expiry the scenario reports a `scenario-timeout` FAILURE naming the elapsed bound, so a wedged server reads as a failing scenario rather than a hung process. The timed-out scenario promise is left pending deliberately — a read blocked on a socket has no cancellation channel — with its rejection swallowed so a late failure can't surface against whichever scenario is running by then. Fixes #427 Co-authored-by: Avi Seth --- src/index.ts | 8 ++++-- src/runner/server.test.ts | 46 ++++++++++++++++++++++++++++++++ src/runner/server.ts | 55 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4fdb5bc..c42be2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -555,11 +555,13 @@ program '--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, 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; @@ -588,7 +590,8 @@ program validated.scenario, outputDir, specVersionFilter, - options.force ?? false + options.force ?? false, + timeout ); // Inapplicable scenario/spec-version combination (already logged by @@ -675,7 +678,8 @@ program specVersionFilter, // a requirement set decides membership, so its choice outranks a // scenario's own applicability window at the pinned revision - Boolean(requirements) + Boolean(requirements), + timeout ) ); allResults.push({ scenario: scenarioName, checks: result.checks }); diff --git a/src/runner/server.test.ts b/src/runner/server.test.ts index f2a2faa..2f6ac64 100644 --- a/src/runner/server.test.ts +++ b/src/runner/server.test.ts @@ -53,6 +53,52 @@ describe('runServerConformanceTest spec-version applicability', () => { }, 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 diff --git a/src/runner/server.ts b/src/runner/server.ts index a2c624a..21e80ec 100644 --- a/src/runner/server.ts +++ b/src/runner/server.ts @@ -27,12 +27,59 @@ 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, specVersion?: SpecVersion, - force = false + force = false, + timeout: number = 30000 ): Promise<{ checks: ConformanceCheck[]; resultDir?: string; @@ -98,7 +145,11 @@ export async function runServerConformanceTest( connect: (opts) => connectFor(resolvedSpecVersion)(serverUrl, opts) }; resetWireValidation(); - const checks = await scenario.run(ctx); + const checks = await runScenarioBounded( + scenario.run(ctx), + scenarioName, + timeout + ); checks.push(...wireSchemaChecks(resolvedSpecVersion)); if (resultDir) { From 61d2ccbc55884d20ac3ddf1ef5561ed9cb0251b1 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:18:43 +0100 Subject: [PATCH 215/226] fix(sse-retry): gate retry timing only on the early side (#375) The retry MUST ("waiting the given number of milliseconds before attempting to reconnect") is a lower bound; no spec text bounds reconnect delay from above. The slightly-late WARNING and >2x FAILURE bands gated environment latency, which CI load inflates - in python-sdk CI the warning band fired on ~4-5% of runs under the suite's parallel client spawns. - delete the late bands: FAILURE only when the reconnect arrives earlier than retry minus the 50ms tolerance - demote the could-not-measure path from WARNING to INFO - negotiate protocolVersion 2025-11-25, the version whose MUST is under test (was 2025-03-26; same fix as 4686e06 for server-sse-polling) Strictly relaxing: no client that passes today can start failing. Co-authored-by: Paul Carleton --- src/scenarios/client/sse-retry.ts | 47 ++++++++----------------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index e89b23e..3cee1e1 100644 --- a/src/scenarios/client/sse-retry.ts +++ b/src/scenarios/client/sse-retry.ts @@ -42,10 +42,8 @@ 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(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { @@ -290,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' @@ -459,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', @@ -502,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 } }); @@ -518,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', From 54d3cd6d1d3dc0516b28c051cb6f3b0fdfca9123 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:19:13 +0100 Subject: [PATCH 216/226] chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#264) Bumps the npm_and_yarn group with 1 update in the / directory: [ip-address](https://github.com/beaugunderson/ip-address). Bumps the npm_and_yarn group with 1 update in the /examples/servers/typescript directory: [ip-address](https://github.com/beaugunderson/ip-address). Updates `ip-address` from 10.1.0 to 10.2.0 - [Commits](https://github.com/beaugunderson/ip-address/commits) Updates `ip-address` from 10.1.0 to 10.2.0 - [Commits](https://github.com/beaugunderson/ip-address/commits) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.2.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: ip-address dependency-version: 10.2.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/servers/typescript/package-lock.json | 14 +++++++------- package-lock.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/servers/typescript/package-lock.json b/examples/servers/typescript/package-lock.json index d892ee6..638171a 100644 --- a/examples/servers/typescript/package-lock.json +++ b/examples/servers/typescript/package-lock.json @@ -917,12 +917,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "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.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -1157,9 +1157,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "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" diff --git a/package-lock.json b/package-lock.json index d11fdfc..13e41d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2931,12 +2931,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "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.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3363,9 +3363,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "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" From 98535fb92fbdedca2c6b25374a759873a3dd1203 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sun, 6 Sep 2026 22:41:25 +0300 Subject: [PATCH 217/226] feat(client/auth): check the resource parameter matches the PRM-published identifier (#488) Adds resource-parameter-matches-prm to the RFC 8707 checks, emitted by the token-endpoint-auth and metadata-discovery scenarios; auth/metadata-var2 serves a pathless resource, so a client that re-serializes it through URL.href fails. Negative example client included. The everything-client (SDK 1.x) is baselined on that scenario until typescript-sdk#1972 ships. --- .../typescript/auth-test-resource-slash.ts | 73 +++++++ .../client/auth/discovery-metadata.ts | 34 +++- .../client/auth/helpers/createServer.ts | 7 +- .../auth/helpers/resourceParameterChecks.ts | 178 ++++++++++++++++++ src/scenarios/client/auth/index.test.ts | 28 ++- src/scenarios/client/auth/spec-references.ts | 8 + .../client/auth/token-endpoint-auth.ts | 126 ++----------- 7 files changed, 340 insertions(+), 114 deletions(-) create mode 100644 examples/clients/typescript/auth-test-resource-slash.ts create mode 100644 src/scenarios/client/auth/helpers/resourceParameterChecks.ts 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/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 3c53e08..c5623a9 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -12,6 +12,7 @@ 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'; @@ -77,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' @@ -97,11 +105,23 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { async start(ctx: ScenarioContext): Promise { checks = []; + authorizationResource = undefined; + tokenResource = undefined; + prmResource = undefined; 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 @@ -134,7 +154,10 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { 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 @@ -198,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/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index cd32d21..6d308d6 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -30,6 +30,8 @@ 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( @@ -46,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) @@ -130,6 +133,8 @@ export function createServer( ? getBaseUrl() : `${getBaseUrl()}/mcp`); + onPrmRequest?.({ resource, timestamp: new Date().toISOString() }); + const prmResponse: any = { resource, authorization_servers: [getAuthServerUrl()] 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/index.test.ts b/src/scenarios/client/auth/index.test.ts index b919f0e..41c84ae 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -36,6 +36,7 @@ import { runClient as dpopNoAsNonceClient } from '../../../../examples/clients/t 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'; @@ -67,6 +68,21 @@ const allowClientErrorScenarios = new Set([ '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) { @@ -81,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] }); }); } @@ -154,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', { diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 972417b..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' diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 53643fd..ae046e2 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -6,6 +6,7 @@ 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'; @@ -56,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; @@ -67,6 +69,7 @@ class TokenEndpointAuthScenario implements Scenario { this.checks = []; this.authorizationResource = undefined; this.tokenResource = undefined; + this.prmResource = undefined; const tokenVerifier = new MockTokenVerifier(this.checks, []); const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, { @@ -145,7 +148,10 @@ class TokenEndpointAuthScenario implements Scenario { { prmPath: '/.well-known/oauth-protected-resource/mcp', requiredScopes: [], - tokenVerifier + tokenVerifier, + onPrmRequest: ({ resource }) => { + this.prmResource = resource; + } } ); await this.server.start(app); @@ -173,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 { From 0d824aebd86dbc7c4509f5cd189b1445c3c239a5 Mon Sep 17 00:00:00 2001 From: dylan Date: Sun, 6 Sep 2026 13:05:26 -0700 Subject: [PATCH 218/226] Fix tools-name-format for spec Tool Names SHOULD rules on 2025-11-25+ (#380) * fix(tools-list): align tools-name-format with 2025-11-25 spec prose Enforce dated spec Tool Names SHOULD rules (128 max, no slash), WARNING severity, 2025-11-25+ gate, negative fixture, and core-spec specReferences. Fixes #379 * test(tools-list): cast Connection.request mock for tsgo typecheck * test(tools-list): pin WARNING to offending tool name in negative test * test(tools-list): rename negative fixture to invalid-tool-names Document SEP-986 markdown vs integrated spec divergence in fixture header. * chore: trim explanatory comments to the essentials Keep the rule statement and the note that the dated spec (not the SEP-986 markdown) is authoritative; drop the process narration. Co-Authored-By: Claude --------- Co-authored-by: triage Co-authored-by: Claude Co-authored-by: Paul Carleton --- .../servers/typescript/invalid-tool-names.ts | 68 ++++++++++ src/scenarios/server/negative.test.ts | 36 ++++++ src/scenarios/server/tools.test.ts | 122 +++++++++++++++--- src/scenarios/server/tools.ts | 55 +++++--- 4 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 examples/servers/typescript/invalid-tool-names.ts 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/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 0d24827..f70aa90 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -8,6 +8,7 @@ import { JsonSchema2020_12Scenario, sep2106KeywordCheckStatus } from './json-schema-2020-12'; +import { ToolsListScenario } from './tools'; import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; import { takeWireViolations } from '../../validation/wire-schema'; @@ -216,6 +217,41 @@ describe('Server scenario negative tests', () => { }, 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( diff --git a/src/scenarios/server/tools.test.ts b/src/scenarios/server/tools.test.ts index d54daa4..b1fe0f7 100644 --- a/src/scenarios/server/tools.test.ts +++ b/src/scenarios/server/tools.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { buildToolsNameFormatCheck, validateToolNameFormat } from './tools.js'; +import { + buildToolsNameFormatCheck, + toolNameFormatCheckApplies, + ToolsListScenario, + validateToolNameFormat +} from './tools.js'; +import type { Connection, RunContext } from '../../connection'; describe('validateToolNameFormat', () => { it('accepts a typical snake_case name', () => { @@ -7,24 +13,30 @@ describe('validateToolNameFormat', () => { }); it('accepts all allowed character classes', () => { - expect(validateToolNameFormat('Aa0_.-/')).toBeNull(); + expect(validateToolNameFormat('Aa0_.-')).toBeNull(); }); it('accepts a single-character name (lower length boundary)', () => { expect(validateToolNameFormat('a')).toBeNull(); }); - it('accepts a 64-character name (upper length boundary)', () => { - expect(validateToolNameFormat('a'.repeat(64))).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 65-character name', () => { - expect(validateToolNameFormat('a'.repeat(65))).toMatch( - /length 65 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/ ); }); @@ -57,7 +69,7 @@ describe('buildToolsNameFormatCheck', () => { it('returns SUCCESS when all tool names are valid', () => { const check = buildToolsNameFormatCheck([ { name: 'test_simple_text' }, - { name: 'namespace/tool-v1.2' } + { name: 'admin.tools.list' } ]); expect(check.status).toBe('SUCCESS'); expect(check.errorMessage).toBeUndefined(); @@ -65,33 +77,33 @@ describe('buildToolsNameFormatCheck', () => { toolCount: 2, results: { test_simple_text: 'valid', - 'namespace/tool-v1.2': 'valid' + 'admin.tools.list': 'valid' } }); }); - it('returns FAILURE with per-tool details when some names are invalid', () => { + 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(65) } + { name: 'a'.repeat(129) } ]); - expect(check.status).toBe('FAILURE'); + expect(check.status).toBe('WARNING'); expect(check.errorMessage).toContain( - '2 tool name(s) violate SEP-986 format' + '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(65)]).toMatch(/^invalid: length 65/); + 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('FAILURE'); + expect(check.status).toBe('WARNING'); const results = (check.details as { results: Record }) .results; expect(results['']).toBe( @@ -99,9 +111,85 @@ describe('buildToolsNameFormatCheck', () => { ); }); - it('includes both MCP-Tools-List and SEP-986 spec references', () => { + 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-Tools-List', 'SEP-986']); + 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' + ); }); }); diff --git a/src/scenarios/server/tools.ts b/src/scenarios/server/tools.ts index f18df3c..943681b 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -5,7 +5,9 @@ import { ClientScenario, ConformanceCheck, - DRAFT_PROTOCOL_VERSION + DRAFT_PROTOCOL_VERSION, + specVersionAtLeast, + type SpecVersion } from '../../types'; import type { RunContext } from '../../connection'; import type { @@ -21,26 +23,45 @@ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -const TOOL_NAME_PATTERN = /^[A-Za-z0-9_./-]+$/; -const TOOL_NAME_MAX_LENGTH = 64; +/** + * 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-Tools-List', - url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools' + id: 'MCP-Tool-Names', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names' }, { - id: 'SEP-986', - 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 'contains characters outside [A-Za-z0-9_.-]'; } return null; } @@ -52,7 +73,8 @@ export function buildToolsNameFormatCheck( const baseCheck = { id: 'tools-name-format', name: 'ToolsNameFormat', - description: 'Tool names are 1-64 characters and match ^[A-Za-z0-9_./-]+$', + description: + 'Tool names SHOULD be 1-128 characters and match ^[A-Za-z0-9_.-]+$', specReferences: TOOLS_NAME_FORMAT_SPEC_REFS, timestamp }; @@ -86,10 +108,10 @@ export function buildToolsNameFormatCheck( return { ...baseCheck, - status: violations.length === 0 ? 'SUCCESS' : 'FAILURE', + status: violations.length === 0 ? 'SUCCESS' : 'WARNING', errorMessage: violations.length > 0 - ? `${violations.length} tool name(s) violate SEP-986 format: ${violations.join('; ')}` + ? `${violations.length} tool name(s) violate spec Tool Names SHOULD rules: ${violations.join('; ')}` : undefined, details: { toolCount: tools.length, @@ -110,9 +132,10 @@ export class ToolsListScenario implements ClientScenario { **Requirements**: - Return array of all available tools - Each tool MUST have: - - \`name\` (string, 1-64 chars, matching \`^[A-Za-z0-9_./-]+$\`) + - \`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`; async run(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; @@ -159,9 +182,9 @@ export class ToolsListScenario implements ClientScenario { } }); - // Validate tool name format per SEP-986: - // names MUST be 1-64 chars matching ^[A-Za-z0-9_./-]+$ - checks.push(buildToolsNameFormatCheck(result.tools)); + if (toolNameFormatCheckApplies(ctx.specVersion)) { + checks.push(buildToolsNameFormatCheck(result.tools)); + } await conn.close(); } catch (error) { From 542381b7dc92e894e59e0c2d3479f63f2587a893 Mon Sep 17 00:00:00 2001 From: AKS <37595327+AmirK-S@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:13:29 +0200 Subject: [PATCH 219/226] feat(tools-list): check deterministic tools/list ordering on the 2026-07-28 wire (#492) * feat(tools-list): check deterministic tools/list ordering on the 2026-07-28 wire The 2026-07-28 revision adds to 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)." Nothing in the suite exercised it. Add a tools-list-deterministic-order check to the existing tools-list scenario. From the 2026-07-28 wire on, the scenario takes two more consecutive tools/list snapshots and compares the three name sequences. Same multiset of names, different order is WARNING (SHOULD). A set that changed between probes is reported as untestable rather than as a violation, because the spec scopes the SHOULD to an unchanged set. Fewer than two tools on every probe is INFO. The check carries source.introducedIn '2026-07-28', written as a literal since that revision is published and dated, so runners drop it on earlier wires. Prove it fails: examples/servers/typescript/tools-list-rotated-order.ts advertises the same four tools and rotates the list by one position per call; a negative.test.ts case asserts WARNING while tools-list itself still passes. Unit tests cover the helper that builds the check. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BjeC3sCvfHRpdEPz2x41B2 * tools-list: inline the deterministic-order check id and introducedIn literal Check ids and introducedIn revisions are written inline everywhere else in src/scenarios (tests pin the literal slug on purpose, since SDK baselines reference it), so drop the two exported constants. Co-Authored-By: Claude --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Paul Carleton Co-authored-by: Paul Carleton --- .../typescript/tools-list-rotated-order.ts | 76 ++++++++ src/scenarios/server/negative.test.ts | 39 ++++- src/scenarios/server/tools.test.ts | 141 +++++++++++++++ src/scenarios/server/tools.ts | 162 +++++++++++++++++- 4 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 examples/servers/typescript/tools-list-rotated-order.ts 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/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index f70aa90..c440beb 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -4,11 +4,11 @@ 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 { ToolsListScenario } from './tools'; import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types'; import { takeWireViolations } from '../../validation/wire-schema'; @@ -217,6 +217,43 @@ describe('Server scenario negative tests', () => { }, 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; diff --git a/src/scenarios/server/tools.test.ts b/src/scenarios/server/tools.test.ts index b1fe0f7..c8f72ad 100644 --- a/src/scenarios/server/tools.test.ts +++ b/src/scenarios/server/tools.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { + buildToolsListDeterministicOrderCheck, buildToolsNameFormatCheck, toolNameFormatCheckApplies, ToolsListScenario, @@ -193,3 +194,143 @@ describe('ToolsListScenario version gate', () => { ); }); }); + +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 943681b..fd423f1 100644 --- a/src/scenarios/server/tools.ts +++ b/src/scenarios/server/tools.ts @@ -10,6 +10,7 @@ import { type SpecVersion } from '../../types'; import type { RunContext } from '../../connection'; +import { notTestable, untestableCheck } from '../untestable'; import type { ListToolsResult, CallToolResult @@ -120,6 +121,131 @@ export function buildToolsNameFormatCheck( }; } +/** 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'; readonly source = { introducedIn: '2025-06-18' } as const; @@ -135,7 +261,9 @@ export class ToolsListScenario implements ClientScenario { - \`name\` (string) - \`description\` (string) - \`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 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(ctx: RunContext): Promise { const checks: ConformanceCheck[] = []; @@ -186,6 +314,38 @@ export class ToolsListScenario implements ClientScenario { 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({ From 3507f9415025b980b9fe4f955c2e90cb72bce7c4 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sun, 6 Sep 2026 13:23:38 -0700 Subject: [PATCH 220/226] fix(sep-2243): reject x-mcp-header on number-typed params (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2243 permits `x-mcp-header` only on integer, string and boolean parameters — "Parameters with type `number` are not permitted" — but the harness never verified the `number` case and its requirement text still described `number` as permitted. PR #371 fixed the positive `http-custom-headers` fixture (priority is now `integer`, `float_val` is served unannotated) but left the two remaining items from #344: - `HttpInvalidToolHeadersScenario` serves invalid tools for every other x-mcp-header constraint but had no number-typed case, so nothing checked that a client excludes such a tool. - `sep-2243.yaml` claimed primitive types are "(number, string, or boolean)" and that "number values" get decimal-string encoding, both of which contradict the released 2026-07-28 spec. Add an `invalid_number_header` tool mapped to the existing `sep-2243-x-mcp-header-primitive-only` requirement (no new check IDs) and correct both requirement texts. traceability.json is updated in place since it embeds the yaml text verbatim; check IDs and statuses are unchanged. Verified against typescript-sdk@main: the new check fails there because the SDK deliberately allow-lists `number` to satisfy the old fixture — a workaround its own source notes as "tracked upstream" and can now drop. The positive scenario still passes 18/18. Closes #344 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../client/http-custom-headers.test.ts | 50 +++++++++++++++++++ src/scenarios/client/http-custom-headers.ts | 18 +++++++ src/seps/sep-2243.yaml | 4 +- src/seps/traceability.json | 4 +- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/scenarios/client/http-custom-headers.test.ts b/src/scenarios/client/http-custom-headers.test.ts index 8a68f22..56bf8c8 100644 --- a/src/scenarios/client/http-custom-headers.test.ts +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -30,6 +30,18 @@ async function post( }); } +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)); } @@ -198,4 +210,42 @@ describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => { 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 index 8e64e6a..4a73df3 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -68,6 +68,7 @@ const INVALID_TOOL_CONSTRAINT_IDS: Record = { 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', @@ -861,6 +862,23 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario { } }, + // ── 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', diff --git a/src/seps/sep-2243.yaml b/src/seps/sep-2243.yaml index a39b6d2..ddb06ff 100644 --- a/src/seps/sep-2243.yaml +++ b/src/seps/sep-2243.yaml @@ -23,13 +23,13 @@ requirements: 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 (number, string, or boolean).' + 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: number values MUST be converted to their decimal string representation; boolean values MUST be converted to the lowercase strings "true" or "false".' + 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 diff --git a/src/seps/traceability.json b/src/seps/traceability.json index bdf6c62..674f2e9 100644 --- a/src/seps/traceability.json +++ b/src/seps/traceability.json @@ -209,7 +209,7 @@ { "check": "sep-2243-x-mcp-header-primitive-only", "status": "tested", - "text": "x-mcp-header MUST only be applied to parameters with primitive types (number, string, or boolean).", + "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" }, { @@ -221,7 +221,7 @@ { "check": "sep-2243-client-encode-values", "status": "tested", - "text": "Clients MUST encode parameter values before including them in HTTP headers: number values MUST be converted to their decimal string representation; boolean values MUST be converted to the lowercase strings \"true\" or \"false\"." + "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", From cf022cbb541f9d939068d7f84758b9728be7fc23 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 7 Sep 2026 04:27:49 +0800 Subject: [PATCH 221/226] fix(validation): accept extension result envelopes (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(validation): accept extension result envelopes This fixes #424 Signed-off-by: King Star * validation: only known extension resultTypes take the envelope-only path Restrict the generic-envelope fallback to resultType values a known extension defines (today: 'task', the tasks extension's CreateTaskResult). Any other non-core resultType is still validated as the request method's own result — the discriminator is open, so it may be a private extension — and a resulting violation names the unrecognised value so a typo or an unknown extension is obvious instead of silently passing envelope-only. Co-Authored-By: Claude --------- Signed-off-by: King Star Co-authored-by: triage Co-authored-by: Claude --- src/validation/wire-schema.test.ts | 43 +++++++++++++++++++++++++++ src/validation/wire-schema.ts | 47 +++++++++++++++++++++++++----- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/validation/wire-schema.test.ts b/src/validation/wire-schema.test.ts index 98fc4d7..d351047 100644 --- a/src/validation/wire-schema.test.ts +++ b/src/validation/wire-schema.test.ts @@ -117,6 +117,49 @@ describe('wireSchemaErrors', () => { ).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', [ diff --git a/src/validation/wire-schema.ts b/src/validation/wire-schema.ts index 8b45849..1cdbed0 100644 --- a/src/validation/wire-schema.ts +++ b/src/validation/wire-schema.ts @@ -61,6 +61,18 @@ const NON_CANONICAL_DEFS = new Set([ '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>; @@ -261,18 +273,37 @@ export function wireSchemaErrors( 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. + // 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 = - (msg.result as Record | null)?.resultType === - 'input_required' && 'InputRequiredResult' in spec.defs; - const resultDefName = inputRequired - ? 'InputRequiredResult' - : requestMethod !== undefined - ? spec.resultDefs.get(requestMethod) + 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}')` + (e) => `${e} (result of '${requestMethod}')${hint}` ); if (typed.length > 0) return typed; } From 0e1235d352be75d29e4c4cd13e55ee17293a990f Mon Sep 17 00:00:00 2001 From: Aditya Chilka <80785619+adityachilka1@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:25:37 +0530 Subject: [PATCH 222/226] fix(initialize scenario): return 202 No Content for notifications (closes #274) (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport spec (2025-11-25 § 2.1, point 4) requires that a server reply to a JSON-RPC notification with 202 Accepted and no body. The fall-through branch in the initialize scenario test server returned 200 with `{"jsonrpc":"2.0","id":null,"result":{}}` for all non-initialize, non-tools/list requests - including `notifications/initialized`. Branch on `request.id == null` (notification) and emit 202 with no body in that case. Non-notification requests still get the prior placeholder 200-response behavior. Closes #274. Co-authored-by: aditya Co-authored-by: Paul Carleton --- src/scenarios/client/initialize.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index be8d708..9906622 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -83,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( From 8c1d4a6d83c5f5b9de3bea4c2e944f89082be397 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 7 Sep 2026 04:56:29 +0800 Subject: [PATCH 223/226] Send initialized after DNS rebinding probe (#340) Co-authored-by: Paul Carleton --- src/scenarios/server/dns-rebinding.ts | 79 +++++++++- src/scenarios/server/negative.test.ts | 211 ++++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 4 deletions(-) diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index e93f810..ab4ac8b 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -87,6 +87,15 @@ function probeBody(specVersion: string): { }; } +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 @@ -96,7 +105,7 @@ async function sendRequestWithHostAndOrigin( serverUrl: string, hostOrOrigin: string, specVersion: string -): Promise<{ statusCode: number; body: unknown }> { +): 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 @@ -121,6 +130,43 @@ async function sendRequestWithHostAndOrigin( 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', + method + }) + }); + + let body: unknown; + try { + body = await response.body.json(); + } catch { + body = null; + } + return { statusCode: response.statusCode, body @@ -253,25 +299,50 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ ); 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/negative.test.ts b/src/scenarios/server/negative.test.ts index c440beb..746cf9c 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -1,5 +1,7 @@ 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'; @@ -56,6 +58,47 @@ function stopServer(proc: ChildProcess | null): Promise { }); } +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; @@ -86,6 +129,174 @@ describe('Server scenario negative tests', () => { ); 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', () => { From 7022d786071e6d868dcd5cf19a8c30db7d811537 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 7 Sep 2026 04:56:37 +0800 Subject: [PATCH 224/226] Fix custom header schema freshness (#358) Co-authored-by: Paul Carleton --- .../client/draft-result-fields.test.ts | 6 +- .../client/http-custom-headers.test.ts | 82 ++++++++++++++++++- src/scenarios/client/http-custom-headers.ts | 2 +- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts index 27f64de..8307645 100644 --- a/src/scenarios/client/draft-result-fields.test.ts +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -349,7 +349,11 @@ describe('http-custom-headers mock results (2026-07-28)', () => { method: 'tools/list', params: {} }); - expect(list.body.result).toMatchObject(CACHEABLE_FIELDS); + expect(list.body.result).toMatchObject({ + resultType: 'complete', + cacheScope: 'private' + }); + expect(list.body.result.ttlMs).toBeGreaterThan(0); const call = await post(serverUrl, { jsonrpc: '2.0', diff --git a/src/scenarios/client/http-custom-headers.test.ts b/src/scenarios/client/http-custom-headers.test.ts index 56bf8c8..2834191 100644 --- a/src/scenarios/client/http-custom-headers.test.ts +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -18,8 +18,8 @@ async function post( serverUrl: string, body: object, headers: Record = {} -): Promise { - await fetch(serverUrl, { +): Promise<{ status: number; body: any }> { + const response = await fetch(serverUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -28,6 +28,7 @@ async function post( }, body: JSON.stringify(body) }); + return { status: response.status, body: await response.json() }; } async function postJson(serverUrl: string, body: object): Promise { @@ -162,6 +163,83 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { 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', () => { diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index 4a73df3..062efd7 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -278,7 +278,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { id: request.id, result: { resultType: 'complete', - ttlMs: 0, + ttlMs: 300000, cacheScope: 'private', tools: [ { From a983ba93c91e0bb31d0b6849eeb52f0ad1083107 Mon Sep 17 00:00:00 2001 From: Tyler Klose Date: Mon, 7 Sep 2026 01:34:41 -0400 Subject: [PATCH 225/226] feat(authorization): send the RFC 8707 `resource` parameter (#466) The runner is the MCP client in the `authorization` direction, and the spec requires clients to send `resource` in both the authorization request and the token request. It sent neither. Adds `--resource ` and a matching `resource` field on AuthorizationServerOptionsSchema, so `--file` supplies it too. The value must be an absolute URI with no fragment. The parameter is only sent when set, so existing runs are unchanged. No new check IDs: in this direction the runner is the client, so a check that it sent `resource` could never fail. The existing grant checks now reach a path they couldn't before. Co-authored-by: Claude Opus 5 (1M context) --- src/index.ts | 4 + .../authorization-code-grant.test.ts | 99 +++++++++++++++++++ .../authorization-code-grant.ts | 6 ++ .../authorization-server-metadata.test.ts | 33 +++++++ src/schemas.ts | 9 ++ 5 files changed, 151 insertions(+) diff --git a/src/index.ts b/src/index.ts index c42be2e..b376644 100644 --- a/src/index.ts +++ b/src/index.ts @@ -767,6 +767,10 @@ program '--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', diff --git a/src/scenarios/authorization-server/authorization-code-grant.test.ts b/src/scenarios/authorization-server/authorization-code-grant.test.ts index 6b51bf6..e2eaf06 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.test.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.test.ts @@ -18,6 +18,8 @@ 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', @@ -311,6 +313,103 @@ describe('AuthorizationCodeGrantScenario', () => { 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(); diff --git a/src/scenarios/authorization-server/authorization-code-grant.ts b/src/scenarios/authorization-server/authorization-code-grant.ts index 48bcdee..13f3ba0 100644 --- a/src/scenarios/authorization-server/authorization-code-grant.ts +++ b/src/scenarios/authorization-server/authorization-code-grant.ts @@ -168,6 +168,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz code_challenge: this.codeChallenge, code_challenge_method: 'S256' }); + if (options.resource) { + params.set('resource', options.resource); + } return `${metadata.authorization_endpoint}?${params.toString()}`; } @@ -260,6 +263,9 @@ export class AuthorizationCodeGrantScenario implements ClientScenarioForAuthoriz 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' }; diff --git a/src/scenarios/authorization-server/authorization-server-metadata.test.ts b/src/scenarios/authorization-server/authorization-server-metadata.test.ts index a1a5d66..20ad437 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.test.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.test.ts @@ -205,6 +205,39 @@ describe('AuthorizationServerOptionsSchema', () => { 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', () => { diff --git a/src/schemas.ts b/src/schemas.ts index 040aa2e..b8f6783 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -57,6 +57,15 @@ export const AuthorizationServerOptionsSchema = z.object({ .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') From 7169291ec0b68eb370fddcd9947313ab0d5e4156 Mon Sep 17 00:00:00 2001 From: Sri Panyam Date: Fri, 11 Sep 2026 06:52:51 -0700 Subject: [PATCH 226/226] feat(sep-2640): skills server conformance against the Accepted SEP, enumeration + manifest + directory (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add SEP-2640 requirement-traceability YAML (Skills Extension) * style: apply yaml formatter pass * chore(sep-2640): record spec source provenance * chore(sep-2640): re-extract against SEP HEAD 556154c (drops mcp-resource-template) Re-extract sep-2640.yaml against current SEP-2640 HEAD. The SEP removed the `mcp-resource-template` index entry type in two commits on 2026-06-04 (fd50cc91 "Remove mcp-resource-template entries from skill index", 556154c0 "Remove remaining resource template mentions from SDK and rationale sections"), after this extraction was first captured on 2026-06-03 at b77fdfe8. Provenance comment bumped accordingly. Resulting changes: - sep-2640-index-entry-type-enum: drop `"mcp-resource-template"` from the enum. - sep-2640-index-name-required: drop the "is Required for ... Omitted for mcp-resource-template" conditional language; the column is now unconditional Required=Yes at SEP HEAD. - sep-2640-index-digest-required: same simplification; also align "hexadecimal" -> "hex" with the HEAD table text. - sep-2640-template-resource-template-registered: removed. The SEP no longer defines a template entry type, so the SHOULD on registering an MCP resource template is gone. - Excluded "Hosts SHOULD surface template entries..." excerpt: removed. The sentence no longer exists in the SEP. Sweep verified: zero "template" mentions in the SEP at HEAD. Security Implications section sentences referenced by the remaining excluded entries are intact. No other check rows touched. * feat(sep-2640): ResourcesDirectoryReadScenario for resources/directory/read Adds the conformance scenario for the SEP-2640 directoryRead surface that landed in spec commit 2e04c48d (2026-06-09). Per AGENTS.md "fewer scenarios, more checks", a single ResourcesDirectoryReadScenario emits 6 ConformanceChecks, one per new sep-2640.yaml requirement row. Class named for the wire method (resources/directory/read), matching the existing ResourcesListScenario / ResourcesReadTextScenario / etc. family in src/scenarios/server/resources.ts. The runner-facing name field stays as 'sep-2640-skills' (umbrella) so mcpkit's conformance/Makefile entry --scenario sep-2640-skills keeps working without a cross-repo race. src/seps/sep-2640.yaml - File-level provenance held at 556154c (the existing PR 330 baseline). - 6 new check rows appended for the directoryRead additions, each carrying a verbatim, grep-F-searchable excerpt from the SEP at 2e04c48da90224000e750ffd54a3611f2824fbc0: - sep-2640-capability-directory-read-flag - sep-2640-directory-read-method-registered - sep-2640-directory-read-subdir-mimetype - sep-2640-directory-read-result-resources-shape - sep-2640-directory-read-invalid-params - sep-2640-directory-read-pagination - forward_reference header comment notes the asymmetry: PR 97 schema rewrite (360123d0, 2026-06-08) made 3 existing rows stale and drifted ~11 others' wording; full re-extraction at SEP HEAD is mcpkit#780's lifecycle. Provenance deliberately holds at 556154c until that lands. src/types.ts - Adds io.modelcontextprotocol/skills to EXTENSION_IDS so the scenario's source: { extensionId: ... } tag type-checks. src/scenarios/server/directory.ts (new) - Capability discovery via wire-observable signal: -32601 method-not-found is the only definitive "server did not declare directoryRead" signal. - Fixture assumption: server exposes skill://acme/billing/refunds/templates with at least one subdirectory child (mcpkit examples/skills layout). No skill:// resources at all -> every check emits SKIPPED so the scenario stays green against upstream's everything-server fixture. - 6 checks (1:1 with the YAML rows above): 1. directoryRead declared (derived from method registration) 2. method registered (happy-path call succeeds) 3. result.resources shape matches resources/list 4. subdir child carries mimeType: "inode/directory" 5. non-directory URI returns -32602 6. nextCursor round-trips (single-page is conformant) src/scenarios/index.ts - Imports + registers ResourcesDirectoryReadScenario in allClientScenariosList, matching the registration name 'sep-2640-skills' that mcpkit's conformance/Makefile already passes to --scenario. Verified end-to-end against mcpkit examples/skills: cd ~/newstack/mcpkit/main && \ MCPCONFORMANCE_SKILLS_PATH=~/newstack/mcpkit/conf-skills \ make -C conformance testconf-skills -> 6/6 SUCCESS, 0 failed, 0 warnings. Out of scope: - PR 97 schema rewrite YAML refresh (separate ticket, mcpkit#780 lifecycle). - Negative-capability fixture (server without directoryRead) - mcpkit examples/skills has no flag for it; ext/skills/client_directory_test.go already covers the SDK pre-call guard. * feat(sep-2640): expand skills conformance — index + manifest scenarios, drift fixes Builds on the directory-read scenario with the server-observable skills surface and brings the whole set up to the current framework conventions. New scenarios (src/scenarios/server/skills/): - SkillsIndexScenario (sep-2640-skills-index): skill://index.json is exposed and readable; every skills[].type is in the SEP enum; every entry has a non-empty name; a present digest matches sha256:{64hex}; entry URLs use skill://. - SkillsManifestScenario (sep-2640-skills-manifest): a skill-md SKILL.md is markdown-typed; frontmatter name/description surface on the resource; the final skill-path segment equals the frontmatter name; skill _meta keys use the reverse-domain prefix. directory.ts (moved into skills/): capability is now read from server/discover instead of inferred from -32601; an undeclared optional capability SKIPs rather than FAILs; the untestable.ts helpers back the not-exercisable paths; all mcpkit-specific fixture URIs are gone — the directory to exercise is discovered dynamically, so the scenario is brand-neutral. Capability gating accepts directoryRead at either extensions[id].directoryRead (SEP-2640's inline example) or extensions[id].config.directoryRead (the SEP-2133 negotiation envelope SEP-2640 defers to); the two SEPs are inconsistent on nesting, so the check accepts either rather than privileging one reading. All three scenarios verified green against a live SEP-2640 server (6/6, 6/6, 7/7); typecheck, eslint, prettier, and the 504-test vitest suite pass. sep-2640.yaml gains a header note mapping each scenario to the rows it now backs. * feat(sep-2640): re-extract against the 2026-08-21 rewrite The prior extraction held at SEP commit 556154c (2026-06-05) by an explicit in-file decision to wait for the index-schema refactor. The 08-21 core-maintainer rewrite made that pin actively wrong rather than merely behind. Removed as no longer normative: the skill://index.json well-known resource (absent from the current text), the entry type enum, the no-nested-skills rule (now reversed - skills MAY nest), and the five archive rows (archives are deferred to an appendix). Reworded the two index-field rows, the empty-listing row and the verify-digest row. Added the new surface: skills/list and skills/get with their pagination, atomicity and caching attributes; the {uri, frontmatter, resources} entry schema; resources completeness, the "dynamic" sentinel and per-file size; the 512-entry and 16 MiB limits; and the host obligations around lazy retrieval, content-bound approval and frontmatter re-verification. index.ts is replaced by enumeration.ts, which drives both new methods. manifest.ts and directory.ts swap their skill://index.json discovery fallback for skills/list. The capability helper no longer accepts a config envelope. SEP-2133 is Final and defines the extensions field as a map of identifiers to settings objects, with no envelope and no slot for id/specVersion/stability; SEP-2640's capability block matches. The earlier leniency was based on a misreading. 77 rows: 65 checks (39 wire-tested by the three server scenarios) and 12 excluded as host-internal. * feat(sep-2640): close the extraction gaps found by a normative-sentence audit The first pass extracted the Specification sections but under-covered Security Implications, which carries 21 of the SEP's 97 RFC-2119 sentences. Sweeping every normative sentence in the source surfaced rows that were missing entirely: - origin tagging at the point content enters model context (the L179 host MUST) - allowed-tools MUST be ignored for MCP-origin skills - reads bound to the originating server, so a skill from A cannot drive a read against B, and any cross-origin read gated behind per-call approval - servers identified by a host-assigned label, never serverInfo.name - the three disk-cache rules: write-isolation or re-hash, exclusion from filesystem-skill discovery paths, and removal when a server is removed - a resources/read of a SKILL.md by another route is not a load - a nested SKILL.md read as supporting content is ordinary markdown - the registry-assembly and virtual-mount no-prefetch restatements - the SDK affordances guidance, which is the row mcpkit itself is measured by Five rows previously filed as excluded are now declared instead. They are real requirements that a host-side harness could check; calling them unobservable was a limitation of this scenario set, not of the requirement. Adds one wire check, names-should-be-unique, since a within-listing collision is observable and tells an operator the listing needs disambiguating. 96 rows: 89 checks (40 wire-tested) and 7 excluded. * style: apply prettier to the SEP-2640 scenarios and yaml Quote-style normalisation in the yaml (escaped single quotes become double-quoted scalars) and one return-type wrap in helpers.ts. The parsed yaml is byte-identical in content; verified by comparing the parse before and after. * docs(sep-2640): note why resultType is not declared in this yaml It appears in all three result examples but is a base-protocol field on the common Result interface, not a SEP-2640 requirement; its caching-hint obligations are already owned by sep-2549.yaml. * fix(sep-2640): follow nextCursor on resources/directory/read The directory scenario issued one resources/directory/read and every check below it inspected that single page: the result-shape check, the subdirectory-mimeType check, and the target selection for the -32602 probe. SEP-2640 says directory-read pagination mirrors resources/list, so a conformant server MAY split a directory across pages, and reading only the first made "no subdirectory here" indistinguishable from "the subdirectory is on page two". Reported by Sam Bloomberg against the go-sdk implementation, which exposes DirectoryOptions.PageSize. Rebuilding that server with PageSize 1 fails sep-2640-directory-read-subdir-mimetype; with this change it passes 7/7, and both servers still pass at their default page size. Only findable against a second implementation. mcpkit hardcoded its page size to zero with no override, so it never emitted a cursor and the defect was invisible to every run against it. listAllResources already did this for resources/list. directoryReadAll applies the same shape to directory reads, with a page bound and a repeated-cursor guard. * feat(sep-2640): client scenario for the no-prefetch MUST NOT The traceability file filed retrieval policy under "host obligations a harness cannot observe", alongside 48 other rows. That was wrong for this one: "hosts MUST NOT retrieve a skill's files ahead of need" reduces to whether a request arrived, which makes it among the most wire-observable rules in the SEP. Adds a Scenario (harness stands up the server, client is the SUT) that serves a listing naming a SKILL.md and a supporting file, then fails if either is read. The client's contract, keyed on MCP_CONFORMANCE_SCENARIO, is to connect, enumerate, and stop. SKIPs rather than passes when the client never calls skills/list, since without a listing there is no window in which prefetching is possible and a pass would prove nothing. Verified both directions against mcpkit's client: SUCCESS as written, and FAILURE with a deliberate ReadFromEntry added to the driver. A check that has never been shown to fail is the same trap as the directory-pagination false negative this suite shipped for months. * feat(sep-2640): client scenarios for the four read-time verification MUSTs Adds digest, size, frontmatter and unlisted-URI variants alongside the no-prefetch scenario. Each serves an internally honest listing, tampers with exactly one thing on the wire, and watches whether the client notices. Rejection is detected by absence, following auth/resource-mismatch: the client's contract is "load the skill, then read its supporting file", and a client that verifies aborts on the load and never reaches the supporting file. Reaching it is the violation. The unlisted variant is proven by the read never appearing on the wire at all, since a conformant client refuses locally. The frontmatter variant recomputes digest and size for the tampered body, so the frontmatter comparison is the only thing left that can fail. Without that the digest check would mask it and the scenario would pass for the wrong reason. allowClientError is set on all four: a verifying client exits non-zero because it correctly aborted, and that is the pass. Verified both directions against mcpkit's client. All four SUCCESS as written; all four FAILURE against a driver rewritten to read through ReadSkillURI, which skips verification entirely. * fix(sep-2640): gate the cache-attributes check on the negotiated version The check graded ttlMs and cacheScope without looking at the protocol version, which made it wrong in both directions. Below 2026-07-28 those fields are not defined by the negotiated schema at all, so a server that omits them is correct and the WARNING was a false positive. From 2026-07-28 they are required, so a missing one is a failure rather than a warning. Now SKIPPED with a version reason below 2026-07-28, and SUCCESS or FAILURE above it. Reported by Sam Bloomberg against the Go SDK, which was correct on both wires while this check mis-graded it on both. Second finding from the same reviewer against this branch, and the second that only a second implementation could surface: mcpkit emits the attributes on every version it supports, so it could never have produced the false positive. Verified three ways: mcpkit at 2026-07-28 SUCCESS, the Go SDK at 2025-11-25 SKIPPED where it previously warned, and a deliberately stripped mcpkit build at 2026-07-28 FAILURE. * docs(sep-2640): how to run the skills scenarios against any implementation Three implementations have now been cross-checked and every one needed a different invocation, so the knowledge was living in chat rather than the repo. Records the two flags and why both are needed, how to read totals that include wire-schema-valid, and the per-SDK setup. The two traps are worth writing down because both have already cost someone time. Extension scenarios do not match --spec-version alone, so --force is always needed. And --spec-version selects the wire lifecycle rather than filtering, so omitting it against a pre-2026-07-28 server produces a -32022 that reads like a missing method. Also records that the go-sdk serves 2026-07-28 only when its streamable transport is Stateless, which is correct per SEP-2575 and which I briefly mistook for a defect. * fix(sep-2640): advertise skills on server/discover, not only initialize Both client scenarios declared the skills extension and `resources` in their `initialize` reply, but `BaseHttpScenario` intercepts `server/discover` before `handlePost` runs. On the 2026-07-28 lifecycle a discover-first client therefore saw the base class default of `{tools: {}}` and had nothing to gate a `skills/list` call on. The scenarios still passed, because `server/discover` is optional for clients and neither client we ran used it. That is the failure mode where a check goes green because its condition never arose, so both lifecycles are now asserted in capabilities.test.ts rather than left to the next implementation to find. Overriding `discoverCapabilities()` covers both paths at once, since `sendInitialize` already defaults to it. * fix(sep-2640): give the capability and naming checks something to fail on `sep-2640-capability-empty-object` reported a hardcoded SUCCESS, so it could not fail and was checking nothing. 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. That also closes a false SKIP. `skillsCapability` folded a malformed declaration into `undefined`, which downstream read as "extension not declared" and skipped the whole suite, so a server declaring the extension as `true` produced a clean run. `declaredSkillsCapability` separates absent from malformed; the inline/envelope check now SKIPs on a malformed value, since there are no settings to place either way. The base `DiscoverResult` schema rejects a non-object too, so both halves are asserted. `SKILL_NAME_PATTERN` accepted consecutive hyphens. The Agent Skills specification that SEP-2640 defers to for naming lists four rules and we encoded three, so `pdf--processing` passed. Its own valid and invalid examples are now the test cases. Negative controls recorded for all three: with the check back to a hardcoded SUCCESS the four malformed-declaration cases fail, and with the previous regex the consecutive-hyphen case fails. * docs(sep-2640): name skills/list in the discovery prose, not index.json The 2026-08-21 revision replaced the `skill://index.json` well-known resource with `skills/list`, and the code follows it — `discoverDirectory` and the manifest fallback both call `skillsListAll`. Only the prose lagged, including three strings a user actually sees: two untestable-check reasons and the directory scenario's description. The remaining mentions in helpers.ts, enumeration.ts and sep-2640.yaml are deliberate history, describing what was replaced. * fix(sep-2640): grade unlisted reads against the entry, not one fixture URI The `unlisted` client scenario asserted `reads.includes(UNLISTED_URI)`, so it only caught a client that probed the exact path this fixture happens to serve. Any client choosing a different unlisted path passed without being tested, and the check would have reported SUCCESS for a client that reads unlisted files freely. The requirement is "hosts MUST resolve reads of the skill's files only to URIs listed in that entry's resources", so the permitted set now comes from the entry the client was handed and any read outside it is the violation. That is the sentence itself rather than a proxy for it, and it lets a SUT derive its own probe instead of hardcoding ours. Negative control: a client reading a synthesized `mcpkit-unlisted-probe-0.md` is now a FAILURE, where the previous check reported SUCCESS. * fix(sep-2640): require the manifest read, and retire the unlisted scenario Two changes to the client-side verification scenarios, one of which fixes a hole the other one shares. **The manifest read is now a prerequisite.** 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. Both reported SUCCESS, so `listCalled` was the only gate and a client implementing `skills/list` and nothing else passed every variant here. It scored 5/5 on this suite while doing nothing. Absence now means something only after the client has read the tampered `SKILL.md`. A missing prerequisite reports untestable, which per #248 is a FAILURE rather than a SKIP: SKIPPED is excluded from pass/fail counts and exit codes, so it would read as green and hide the gap. Negative control: a driver that lists and exits goes from 1/1 to 0/1 on all three variants. **`-verify-unlisted` is retired**, on Peter Alexander's call. 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. It is also the rule an inspection tool answers differently from a host, since refusing a read requires having a load to refuse, which MCP Inspector 2.6.0 reported as "surfaced" rather than passed. The requirement is not deleted. It moves to an `excluded:` row alongside the acting-on window that scopes it, and the reason records that it is excluded by construction rather than in principle: a `SKILL.md` body linking 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. The scenario name and check id are named there so a future reintroduction lines up with historical reports rather than reading as a new requirement. Check rows go from 89 to 88. * feat(sep-2640): check skills/get cache attributes and the resources capability Both requirements landed when the stable spec page merged ext-skills#139 today, and both are checkable on the wire. **`sep-2640-skills-get-cache-attributes`.** `GetSkillResult extends CacheableResult`, so `ttlMs` and `cacheScope` are REQUIRED as they are on `resources/read`. Held out of this PR until the page merged rather than grading against text that was still open: SEP-2640 says of these fields that "whether the result should also carry the base protocol's caching attributes ... is left open", so this is a behaviour change and a server conformant to the SEP text alone omits both. It is a reading not everyone shares. The MCP Inspector's SEP-2640 writeup argues the opposite, that the SEP forecloses it and inventing the requirement would report a reasonable server as broken. Gated the same way as the skills/list check: below 2026-07-28 `CacheableResult` is undefined by the negotiated schema, so omission is correct and the check SKIPs with a version reason rather than failing a conformant legacy server. **`sep-2640-capability-requires-resources`.** A server declaring the extension MUST also declare the base `resources` capability, since skill files are served through `resources/read`. The page states it as a consequence of the base Resources specification rather than a free-standing new rule, and the declaration is on the wire either way. Verified against mcpkit: 32/32 on 2026-07-28 and 30/30 on 2025-11-25, where the get-cache check SKIPs below the floor as designed. Negative control recorded: a build with the attributes stripped from `handleSkillsGet` reports 31/32 with `sep-2640-skills-get-cache-attributes` FAILURE. Check rows go from 88 to 90. * fix(sep-2640): compare frontmatter by content, not key order entry-frontmatter-identical compared each top-level key with JSON.stringify, which is key-order sensitive for nested objects. A server whose encoder sorts map keys (Go's encoding/json does) failed this MUST for any SKILL.md whose metadata keys are not in alphabetical order, although the content is identical. Compare structurally instead. A real difference still fails; both cases are pinned in negative.test.ts. * fix(sep-2640): make the client-scenario mocks answer like a conformant server The skills/list and resources/read replies carried only resultType, so on the 2026-07-28 wire they lacked the ttlMs/cacheScope a ReadResourceResult requires, and skills/list lacked the attributes the enumeration scenario requires of servers. initialize always answered 2026-07-28, while the runner hands these extension scenarios' clients 2025-11-25 by default. A client that checks the negotiated version disconnected before listing, and every verify variant reported SKIPPED whether or not the client verified anything. The mocks now stamp the caching members and answer initialize at the requested version when the suite can speak it. The runner default is left alone because it also applies to the auth extension scenarios. Pinned in draft-result-fields.test.ts next to the other hand-rolled mocks. * docs(sep-2640): run from the published package; --force only with --spec-version The run notes pointed at a fork branch that goes stale on merge, and said --force is always needed. Extension scenarios only SKIP when an explicit --spec-version is passed; without it they run on the draft wire. * docs(sep-2640): drop RUNNING_SEP2640.md Per-run notes and SDK result tables belong in the PR description, not a root-level doc that goes stale on merge. --------- Co-authored-by: Paul Carleton --- .../client/draft-result-fields.test.ts | 95 ++ .../client/skills/capabilities.test.ts | 83 ++ src/scenarios/client/skills/mock-results.ts | 52 + src/scenarios/client/skills/no-prefetch.ts | 189 +++ src/scenarios/client/skills/verification.ts | 280 ++++ src/scenarios/index.ts | 32 +- src/scenarios/server/skills/directory.ts | 393 ++++++ src/scenarios/server/skills/enumeration.ts | 1215 +++++++++++++++++ src/scenarios/server/skills/helpers.ts | 461 +++++++ src/scenarios/server/skills/manifest.ts | 358 +++++ src/scenarios/server/skills/negative.test.ts | 301 ++++ src/seps/sep-2640.yaml | 308 +++++ src/types.ts | 3 +- 13 files changed, 3767 insertions(+), 3 deletions(-) create mode 100644 src/scenarios/client/skills/capabilities.test.ts create mode 100644 src/scenarios/client/skills/mock-results.ts create mode 100644 src/scenarios/client/skills/no-prefetch.ts create mode 100644 src/scenarios/client/skills/verification.ts create mode 100644 src/scenarios/server/skills/directory.ts create mode 100644 src/scenarios/server/skills/enumeration.ts create mode 100644 src/scenarios/server/skills/helpers.ts create mode 100644 src/scenarios/server/skills/manifest.ts create mode 100644 src/scenarios/server/skills/negative.test.ts create mode 100644 src/seps/sep-2640.yaml diff --git a/src/scenarios/client/draft-result-fields.test.ts b/src/scenarios/client/draft-result-fields.test.ts index 8307645..6ccd0bf 100644 --- a/src/scenarios/client/draft-result-fields.test.ts +++ b/src/scenarios/client/draft-result-fields.test.ts @@ -12,6 +12,8 @@ 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 @@ -528,3 +530,96 @@ describe('sep-2322-client-request-state mock results (2026-07-28)', () => { } }); }); + +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/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/index.ts b/src/scenarios/index.ts index 03cf55f..375d701 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -10,6 +10,8 @@ import { 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'; @@ -58,6 +60,10 @@ import { 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, @@ -151,7 +157,15 @@ const pendingClientScenariosList: ClientScenario[] = [ new TasksDispatchScenario(), new TasksStatusNotificationsScenario(), new TasksRequiredTaskErrorScenario(), - new TasksMrtrCompositionScenario() + 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 @@ -203,6 +217,12 @@ const allClientScenariosList: ClientScenario[] = [ // 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(), @@ -317,7 +337,15 @@ const scenariosList: Scenario[] = [ new JsonSchemaRefDerefScenario(), // JSON Schema 2020-12 client-side keyword preservation (SEP-1613, SEP-2106) - new JsonSchema2020_12PreservationScenario() + 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) 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/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/types.ts b/src/types.ts index 5960945..ebe75a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,7 +102,8 @@ export const EXTENSION_IDS = [ 'io.modelcontextprotocol/enterprise-managed-authorization', 'io.modelcontextprotocol/auth/dpop', 'io.modelcontextprotocol/auth/wif', - 'io.modelcontextprotocol/tasks' + 'io.modelcontextprotocol/tasks', + 'io.modelcontextprotocol/skills' ] as const; export type ExtensionId = (typeof EXTENSION_IDS)[number];