From b0cf83716b6bacc575f3ef3ff3403b60ceb017b8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:18:45 +0000 Subject: [PATCH 01/11] feat: add conformance test infrastructure for v1.x (#1518) Co-authored-by: Claude Opus 4.6 Co-authored-by: Paul Carleton --- .github/workflows/conformance.yml | 40 + .gitignore | 1 + package-lock.json | 115 ++- package.json | 8 +- test/conformance/conformance-baseline.yml | 14 + .../scripts/run-server-conformance.sh | 45 + test/conformance/src/everythingClient.ts | 370 +++++++ test/conformance/src/everythingServer.ts | 953 ++++++++++++++++++ .../src/helpers/conformanceOAuthProvider.ts | 87 ++ test/conformance/src/helpers/logger.ts | 27 + .../conformance/src/helpers/withOAuthRetry.ts | 81 ++ test/conformance/tsconfig.json | 8 + 12 files changed, 1734 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/conformance.yml create mode 100644 test/conformance/conformance-baseline.yml create mode 100755 test/conformance/scripts/run-server-conformance.sh create mode 100644 test/conformance/src/everythingClient.ts create mode 100644 test/conformance/src/everythingServer.ts create mode 100644 test/conformance/src/helpers/conformanceOAuthProvider.ts create mode 100644 test/conformance/src/helpers/logger.ts create mode 100644 test/conformance/src/helpers/withOAuthRetry.ts create mode 100644 test/conformance/tsconfig.json diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 000000000..974a84cdb --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,40 @@ +name: Conformance Tests + +on: + push: + branches: [v1.x] + pull_request: + branches: [v1.x] + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + client-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:conformance:client:all + + server-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test:conformance:server diff --git a/.gitignore b/.gitignore index a1b83bc4f..81be15073 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ dist/ # IDE .idea/ +test/conformance/node_modules/ diff --git a/package-lock.json b/package-lock.json index e3f00b3a7..cca035cbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "devDependencies": { "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", + "@modelcontextprotocol/conformance": "^0.1.11", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", @@ -735,6 +736,67 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/conformance": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.11.tgz", + "integrity": "sha512-cqayAmyTUhnRsyrOuTqZ+kCc2w/goppxnqZ+XrOsVd/M25No/HiZ1GbZI92sFA7ONYzonqRja56G9IiISIns3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "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" + }, + "bin": { + "conformance": "dist/index.js" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "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.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1333,7 +1395,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -1765,7 +1826,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2007,6 +2067,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -2308,7 +2378,6 @@ "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", @@ -2604,9 +2673,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2627,7 +2696,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", @@ -3049,7 +3117,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4107,7 +4174,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4188,7 +4254,6 @@ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -4234,7 +4299,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4267,6 +4331,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "dev": true, + "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", @@ -4429,7 +4503,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4443,7 +4516,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4579,6 +4651,22 @@ } } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "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", @@ -4596,7 +4684,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 dc02209b1..e1ed0e1ed 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,12 @@ "test:watch": "vitest", "start": "npm run server", "server": "tsx watch --clear-screen=false scripts/cli.ts server", - "client": "tsx scripts/cli.ts client" + "client": "tsx scripts/cli.ts client", + "test:conformance:server": "test/conformance/scripts/run-server-conformance.sh --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:server:all": "test/conformance/scripts/run-server-conformance.sh --suite all --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:server:run": "npx tsx test/conformance/src/everythingServer.ts", + "test:conformance:client": "npx @modelcontextprotocol/conformance client --command 'npx tsx test/conformance/src/everythingClient.ts' --expected-failures test/conformance/conformance-baseline.yml", + "test:conformance:client:all": "npx @modelcontextprotocol/conformance client --command 'npx tsx test/conformance/src/everythingClient.ts' --suite all --expected-failures test/conformance/conformance-baseline.yml" }, "dependencies": { "@hono/node-server": "^1.19.9", @@ -118,6 +123,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", + "@modelcontextprotocol/conformance": "^0.1.11", "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", diff --git a/test/conformance/conformance-baseline.yml b/test/conformance/conformance-baseline.yml new file mode 100644 index 000000000..23d7e75a8 --- /dev/null +++ b/test/conformance/conformance-baseline.yml @@ -0,0 +1,14 @@ +# Known conformance test failures for v1.x +# These are tracked and should be removed as they're fixed. +# +# tools_call: conformance runner's test server reuses a single Server +# instance across requests, triggering v1.26.0's "Already connected" +# guard (GHSA-345p-7cg4-v4c7). Fixed in conformance repo (PR #141), +# remove this entry once a new conformance release is published. +# +# auth/pre-registration: scenario added in conformance 0.1.11 that +# requires a dedicated client handler for pre-registered credentials. +# Needs to be implemented in both v1.x and main. +client: + - tools_call + - auth/pre-registration diff --git a/test/conformance/scripts/run-server-conformance.sh b/test/conformance/scripts/run-server-conformance.sh new file mode 100755 index 000000000..5105d64f7 --- /dev/null +++ b/test/conformance/scripts/run-server-conformance.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Script to run server conformance tests +# Starts the conformance server, runs conformance tests, then stops the server + +set -e + +PORT="${PORT:-3000}" +SERVER_URL="http://localhost:${PORT}/mcp" + +# Navigate to repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start the server in the background +echo "Starting conformance test server on port ${PORT}..." +npx tsx test/conformance/src/everythingServer.ts & +SERVER_PID=$! + +# Function to cleanup on exit +cleanup() { + echo "Stopping server (PID: ${SERVER_PID})..." + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for server to be ready +echo "Waiting for server to be ready..." +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "${SERVER_URL}" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} attempts" + exit 1 + fi + sleep 0.5 +done + +echo "Server is ready. Running conformance tests..." + +# Run conformance tests - pass through all arguments +npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" "$@" + +echo "Conformance tests completed." diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts new file mode 100644 index 000000000..bd4c079de --- /dev/null +++ b/test/conformance/src/everythingClient.ts @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +/** + * Everything client (v1.x) - 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. + */ + +import { Client } from '../../../src/client/index.js'; +import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../../src/client/auth-extensions.js'; +import { ElicitRequestSchema } from '../../../src/types.js'; +import { z } from 'zod'; + +import { logger } from './helpers/logger.js'; +import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; + +/** + * Fixed client metadata URL for CIMD conformance tests. + */ +const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json'; + +/** + * Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT. + */ +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() + }) +]); + +/** + * 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)); +} + +// 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'); +} + +// tools_call scenario needs to actually call a tool +async function runToolsCallClient(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'); + + const tools = await client.listTools(); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + const addTool = tools.tools.find(t => t.name === 'add_numbers'); + if (addTool) { + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } + }); + logger.debug('Tool call result:', JSON.stringify(result, null, 2)); + } + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('initialize', runBasicClient); +registerScenario('tools_call', runToolsCallClient); + +// ============================================================================ +// 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), handle401, CIMD_CLIENT_METADATA_URL)(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-cimd', + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/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', + 'auth/scope-retry-limit', + 'auth/token-endpoint-auth-basic', + 'auth/token-endpoint-auth-post', + 'auth/token-endpoint-auth-none' + ], + runAuthClient +); + +// ============================================================================ +// Client Credentials scenarios +// ============================================================================ + +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); + +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); + +// ============================================================================ +// Elicitation defaults scenario +// ============================================================================ + +async function runElicitationDefaultsClient(serverUrl: string): Promise { + const client = new Client( + { name: 'elicitation-defaults-test-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { + form: { + 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 { + action: 'accept' as const, + content: {} + }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + 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-sep1034-client-defaults', runElicitationDefaultsClient); + +// ============================================================================ +// SSE retry scenario +// ============================================================================ + +async function runSSERetryClient(serverUrl: string): Promise { + const client = new Client({ name: 'sse-retry-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 tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + const testTool = tools.tools.find(t => t.name === 'test_reconnection'); + if (!testTool) { + throw new Error('Test tool not found: test_reconnection'); + } + + logger.debug('Calling test_reconnection tool...'); + const result = await client.callTool({ + name: 'test_reconnection', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('sse-retry', runSSERetryClient); + +// ============================================================================ +// Main entry point +// ============================================================================ + +async function main(): Promise { + const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO; + const serverUrl = process.argv[2]; + + if (!scenarioName || !serverUrl) { + logger.error('Usage: MCP_CONFORMANCE_SCENARIO= everything-client '); + logger.error('\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.'); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + const handler = scenarioHandlers[scenarioName]; + if (!handler) { + logger.error(`Unknown scenario: ${scenarioName}`); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).sort()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + try { + await handler(serverUrl); + process.exit(0); + } catch (error) { + logger.error('Error:', error); + process.exit(1); + } +} + +try { + await main(); +} catch (error) { + logger.error('Error:', error); + process.exit(1); +} diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts new file mode 100644 index 000000000..05f4cb174 --- /dev/null +++ b/test/conformance/src/everythingServer.ts @@ -0,0 +1,953 @@ +#!/usr/bin/env node + +/** + * MCP Conformance Test Server (v1.x) + * + * Server implementing all MCP features for conformance testing. + * Adapted from the main branch version for the v1.x single-package SDK. + */ + +import { randomUUID } from 'node:crypto'; + +import { StreamableHTTPServerTransport } from '../../../src/server/streamableHttp.js'; +import type { EventId, EventStore, StreamId } from '../../../src/server/streamableHttp.js'; +import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js'; +import type { CallToolResult, GetPromptResult, ReadResourceResult } from '../../../src/types.js'; +import { + CompleteRequestSchema, + CreateMessageResultSchema, + ElicitResultSchema, + isInitializeRequest, + SetLevelRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema +} from '../../../src/types.js'; +import { localhostHostValidation } from '../../../src/server/middleware/hostHeaderValidation.js'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; +import { z } from 'zod'; + +// Server state +const resourceSubscriptions = new Set(); +const watchedResourceContent = 'Watched resource content'; + +// Session management +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// In-memory event store for SEP-1699 resumability +const eventStoreData = new Map(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: unknown): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + eventStoreData.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: unknown) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0] || lastEventId; + const eventsToReplay: Array<[string, { message: unknown }]> = []; + 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 (message && typeof message === 'object' && 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=='; + +// Sample base64 encoded minimal WAV file for testing +const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + +// Function to create a new MCP server instance (one per session) +function createMcpServer() { + const mcpServer = new McpServer( + { + name: 'mcp-conformance-test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { + listChanged: true + }, + resources: { + subscribe: true, + listChanged: true + }, + prompts: { + listChanged: true + }, + logging: {}, + completions: {} + } + } + ); + + // Helper to send log messages using the underlying server + function sendLog( + level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', + message: string, + _data?: unknown + ) { + mcpServer.server + .notification({ + method: 'notifications/message', + params: { + level, + logger: 'conformance-test-server', + data: _data || message + } + }) + .catch(() => { + // Ignore error if no client is connected + }); + } + + // ===== TOOLS ===== + + // Simple text tool + mcpServer.tool('test_simple_text', 'Tests simple text content response', async (): Promise => { + return { + content: [{ type: 'text', text: 'This is a simple text response for testing.' }] + }; + }); + + // Image content tool + mcpServer.tool('test_image_content', 'Tests image content response', async (): Promise => { + return { + content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] + }; + }); + + // Audio content tool + mcpServer.tool('test_audio_content', 'Tests audio content response', async (): Promise => { + return { + content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] + }; + }); + + // Embedded resource tool + mcpServer.tool('test_embedded_resource', 'Tests embedded resource content response', async (): Promise => { + return { + content: [ + { + type: 'resource', + resource: { + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.' + } + } + ] + }; + }); + + // Multiple content types tool + mcpServer.tool( + 'test_multiple_content_types', + 'Tests response with multiple content types (text, image, resource)', + async (): Promise => { + return { + content: [ + { type: 'text', text: 'Multiple content types test:' }, + { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }, + { + type: 'resource', + resource: { + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: JSON.stringify({ test: 'data', value: 123 }) + } + } + ] + }; + } + ); + + // Tool with logging + mcpServer.tool( + 'test_tool_with_logging', + 'Tests tool that emits log messages during execution', + {}, + async (_args, extra): Promise => { + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution started' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool processing data' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution completed' + } + }); + return { + content: [{ type: 'text', text: 'Tool with logging executed successfully' }] + }; + } + ); + + // Tool with progress + mcpServer.tool( + 'test_tool_with_progress', + 'Tests tool that reports progress notifications', + {}, + async (_args, extra): Promise => { + const progressToken = extra._meta?.progressToken ?? 0; + console.log('Progress token:', progressToken); + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 0, + total: 100, + message: `Completed step ${0} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100, + message: `Completed step ${50} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: 100, + total: 100, + message: `Completed step ${100} of ${100}` + } + }); + + return { + content: [{ type: 'text', text: String(progressToken) }] + }; + } + ); + + // Error handling tool + mcpServer.tool('test_error_handling', 'Tests error response handling', async (): Promise => { + throw new Error('This tool intentionally returns an error for testing'); + }); + + // SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection + mcpServer.tool( + 'test_reconnection', + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + {}, + async (_args, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${extra.sessionId}] Starting test_reconnection tool...`); + + // Get the transport for this session + const transport = extra.sessionId ? transports[extra.sessionId] : undefined; + if (transport && extra.requestId) { + // Close the SSE stream to trigger client reconnection + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + transport.closeSSEStream(extra.requestId); + } + + // Wait for client to reconnect (should respect retry field) + await sleep(100); + + console.log(`[${extra.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.tool( + 'test_sampling', + 'Tests server-initiated sampling (LLM completion request)', + { + prompt: z.string() + }, + async (args: { prompt: string }, extra): Promise => { + try { + // Request sampling from client + const result = (await extra.sendRequest( + { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: args.prompt + } + } + ], + maxTokens: 100 + } + }, + CreateMessageResultSchema + )) as { content?: { text?: string }; message?: { content?: { text?: string } } }; + + const modelResponse = result.content?.text || result.message?.content?.text || 'No response'; + + return { + content: [ + { + type: 'text', + text: `LLM response: ${modelResponse}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // Elicitation tool - requests user input from client + mcpServer.tool( + 'test_elicitation', + 'Tests server-initiated elicitation (user input request)', + { + message: z.string().describe('The message to show the user') + }, + async (args: { message: string }, extra): Promise => { + try { + // Request user input from client + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + message: args.message, + requestedSchema: { + type: 'object', + properties: { + response: { + type: 'string', + description: "User's response" + } + }, + required: ['response'] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1034: Elicitation with default values for all primitive types + mcpServer.tool( + 'test_elicitation_sep1034_defaults', + 'Tests elicitation with default values per SEP-1034', + {}, + async (_args, extra): Promise => { + try { + const result = await extra.sendRequest( + { + 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: [] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1330: Elicitation with enum schema improvements + mcpServer.tool( + 'test_elicitation_sep1330_enums', + 'Tests elicitation with enum schema improvements per SEP-1330', + {}, + async (_args, extra): Promise => { + try { + const result = await extra.sendRequest( + { + 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: [] + } + } + }, + ElicitResultSchema + ); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1613: JSON Schema 2020-12 conformance test tool + const addressSchema = z.object({ + street: z.string().optional(), + city: z.string().optional() + }); + mcpServer.tool( + 'json_schema_2020_12_tool', + 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + { + name: z.string().optional(), + address: addressSchema.optional() + }, + async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + return { + content: [ + { + type: 'text', + text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` + } + ] + }; + } + ); + + // ===== RESOURCES ===== + + // Static text resource + mcpServer.registerResource( + 'static-text', + 'test://static-text', + { + title: 'Static Text Resource', + description: 'A static text resource for testing', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'This is the content of the static text resource.' + } + ] + }; + } + ); + + // Static binary resource + mcpServer.registerResource( + 'static-binary', + 'test://static-binary', + { + title: 'Static Binary Resource', + description: 'A static binary resource (image) for testing', + mimeType: 'image/png' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-binary', + mimeType: 'image/png', + blob: TEST_IMAGE_BASE64 + } + ] + }; + } + ); + + // Resource template + mcpServer.registerResource( + 'template', + new ResourceTemplate('test://template/{id}/data', { list: undefined }), + { + title: 'Resource Template', + description: 'A resource template with parameter substitution', + mimeType: 'application/json' + }, + async (uri, variables): Promise => { + const id = variables.id; + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'application/json', + text: JSON.stringify({ + id, + templateTest: true, + data: `Data for ID: ${id}` + }) + } + ] + }; + } + ); + + // Watched resource + mcpServer.registerResource( + 'watched-resource', + 'test://watched-resource', + { + title: 'Watched Resource', + description: 'A resource that auto-updates every 3 seconds', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://watched-resource', + mimeType: 'text/plain', + text: watchedResourceContent + } + ] + }; + } + ); + + // Subscribe/Unsubscribe handlers + mcpServer.server.setRequestHandler(SubscribeRequestSchema, async request => { + const uri = request.params.uri; + resourceSubscriptions.add(uri); + sendLog('info', `Subscribed to resource: ${uri}`); + return {}; + }); + + mcpServer.server.setRequestHandler(UnsubscribeRequestSchema, async request => { + const uri = request.params.uri; + resourceSubscriptions.delete(uri); + sendLog('info', `Unsubscribed from resource: ${uri}`); + return {}; + }); + + // ===== PROMPTS ===== + + // Simple prompt + mcpServer.prompt('test_simple_prompt', 'A simple prompt without arguments', async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt for testing.' + } + } + ] + }; + }); + + // Prompt with arguments + mcpServer.prompt( + 'test_prompt_with_arguments', + 'A prompt with required arguments', + { + arg1: z.string().describe('First test argument'), + arg2: z.string().describe('Second test argument') + }, + async (args: { arg1: string; arg2: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` + } + } + ] + }; + } + ); + + // Prompt with embedded resource + mcpServer.prompt( + 'test_prompt_with_embedded_resource', + 'A prompt that includes an embedded resource', + { + resourceUri: z.string().describe('URI of the resource to embed') + }, + async (args: { resourceUri: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: args.resourceUri, + mimeType: 'text/plain', + text: 'Embedded resource content for testing.' + } + } + }, + { + role: 'user', + content: { + type: 'text', + text: 'Please process the embedded resource above.' + } + } + ] + }; + } + ); + + // Prompt with image + mcpServer.prompt('test_prompt_with_image', 'A prompt that includes image content', async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'image', + data: TEST_IMAGE_BASE64, + mimeType: 'image/png' + } + }, + { + role: 'user', + content: { type: 'text', text: 'Please analyze the image above.' } + } + ] + }; + }); + + // ===== LOGGING ===== + + mcpServer.server.setRequestHandler(SetLevelRequestSchema, async request => { + const level = request.params.level; + sendLog('info', `Log level set to: ${level}`); + return {}; + }); + + // ===== COMPLETION ===== + + mcpServer.server.setRequestHandler(CompleteRequestSchema, async () => { + return { + completion: { + values: [], + total: 0, + hasMore: false + } + }; + }); + + return mcpServer; +} + +// ===== EXPRESS APP ===== + +const app = express(); +app.use(express.json()); +app.use(localhostHostValidation()); + +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id'] + }) +); + +// Handle POST requests - stateful mode +app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const mcpServer = createMcpServer(); + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000, + onsessioninitialized: (newSessionId: string) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Invalid or missing session ID' + }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests - SSE streams for sessions +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing SSE stream for session ${sessionId}`); + } + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Handle DELETE requests - session termination +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + +// Start server +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); +}); diff --git a/test/conformance/src/helpers/conformanceOAuthProvider.ts b/test/conformance/src/helpers/conformanceOAuthProvider.ts new file mode 100644 index 000000000..7623fcc55 --- /dev/null +++ b/test/conformance/src/helpers/conformanceOAuthProvider.ts @@ -0,0 +1,87 @@ +import type { OAuthClientProvider } from '../../../../src/client/auth.js'; +import type { OAuthClientInformation, OAuthClientInformationFull, OAuthClientMetadata, OAuthTokens } from '../../../../src/shared/auth.js'; + +export class ConformanceOAuthProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _authCode?: string; + private _authCodePromise?: Promise; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadataUrl?: string | URL + ) {} + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + get clientMetadataUrl(): string | undefined { + return this._clientMetadataUrl?.toString(); + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + try { + const response = await fetch(authorizationUrl.toString(), { + redirect: 'manual' + }); + + const location = response.headers.get('location'); + if (location) { + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get('code'); + if (code) { + this._authCode = code; + return; + } else { + throw new Error('No auth code in redirect URL'); + } + } else { + throw new Error(`No redirect location received, from '${authorizationUrl.toString()}'`); + } + } catch (error) { + console.error('Failed to fetch authorization URL:', error); + throw error; + } + } + + async getAuthCode(): Promise { + if (this._authCode) { + return this._authCode; + } + throw new Error('No authorization code'); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/test/conformance/src/helpers/logger.ts b/test/conformance/src/helpers/logger.ts new file mode 100644 index 000000000..8de9342bd --- /dev/null +++ b/test/conformance/src/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/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts new file mode 100644 index 000000000..e24c8316f --- /dev/null +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -0,0 +1,81 @@ +import type { FetchLike } from '../../../../src/shared/transport.js'; +import type { Middleware } from '../../../../src/client/middleware.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '../../../../src/client/auth.js'; + +import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js'; + +export const handle401 = 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 + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(`Authentication failed with result: ${result}`); + } + } +}; + +export const withOAuthRetry = ( + clientName: string, + baseUrl?: string | URL, + 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 (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(); + + if (response.status === 401 || response.status === 403) { + const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); + await handle401Fn(response, provider, next, serverUrl); + + response = await makeRequest(); + } + + if (response.status === 401 || response.status === 403) { + const url = typeof input === 'string' ? input : input.toString(); + throw new UnauthorizedError(`Authentication failed for ${url}`); + } + + return response; + }; + }; +}; diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json new file mode 100644 index 000000000..5d51831c5 --- /dev/null +++ b/test/conformance/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src"], + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + } +} From 825e9ab80332e7fac19d221f6aae352dc73172fe Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:20:54 +0000 Subject: [PATCH 02/11] feat: backport discoverOAuthServerInfo() and discovery caching to v1.x (#1533) --- src/client/auth.ts | 197 +++++++++++++++++++-- test/client/auth.test.ts | 364 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 542 insertions(+), 19 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 4c82b5114..b59862052 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -150,7 +150,7 @@ export interface OAuthClientProvider { * credentials, in the case where the server has indicated that they are no longer valid. * This avoids requiring the user to intervene manually. */ - invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; /** * Prepares grant-specific parameters for a token request. @@ -189,6 +189,46 @@ export interface OAuthClientProvider { * } */ prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the OAuth discovery state after RFC 9728 and authorization server metadata + * discovery. Providers can persist this state to avoid redundant discovery requests + * on subsequent {@linkcode auth} calls. + * + * This state can also be provided out-of-band (e.g., from a previous session or + * external configuration) to bootstrap the OAuth flow without discovery. + * + * Called by {@linkcode auth} after successful discovery. + */ + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + + /** + * Returns previously saved discovery state, or `undefined` if none is cached. + * + * When available, {@linkcode auth} restores the discovery state (authorization server + * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing + * latency on subsequent calls. + * + * Providers should clear cached discovery state on repeated authentication failures + * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow + * re-discovery in case the authorization server has changed. + */ + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. + * + * Contains the results of RFC 9728 protected resource metadata discovery and + * authorization server metadata discovery. Persisting this state avoids + * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + */ +// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL +// at which authorization server metadata was discovered. This would require +// `discoverAuthorizationServerMetadata()` to return the successful discovery URL. +export interface OAuthDiscoveryState extends OAuthServerInfo { + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -397,32 +437,70 @@ async function authInternal( fetchFn?: FetchLike; } ): Promise { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; + + // If resourceMetadataUrl is not provided, try to load it from cached state + // This handles browser redirects where the URL was saved before navigation + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = + cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); + } catch { + // RFC 9728 not available — selectResourceURL will handle undefined + } } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } - /** - * If we don't get a valid authorization server metadata from protected resource metadata, - * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. - */ - if (!authorizationServerUrl) { - authorizationServerUrl = new URL('/', serverUrl); + // Re-save if we enriched the cached state with missing metadata + if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } + } else { + // Full discovery via RFC 9728 + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // Persist discovery state for future use + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); } const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn - }); - // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { @@ -937,6 +1015,87 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string; + + /** + * The authorization server metadata (endpoints, capabilities), + * or `undefined` if metadata discovery failed. + */ + authorizationServerMetadata?: AuthorizationServerMetadata; + + /** + * The OAuth 2.0 Protected Resource Metadata from RFC 9728, + * or `undefined` if the server does not support it. + */ + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +/** + * Discovers the authorization server for an MCP server following + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected + * Resource Metadata), with fallback to treating the server URL as the + * authorization server. + * + * This function combines two discovery steps into one call: + * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the + * authorization server URL (RFC 9728). + * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery). + * + * Use this when you need the authorization server metadata for operations outside the + * {@linkcode auth} orchestrator, such as token refresh or token revocation. + * + * @param serverUrl - The MCP resource server URL + * @param opts - Optional configuration + * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint + * @param opts.fetchFn - Custom fetch function for HTTP requests + * @returns Authorization server URL, metadata, and resource metadata (if available) + */ +export async function discoverOAuthServerInfo( + serverUrl: string | URL, + opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: opts?.resourceMetadataUrl }, + opts?.fetchFn + ); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // RFC 9728 not supported -- fall back to treating the server URL as the authorization server + } + + // If we don't get a valid authorization server from protected resource metadata, + // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server + if (!authorizationServerUrl) { + authorizationServerUrl = String(new URL('/', serverUrl)); + } + + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + + return { + authorizationServerUrl, + authorizationServerMetadata, + resourceMetadata + }; +} + /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. */ diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index d6e7e8684..8df325eed 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -8,6 +8,7 @@ import { refreshAuthorization, registerClient, discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, extractWWWAuthenticateParams, auth, type OAuthClientProvider, @@ -916,6 +917,369 @@ describe('OAuth Authorization', () => { }); }); + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); + }); + + it('restores full discovery state from cache including resource metadata', async () => { + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); + + // Verify the token request includes the resource parameter from cached metadata + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); + }); + + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should have used the cached PRM URL, not the default well-known path + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); + }); + }); + describe('selectClientAuthMethod', () => { it('selects the correct client authentication method from client information', () => { const clientInfo = { From 97ab379e4572ac8e38ff8b99891f29a69cfbb5bb Mon Sep 17 00:00:00 2001 From: Valentin Beggi <87306219+valentinbeggi@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:57:53 +0100 Subject: [PATCH 03/11] feat: add url property to RequestInfo interface (#1353) Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> --- .changeset/add-url-to-request-info.md | 5 +++++ src/server/sse.ts | 11 ++++++++++- src/server/webStandardStreamableHttp.ts | 5 +++-- src/types.ts | 4 ++++ test/server/sse.test.ts | 16 ++++++++++++---- test/server/streamableHttp.test.ts | 3 ++- 6 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 .changeset/add-url-to-request-info.md diff --git a/.changeset/add-url-to-request-info.md b/.changeset/add-url-to-request-info.md new file mode 100644 index 000000000..dd3b1d252 --- /dev/null +++ b/.changeset/add-url-to-request-info.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Add `url` property to `RequestInfo` interface as a `URL` type, exposing the full request URL to server handlers. The URL is unified across all HTTP transports (SSE and Streamable HTTP) to always provide the complete URL including protocol, host, and path. diff --git a/src/server/sse.ts b/src/server/sse.ts index b7450a09e..4931beae6 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { IncomingMessage, ServerResponse } from 'node:http'; +import { TLSSocket } from 'node:tls'; import { Transport } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; import getRawBody from 'raw-body'; @@ -149,7 +150,15 @@ export class SSEServerTransport implements Transport { } const authInfo: AuthInfo | undefined = req.auth; - const requestInfo: RequestInfo = { headers: req.headers }; + + const host = req.headers.host; + const protocol = req.socket instanceof TLSSocket ? 'https' : 'http'; + const fullUrl = host && req.url ? new URL(req.url, `${protocol}://${host}`) : undefined; + + const requestInfo: RequestInfo = { + headers: req.headers, + url: fullUrl + }; let body: string | unknown; try { diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index c811c54c4..4943565a1 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -605,9 +605,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(415, -32000, 'Unsupported Media Type: Content-Type must be application/json'); } - // Build request info from headers + // Build request info from headers and URL const requestInfo: RequestInfo = { - headers: Object.fromEntries(req.headers.entries()) + headers: Object.fromEntries(req.headers.entries()), + url: new URL(req.url) }; let rawMessage; diff --git a/src/types.ts b/src/types.ts index 6bec5190c..bdd2dfed0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2361,6 +2361,10 @@ export interface RequestInfo { * The headers of the request. */ headers: IsomorphicHeaders; + /** + * The full URL of the request. + */ + url?: URL; } /** diff --git a/test/server/sse.test.ts b/test/server/sse.test.ts index 4686f2ba9..0e996d1d6 100644 --- a/test/server/sse.test.ts +++ b/test/server/sse.test.ts @@ -19,10 +19,15 @@ const createMockResponse = () => { return res as unknown as Mocked; }; -const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { +const createMockRequest = ({ + headers = {}, + body, + url = '/messages' +}: { headers?: Record; body?: string; url?: string } = {}) => { const mockReq = { headers, body: body ? body : undefined, + url, auth: { token: 'test-token' }, @@ -312,7 +317,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'user-agent': 'node', 'accept-encoding': 'gzip, deflate', 'content-length': '124' - } + }, + url: `http://127.0.0.1:${serverPort}/?sessionId=${sessionId}` }) } ] @@ -387,7 +393,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { id: 1 }); const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, + headers: { host: 'localhost', 'content-type': 'application/json' }, body: validMessage }); const mockRes = createMockResponse(); @@ -416,8 +422,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }, requestInfo: { headers: { + host: 'localhost', 'content-type': 'application/json' - } + }, + url: new URL('http://localhost/messages') } } ); diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 1c4d5ed84..3968c21a5 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -443,7 +443,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 'user-agent': expect.any(String), 'accept-encoding': expect.any(String), 'content-length': expect.any(String) - } + }, + url: baseUrl.toString() }); }); From 5c16ae3339bfa1dd71b0dee1a534e1b6d8be658e Mon Sep 17 00:00:00 2001 From: Luca Chang <131398524+LucaButBoring@users.noreply.github.com> Date: Sun, 15 Feb 2026 05:02:27 -0800 Subject: [PATCH 04/11] [v1.x] feat(tasks): add streaming methods for elicitation and sampling (#1528) Co-authored-by: Konstantin Konstantinov --- src/examples/client/simpleStreamableHttp.ts | 89 ++- src/examples/server/simpleStreamableHttp.ts | 109 ++++ src/experimental/tasks/server.ts | 198 +++++- .../tasks/server-streaming.test.ts | 570 ++++++++++++++++++ 4 files changed, 944 insertions(+), 22 deletions(-) create mode 100644 test/experimental/tasks/server-streaming.test.ts diff --git a/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 21ab4f556..19eb5577a 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -15,6 +15,7 @@ import { LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, ElicitRequestSchema, + ElicitResult, ResourceLink, ReadResourceRequest, ReadResourceResultSchema, @@ -22,6 +23,7 @@ import { ErrorCode, McpError } from '../../types.js'; +import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { Ajv } from 'ajv'; @@ -65,6 +67,7 @@ function printHelp(): void { console.log(' greet [name] - Call the greet tool'); console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)'); + console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation'); console.log(' start-notifications [interval] [count] - Start periodic notifications'); console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); console.log(' list-prompts - List available prompts'); @@ -131,6 +134,11 @@ function commandLoop(): void { await callCollectInfoTool(args[1] || 'contact'); break; + case 'collect-info-task': { + await callCollectInfoWithTask(args[1] || 'contact'); + break; + } + case 'start-notifications': { const interval = args[1] ? parseInt(args[1], 10) : 2000; const count = args[2] ? parseInt(args[2], 10) : 10; @@ -232,7 +240,10 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client with form elicitation capability + // Create task store for client-side task support + const clientTaskStore = new InMemoryTaskStore(); + + // Create a new client with form elicitation capability and task support client = new Client( { name: 'example-client', @@ -242,25 +253,46 @@ async function connect(url?: string): Promise { capabilities: { elicitation: { form: {} + }, + tasks: { + requests: { + elicitation: { + create: {} + } + } } - } + }, + taskStore: clientTaskStore } ); client.onerror = error => { console.error('\x1b[31mClient error:', error, '\x1b[0m'); }; - // Set up elicitation request handler with proper validation - client.setRequestHandler(ElicitRequestSchema, async request => { + // Set up elicitation request handler with proper validation and task support + client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { if (request.params.mode !== 'form') { throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); } console.log('\n🔔 Elicitation (form) Request Received:'); console.log(`Message: ${request.params.message}`); console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`); + console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`); console.log('Requested Schema:'); console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + // Helper to return result, optionally creating a task if requested + const returnResult = async (result: ElicitResult) => { + if (request.params.task && extra.taskStore) { + // Create a task and store the result + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + console.log(`📋 Created client-side task: ${task.taskId}`); + return { task }; + } + return result; + }; + const schema = request.params.requestedSchema; const properties = schema.properties; const required = schema.required || []; @@ -381,7 +413,7 @@ async function connect(url?: string): Promise { } if (inputCancelled) { - return { action: 'cancel' }; + return returnResult({ action: 'cancel' }); } // If we didn't complete all fields due to an error, try again @@ -394,7 +426,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); } } @@ -412,7 +444,7 @@ async function connect(url?: string): Promise { continue; } else { console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); } } @@ -426,25 +458,34 @@ async function connect(url?: string): Promise { }); }); - if (confirmAnswer === 'yes' || confirmAnswer === 'y') { - return { - action: 'accept', - content - }; - } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { - return { action: 'cancel' }; - } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { - if (attempts < maxAttempts) { - console.log('Please re-enter the information...'); - continue; - } else { - return { action: 'decline' }; + switch (confirmAnswer) { + case 'yes': + case 'y': { + return returnResult({ + action: 'accept', + content: content as ElicitResult['content'] + }); + } + case 'cancel': + case 'c': { + return returnResult({ action: 'cancel' }); + } + case 'no': + case 'n': { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return returnResult({ action: 'decline' }); + } + + break; } } } console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; + return returnResult({ action: 'decline' }); }); transport = new StreamableHTTPClientTransport(new URL(serverUrl), { @@ -641,6 +682,12 @@ async function callCollectInfoTool(infoType: string): Promise { await callTool('collect-user-info', { infoType }); } +async function callCollectInfoWithTask(infoType: string): Promise { + console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`); + console.log('This will create a task on the server, which will elicit input and create a task on the client.\n'); + await callToolTask('collect-user-info-task', { infoType }); +} + async function startNotifications(interval: number, count: number): Promise { console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); await callTool('start-notification-stream', { interval, count }); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index e3b754fa6..e25c67986 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -8,6 +8,7 @@ import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; import { createMcpExpressApp } from '../../server/express.js'; import { CallToolResult, + ElicitResult, ElicitResultSchema, GetPromptResult, isInitializeRequest, @@ -280,6 +281,114 @@ const getServer = () => { } ); + // Register a tool that demonstrates bidirectional task support: + // Server creates a task, then elicits input from client using elicitInputStream + // Using the experimental tasks API - WARNING: may change without notice + server.experimental.tasks.registerToolTask( + 'collect-user-info-task', + { + title: 'Collect Info with Task', + description: 'Collects user info via elicitation with task support using elicitInputStream', + inputSchema: { + infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact') + } + }, + { + async createTask({ infoType }, { taskStore: createTaskStore, taskRequestedTtl }) { + // Create the server-side task + const task = await createTaskStore.createTask({ + ttl: taskRequestedTtl + }); + + // Perform async work that makes a nested elicitation request using elicitInputStream + (async () => { + try { + const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences'; + + // Define schemas with proper typing for PrimitiveSchemaDefinition + const contactSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name', description: 'Your full name' }, + email: { type: 'string', title: 'Email', description: 'Your email address' } + }, + required: ['name', 'email'] + }; + + const preferencesSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + }; + + const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema; + + // Use elicitInputStream to elicit input from client + // This demonstrates the streaming elicitation API + // Access via server.server to get the underlying Server instance + const stream = server.server.experimental.tasks.elicitInputStream({ + mode: 'form', + message, + requestedSchema + }); + + let elicitResult: ElicitResult | undefined; + for await (const msg of stream) { + if (msg.type === 'result') { + elicitResult = msg.result as ElicitResult; + } else if (msg.type === 'error') { + throw msg.error; + } + } + + if (!elicitResult) { + throw new Error('No result received from elicitation'); + } + + let resultText: string; + if (elicitResult.action === 'accept') { + resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`; + } else if (elicitResult.action === 'decline') { + resultText = `User declined to provide ${infoType} information`; + } else { + resultText = 'User cancelled the request'; + } + + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: resultText }] + }); + } catch (error) { + console.error('Error in collect-user-info-task:', error); + await taskStore.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } + })(); + + return { task }; + }, + async getTask(_args, { taskId, taskStore: getTaskStore }) { + return await getTaskStore.getTask(taskId); + }, + async getTaskResult(_args, { taskId, taskStore: getResultTaskStore }) { + const result = await getResultTaskStore.getTaskResult(taskId); + return result as CallToolResult; + } + } + ); + // Register a simple prompt with title server.registerPrompt( 'greeting-template', diff --git a/src/experimental/tasks/server.ts b/src/experimental/tasks/server.ts index a4150a8d7..e77ad2582 100644 --- a/src/experimental/tasks/server.ts +++ b/src/experimental/tasks/server.ts @@ -9,7 +9,21 @@ import type { Server } from '../../server/index.js'; import type { RequestOptions } from '../../shared/protocol.js'; import type { ResponseMessage } from '../../shared/responseMessage.js'; import type { AnySchema, SchemaOutput } from '../../server/zod-compat.js'; -import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '../../types.js'; +import type { + ServerRequest, + Notification, + Request, + Result, + GetTaskResult, + ListTasksResult, + CancelTaskResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult +} from '../../types.js'; +import { CreateMessageResultSchema, ElicitResultSchema } from '../../types.js'; /** * Experimental task features for low-level MCP servers. @@ -60,6 +74,188 @@ export class ExperimentalServerTasks< return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); } + /** + * Sends a sampling request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests, yields 'taskCreated' and 'taskStatus' messages + * before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.createMessageStream({ + * messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + * maxTokens: 100 + * }, { + * onprogress: (progress) => { + * // Handle streaming tokens via progress notifications + * console.log('Progress:', progress.message); + * } + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('Final result:', message.result); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The sampling request parameters + * @param options - Optional request options (timeout, signal, task creation params, onprogress, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + createMessageStream( + params: CreateMessageRequestParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + + // Capability check - only required when tools/toolChoice are provided + if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { + throw new Error('Client does not support sampling tools capability.'); + } + + // Message structure validation - always validate tool_use/tool_result pairs. + // These may appear even without tools/toolChoice in the current request when + // a previous sampling request returned tool_use and this is a follow-up with results. + if (params.messages.length > 0) { + const lastMessage = params.messages[params.messages.length - 1]; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some(c => c.type === 'tool_result'); + + const previousMessage = params.messages.length > 1 ? params.messages[params.messages.length - 2] : undefined; + const previousContent = previousMessage + ? Array.isArray(previousMessage.content) + ? previousMessage.content + : [previousMessage.content] + : []; + const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); + + if (hasToolResults) { + if (lastContent.some(c => c.type !== 'tool_result')) { + throw new Error('The last message must contain only tool_result content if any is present'); + } + if (!hasPreviousToolUse) { + throw new Error('tool_result blocks are not matching any tool_use from the previous message'); + } + } + if (hasPreviousToolUse) { + // Extract tool_use IDs from previous message and tool_result IDs from current message + const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => (c as { id: string }).id)); + const toolResultIds = new Set( + lastContent.filter(c => c.type === 'tool_result').map(c => (c as { toolUseId: string }).toolUseId) + ); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { + throw new Error('ids of tool_result blocks and tool_use blocks from previous message do not match'); + } + } + } + + return this.requestStream( + { + method: 'sampling/createMessage', + params + }, + CreateMessageResultSchema, + options + ); + } + + /** + * Sends an elicitation request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests (especially URL-based elicitation), yields 'taskCreated' + * and 'taskStatus' messages before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.elicitInputStream({ + * mode: 'url', + * message: 'Please authenticate', + * elicitationId: 'auth-123', + * url: 'https://example.com/auth' + * }, { + * task: { ttl: 300000 } // Task-augmented for long-running auth flow + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('User action:', message.result.action); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The elicitation request parameters + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + elicitInputStream( + params: ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + const mode = params.mode ?? 'form'; + + // Capability check based on mode + switch (mode) { + case 'url': { + if (!clientCapabilities?.elicitation?.url) { + throw new Error('Client does not support url elicitation.'); + } + break; + } + case 'form': { + if (!clientCapabilities?.elicitation?.form) { + throw new Error('Client does not support form elicitation.'); + } + break; + } + } + + // Normalize params to ensure mode is set for form mode (defaults to 'form' per spec) + const normalizedParams = mode === 'form' && params.mode === undefined ? { ...params, mode: 'form' as const } : params; + + // Cast to ServerRequest needed because TypeScript can't narrow the union type + // based on the discriminated 'method' field when constructing the object literal + return this.requestStream( + { + method: 'elicitation/create', + params: normalizedParams + } as ServerRequest, + ElicitResultSchema, + options + ); + } + /** * Gets the current status of a task. * diff --git a/test/experimental/tasks/server-streaming.test.ts b/test/experimental/tasks/server-streaming.test.ts new file mode 100644 index 000000000..938005b64 --- /dev/null +++ b/test/experimental/tasks/server-streaming.test.ts @@ -0,0 +1,570 @@ +/** + * Tests for experimental server streaming methods: createMessageStream and elicitInputStream. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Client } from '../../../src/client/index.js'; +import { Server } from '../../../src/server/index.js'; +import { InMemoryTransport } from '../../../src/inMemory.js'; +import { InMemoryTaskStore } from '../../../src/experimental/tasks/stores/in-memory.js'; +import { toArrayAsync } from '../../../src/shared/responseMessage.js'; +import { + CreateMessageRequestSchema, + ElicitRequestSchema, + type CreateMessageResult, + type ElicitResult, + type Task +} from '../../../src/types.js'; + +describe('createMessageStream', () => { + test('should throw when tools are provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + }).toThrow('Client does not support sampling tools capability'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should throw when tool_result has no matching tool_use in previous message', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [ + { role: 'user', content: { type: 'text', text: 'Hello' } }, + { + role: 'user', + content: [{ type: 'tool_result', toolUseId: 'test-id', content: [{ type: 'text', text: 'result' }] }] + } + ], + maxTokens: 100 + }); + }).toThrow('tool_result blocks are not matching any tool_use from the previous message'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + describe('terminal message guarantees', () => { + test('should yield exactly one terminal message for successful request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('result'); + + const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should yield error as terminal message when client returns error', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => { + throw new Error('Simulated client error'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + + test('should yield exactly one terminal message with result', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).content).toBeDefined(); + } + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); + + describe('non-task request minimality', () => { + test('should yield only result message for non-task request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + expect(messages.length).toBe(1); + + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); + + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + sampling: {}, + tasks: { + requests: { + sampling: { createMessage: {} } + } + } + }, + taskStore: clientTaskStore + } + ); + + client.setRequestHandler(CreateMessageRequestSchema, async (request, extra) => { + const result = { + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Task response' } + }; + + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream( + { + messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }], + maxTokens: 100 + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).model).toBe('test-model'); + } + + clientTaskStore.cleanup(); + await client.close().catch(() => {}); + await server.close().catch(() => {}); + }); + }); +}); + +describe('elicitInputStream', () => { + let server: Server; + let client: Client; + let clientTransport: ReturnType[0]; + let serverTransport: ReturnType[1]; + + beforeEach(async () => { + server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {}, + url: {} + } + } + } + ); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await server.close().catch(() => {}); + await client.close().catch(() => {}); + }); + + test('should throw when client does not support form elicitation', async () => { + // Create client without form elicitation capability + const noFormClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const [noFormClientTransport, noFormServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noFormClient.connect(noFormClientTransport), server.connect(noFormServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { type: 'object', properties: {} } + }); + }).toThrow('Client does not support form elicitation.'); + + await noFormClient.close().catch(() => {}); + }); + + test('should throw when client does not support url elicitation', async () => { + // Create client without url elicitation capability + const noUrlClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const [noUrlClientTransport, noUrlServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noUrlClient.connect(noUrlClientTransport), server.connect(noUrlServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'url', + message: 'Open URL', + elicitationId: 'test-123', + url: 'https://example.com/auth' + }); + }).toThrow('Client does not support url elicitation.'); + + await noUrlClient.close().catch(() => {}); + }); + + test('should default to form mode when mode is not specified', async () => { + const requestStreamSpy = vi.spyOn(server.experimental.tasks, 'requestStream'); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { value: 'test' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call without explicit mode - need to cast because TypeScript expects mode + const params = { + message: 'Enter value', + requestedSchema: { + type: 'object' as const, + properties: { value: { type: 'string' as const } } + } + }; + + const stream = server.experimental.tasks.elicitInputStream( + params as Parameters[0] + ); + await toArrayAsync(stream); + + // Verify mode was normalized to 'form' + expect(requestStreamSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'elicitation/create', + params: expect.objectContaining({ mode: 'form' }) + }), + expect.anything(), + undefined + ); + }); + + test('should yield error as terminal message when client returns error', async () => { + client.setRequestHandler(ElicitRequestSchema, () => { + throw new Error('Simulated client error'); + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + }); + + // For any streaming elicitation request, the AsyncGenerator yields exactly one terminal + // message (either 'result' or 'error') as its final message. + describe('terminal message guarantees', () => { + test.each([ + { action: 'accept' as const, content: { data: 'test-value' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield exactly one terminal message for action: $action', async ({ action, content }) => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Test message', + requestedSchema: { + type: 'object', + properties: { data: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Count terminal messages (result or error) + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + // Verify terminal message is the last message + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + // Verify result content matches expected action + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe(action); + } + }); + }); + + // For any non-task elicitation request, the generator yields exactly one 'result' message + // (or 'error' if the request fails), with no 'taskCreated' or 'taskStatus' messages. + describe('non-task request minimality', () => { + test.each([ + { action: 'accept' as const, content: { value: 'test' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield only result message for non-task request with action: $action', async ({ action, content }) => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Non-task request (no task option) + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Non-task request', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Verify no taskCreated or taskStatus messages + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + // Verify exactly one result message + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + // Verify total message count is 1 + expect(messages.length).toBe(1); + }); + }); + + // For any task-augmented elicitation request, the generator should yield at least one + // 'taskCreated' message followed by 'taskStatus' messages before yielding the final + // result or error. + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const taskClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { form: {} }, + tasks: { + requests: { + elicitation: { create: {} } + } + } + }, + taskStore: clientTaskStore + } + ); + + taskClient.setRequestHandler(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept' as const, + content: { username: 'task-user' } + }; + + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: extra.taskRequestedTtl }); + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); + + const stream = server.experimental.tasks.elicitInputStream( + { + mode: 'form', + message: 'Task-augmented request', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } }, + required: ['username'] + } + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe('accept'); + expect((lastMessage.result as ElicitResult).content).toEqual({ username: 'task-user' }); + } + + clientTaskStore.cleanup(); + await taskClient.close().catch(() => {}); + }); + }); +}); From 8cbc65848388cb0364122f5760cb6b01ff8a3654 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:39:03 +0000 Subject: [PATCH 05/11] chore: bump version for v1.27.0 (#1541) --- 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 cca035cbd..b53cd04ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.26.0", + "version": "1.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.26.0", + "version": "1.27.0", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index e1ed0e1ed..cadddec62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.26.0", + "version": "1.27.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From f2d21458ccccd7cfaa1a2a171a262961591d4d0b Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:27:46 +0000 Subject: [PATCH 06/11] feat: implement auth/pre-registration conformance scenario (#1545) --- .github/workflows/conformance.yml | 4 +- package-lock.json | 208 +++++++++++++++++- package.json | 2 +- test/conformance/conformance-baseline.yml | 14 +- test/conformance/src/everythingClient.ts | 43 ++++ .../conformance/src/helpers/withOAuthRetry.ts | 21 +- 6 files changed, 265 insertions(+), 27 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 974a84cdb..9d049ec3b 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - run: npm ci - run: npm run build @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm - run: npm ci - run: npm run build diff --git a/package-lock.json b/package-lock.json index b53cd04ed..54ef65919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "devDependencies": { "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", - "@modelcontextprotocol/conformance": "^0.1.11", + "@modelcontextprotocol/conformance": "^0.1.14", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", @@ -737,13 +737,14 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.11.tgz", - "integrity": "sha512-cqayAmyTUhnRsyrOuTqZ+kCc2w/goppxnqZ+XrOsVd/M25No/HiZ1GbZI92sFA7ONYzonqRja56G9IiISIns3A==", + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.1.14.tgz", + "integrity": "sha512-CNl/7d+yHfXExPDUsNG/kO4t2iLamqLzvsFxscTT3pbP4utbnDvc6lfvLjM3TLrjupY4Iq5FURmTzhsCstA3sw==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", + "@octokit/rest": "^22.0.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -810,6 +811,172 @@ "url": "https://paulmillr.com/funding/" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -1928,6 +2095,13 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "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==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -2752,6 +2926,23 @@ "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==", + "dev": true, + "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", @@ -4348,6 +4539,13 @@ "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==", + "dev": true, + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index cadddec62..e81f5f88e 100644 --- a/package.json +++ b/package.json @@ -123,8 +123,8 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@modelcontextprotocol/conformance": "^0.1.11", "@eslint/js": "^9.39.1", + "@modelcontextprotocol/conformance": "^0.1.14", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", diff --git a/test/conformance/conformance-baseline.yml b/test/conformance/conformance-baseline.yml index 23d7e75a8..e4ae46cb0 100644 --- a/test/conformance/conformance-baseline.yml +++ b/test/conformance/conformance-baseline.yml @@ -1,14 +1,8 @@ # Known conformance test failures for v1.x # These are tracked and should be removed as they're fixed. # -# tools_call: conformance runner's test server reuses a single Server -# instance across requests, triggering v1.26.0's "Already connected" -# guard (GHSA-345p-7cg4-v4c7). Fixed in conformance repo (PR #141), -# remove this entry once a new conformance release is published. -# -# auth/pre-registration: scenario added in conformance 0.1.11 that -# requires a dedicated client handler for pre-registered credentials. -# Needs to be implemented in both v1.x and main. +# auth/cross-app-access-complete-flow: SEP-990 Enterprise Managed OAuth +# scenario added in conformance 0.1.14. Requires implementing token +# exchange (RFC 8693) and JWT bearer grant (RFC 7523) in the client. client: - - tools_call - - auth/pre-registration + - auth/cross-app-access-complete-flow diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index bd4c079de..002449f29 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -16,6 +16,7 @@ import { ElicitRequestSchema } from '../../../src/types.js'; import { z } from 'zod'; import { logger } from './helpers/logger.js'; +import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider.js'; import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; /** @@ -37,6 +38,11 @@ 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() }) ]); @@ -228,6 +234,43 @@ async function runClientCredentialsBasic(serverUrl: string): Promise { registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); +// ============================================================================ +// Pre-registration scenario (no dynamic client registration) +// ============================================================================ + +async function runPreRegistrationClient(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/pre-registration') { + throw new Error(`Expected pre-registration context, got ${ctx.name}`); + } + + // Create a provider pre-populated with registered credentials, + // so the SDK skips dynamic client registration. + const provider = new ConformanceOAuthProvider('http://localhost:3000/callback', { + client_name: 'conformance-pre-registration', + redirect_uris: ['http://localhost:3000/callback'] + }); + provider.saveClientInformation({ + client_id: ctx.client_id, + client_secret: ctx.client_secret, + redirect_uris: ['http://localhost:3000/callback'] + }); + + const oauthFetch = withOAuthRetry('conformance-pre-registration', new URL(serverUrl), handle401, undefined, provider)(fetch); + + const client = new Client({ name: 'conformance-pre-registration', 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(); +} + +registerScenario('auth/pre-registration', runPreRegistrationClient); + // ============================================================================ // Elicitation defaults scenario // ============================================================================ diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts index e24c8316f..1112bb710 100644 --- a/test/conformance/src/helpers/withOAuthRetry.ts +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -38,16 +38,19 @@ export const withOAuthRetry = ( clientName: string, baseUrl?: string | URL, handle401Fn: typeof handle401 = handle401, - clientMetadataUrl?: string + clientMetadataUrl?: string, + existingProvider?: ConformanceOAuthProvider ): Middleware => { - const provider = new ConformanceOAuthProvider( - 'http://localhost:3000/callback', - { - client_name: clientName, - redirect_uris: ['http://localhost:3000/callback'] - }, - clientMetadataUrl - ); + const provider = + existingProvider ?? + new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: clientName, + redirect_uris: ['http://localhost:3000/callback'] + }, + clientMetadataUrl + ); return (next: FetchLike) => { return async (input: string | URL, init?: RequestInit): Promise => { const makeRequest = async (): Promise => { From 2084a22074d4c8fd54ddc8637783fb10c13edf90 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:21:23 +0000 Subject: [PATCH 07/11] docs: add governance documentation for SEP-1730 (#1547) --- .github/dependabot.yml | 10 ++++++++++ DEPENDENCY_POLICY.md | 29 +++++++++++++++++++++++++++++ ROADMAP.md | 22 ++++++++++++++++++++++ VERSIONING.md | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 DEPENDENCY_POLICY.md create mode 100644 ROADMAP.md create mode 100644 VERSIONING.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1b0059283 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: monthly + groups: + github-actions: + patterns: + - '*' diff --git a/DEPENDENCY_POLICY.md b/DEPENDENCY_POLICY.md new file mode 100644 index 000000000..6091066cf --- /dev/null +++ b/DEPENDENCY_POLICY.md @@ -0,0 +1,29 @@ +# Dependency Policy + +As a library consumed by downstream projects, the MCP TypeScript SDK takes a conservative approach to dependency updates. Dependencies are kept stable unless there is a specific reason to update, such as a security vulnerability, a bug fix, or a need for new functionality. + +## Update Triggers + +Dependencies are updated when: + +- A **security vulnerability** is disclosed (via GitHub security alerts). +- A bug in a dependency directly affects the SDK. +- A new dependency feature is needed for SDK development. +- A dependency drops support for a Node.js version the SDK still targets. + +Routine version bumps without a clear motivation are avoided to minimize churn for downstream consumers. + +## What We Don't Do + +The SDK does not run scheduled version bumps for npm dependencies. Updating a dependency can force downstream consumers to adopt that update transitively, which can be disruptive for projects with strict dependency policies. + +Dependencies are only updated when there is a concrete reason, not simply because a newer version is available. + +## Automated Tooling + +- **GitHub security updates** are enabled at the repository level and automatically open pull requests for npm packages with known vulnerabilities. This is a GitHub repo setting, separate from the `dependabot.yml` configuration. +- **GitHub Actions versions** are kept up to date via Dependabot on a monthly schedule (see `.github/dependabot.yml`). + +## Pinning and Ranges + +Production dependencies use caret ranges (`^`) to allow compatible updates within a major version. Exact versions are pinned only when necessary to work around a specific issue. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..9f9bb31e0 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,22 @@ +# Roadmap + +## Spec Implementation Tracking + +The SDK tracks implementation of MCP spec components via GitHub Projects, with a dedicated project board for each spec revision. For example, see the [2025-11-25 spec revision board](https://github.com/orgs/modelcontextprotocol/projects/26). + +## Current Focus Areas + +### Next Spec Revision + +The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). Key areas expected in the next revision include extensions and stateless transports. + +The SDK has historically implemented spec changes promptly as they are finalized, with dedicated project boards tracking component-level progress for each revision. + +### v2 + +A major version of the SDK is in active development, tracked via [GitHub Project](https://github.com/orgs/modelcontextprotocol/projects/31). Target milestones: + +- **Alpha**: ~mid-March 2026 +- **Beta**: ~May 2026 + +The v2 release is planned to align with the next spec release, expected around mid-2026. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 000000000..5384f09ef --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,40 @@ +# Versioning Policy + +The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) follows [Semantic Versioning 2.0.0](https://semver.org/). + +## Version Format + +`MAJOR.MINOR.PATCH` + +- **MAJOR**: Incremented for breaking changes (see below). +- **MINOR**: Incremented for new features that are backward-compatible. +- **PATCH**: Incremented for backward-compatible bug fixes. + +## What Constitutes a Breaking Change + +The following changes are considered breaking and require a major version bump: + +- Removing or renaming a public API export (class, function, type, or constant). +- Changing the signature of a public function or method in a way that breaks existing callers (removing parameters, changing required/optional status, changing types). +- Removing or renaming a public type or interface field. +- Changing the behavior of an existing API in a way that breaks documented contracts. +- Dropping support for a Node.js LTS version. +- Removing support for a transport type. +- Changes to the MCP protocol version that require client/server code changes. + +The following are **not** considered breaking: + +- Adding new optional parameters to existing functions. +- Adding new exports, types, or interfaces. +- Adding new optional fields to existing types. +- Bug fixes that correct behavior to match documented intent. +- Internal refactoring that does not affect the public API. +- Adding support for new MCP spec features. +- Changes to dev dependencies or build tooling. + +## How Breaking Changes Are Communicated + +1. **Changelog**: All breaking changes are documented in the GitHub release notes with migration instructions. +2. **Deprecation**: When feasible, APIs are deprecated for at least one minor release before removal using `@deprecated` JSDoc annotations, which surface warnings through TypeScript tooling and editors. +3. **Migration guide**: Major version releases include a migration guide describing what changed and how to update. +4. **PR labels**: Pull requests containing breaking changes are labeled with `breaking change`. From 342ea394ca6e660e294162efdeafc411284bcc0d Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:22:16 +0000 Subject: [PATCH 08/11] docs: comprehensive feature documentation for SEP-1730 Tier 1 (#1548) --- README.md | 1 + docs/capabilities.md | 112 ++++++- docs/client.md | 50 +++ docs/protocol.md | 200 ++++++++++++ docs/server.md | 303 +++++++++++++++++- src/examples/server/elicitationFormExample.ts | 23 ++ src/examples/server/progressExample.ts | 58 ++++ 7 files changed, 742 insertions(+), 5 deletions(-) create mode 100644 docs/protocol.md create mode 100644 src/examples/server/progressExample.ts diff --git a/README.md b/README.md index 254671c8f..7a553ebbd 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ For more details on how to run these examples (including recommended commands an - [docs/server.md](docs/server.md) – building and running MCP servers, transports, tools/resources/prompts, CORS, DNS rebinding, and multi-node deployment. - [docs/client.md](docs/client.md) – using the high-level client, transports, backwards compatibility, and OAuth helpers. - [docs/capabilities.md](docs/capabilities.md) – sampling, elicitation (form and URL), and experimental task-based execution. + - [docs/protocol.md](docs/protocol.md) – protocol features: ping, progress, cancellation, pagination, capability negotiation, and JSON Schema. - [docs/faq.md](docs/faq.md) – environment and troubleshooting FAQs (including Node.js Web Crypto support). - External references: - [Model Context Protocol documentation](https://modelcontextprotocol.io) diff --git a/docs/capabilities.md b/docs/capabilities.md index 301e850fe..d436a00cd 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -27,6 +27,99 @@ Runnable example: The `simpleStreamableHttp` server also includes a `collect-user-info` tool that demonstrates how to drive elicitation from a tool and handle the response. +#### Schema validation + +Elicitation schemas support validation constraints on each field. The server validates responses automatically against the `requestedSchema` using the SDK's JSON Schema validator. + +```typescript +const result = await server.server.elicitInput({ + mode: 'form', + message: 'Enter your details:', + requestedSchema: { + type: 'object', + properties: { + email: { + type: 'string', + title: 'Email', + format: 'email', + minLength: 5 + }, + age: { + type: 'integer', + title: 'Age', + minimum: 0, + maximum: 150 + } + }, + required: ['email'] + } +}); +``` + +String fields support `minLength`, `maxLength`, and `format` (`'email'`, `'uri'`, `'date'`, `'date-time'`). Number fields support `minimum` and `maximum`. + +#### Default values + +Schema properties can include `default` values. When the client declares the `applyDefaults` capability, the SDK automatically fills in defaults for fields the user doesn't provide. + +> **Note:** `applyDefaults` is a TypeScript SDK extension — it is not part of the MCP protocol specification. + +```typescript +// Client declares applyDefaults: +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { capabilities: { elicitation: { form: { applyDefaults: true } } } } +); + +// Server schema with defaults: +requestedSchema: { + type: 'object', + properties: { + newsletter: { type: 'boolean', title: 'Newsletter', default: false }, + theme: { type: 'string', title: 'Theme', default: 'dark' } + } +} +``` + +#### Enum values + +Elicitation schemas support several enum patterns for single-select and multi-select fields: + +```typescript +requestedSchema: { + type: 'object', + properties: { + // Simple enum (untitled options) + color: { + type: 'string', + title: 'Favorite Color', + enum: ['red', 'green', 'blue'], + default: 'blue' + }, + // Titled enum with display labels + priority: { + type: 'string', + title: 'Priority', + oneOf: [ + { const: 'low', title: 'Low Priority' }, + { const: 'medium', title: 'Medium Priority' }, + { const: 'high', title: 'High Priority' } + ] + }, + // Multi-select + tags: { + type: 'array', + title: 'Tags', + items: { type: 'string', enum: ['frontend', 'backend', 'docs'] }, + minItems: 1, + maxItems: 3 + } + } +} +``` + +For a full example with validation, defaults, and enums, see [`elicitationFormExample.ts`](../src/examples/server/elicitationFormExample.ts). + ### URL elicitation URL elicitation is designed for sensitive data and secure web‑based flows (e.g., collecting an API key, confirming a payment, or doing third‑party OAuth). Instead of returning form data, the server asks the client to open a URL and the rest of the flow happens in the browser. @@ -46,6 +139,23 @@ Key points: Sensitive information **must not** be collected via form elicitation; always use URL elicitation or out‑of‑band flows for secrets. +#### Complete notification + +When a URL elicitation flow finishes (the user completes the browser-based action), the server sends a `notifications/elicitation/complete` notification to the client. This tells the client the out-of-band flow is done and any pending UI can be dismissed. + +Use `createElicitationCompletionNotifier` on the low-level server to create a callback that sends this notification: + +```typescript +// Create a notifier for a specific elicitation: +const notifyComplete = server.server.createElicitationCompletionNotifier('setup-123'); + +// Later, when the browser flow completes (e.g. via webhook): +await notifyComplete(); +// Client receives: { method: 'notifications/elicitation/complete', params: { elicitationId: 'setup-123' } } +``` + +See [`elicitationUrlExample.ts`](../src/examples/server/elicitationUrlExample.ts) for a full working example. + ## Task-based execution (experimental) Task-based execution enables “call-now, fetch-later” patterns for long-running operations. Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. @@ -70,7 +180,7 @@ For a runnable example that uses the in-memory store shipped with the SDK, see: On the client, you use: - `client.experimental.tasks.callToolStream(...)` to start a tool call that may create a task and emit status updates over time. -- `client.getTask(...)` and `client.getTaskResult(...)` to check status and fetch results after reconnecting. +- `client.experimental.tasks.getTask(...)` and `client.experimental.tasks.getTaskResult(...)` to check status and fetch results after reconnecting. The interactive client in: diff --git a/docs/client.md b/docs/client.md index d28765fd0..8f1c65327 100644 --- a/docs/client.md +++ b/docs/client.md @@ -58,3 +58,53 @@ These examples show how to: - Perform dynamic client registration if needed. - Acquire access tokens. - Attach OAuth credentials to Streamable HTTP requests. + +## stdio transport + +Use `StdioClientTransport` to connect to a server that runs as a local child process: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'production' }, + cwd: '/path/to/server' +}); + +const client = new Client({ name: 'my-client', version: '1.0.0' }); +await client.connect(transport); +// connect() calls transport.start() automatically, spawning the child process +``` + +The transport communicates over the child process's stdin/stdout using JSON-RPC. The `stderr` option controls where the child's stderr goes (defaults to `'inherit'`). + +## Roots + +Roots let a client expose filesystem locations to the server, so the server knows which directories or files are relevant. Declare the `roots` capability and register a handler: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const client = new Client({ name: 'my-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } }); + +client.setRequestHandler(ListRootsRequestSchema, async () => { + return { + roots: [ + { uri: 'file:///home/user/project', name: 'My Project' }, + { uri: 'file:///home/user/data', name: 'Data Directory' } + ] + }; +}); +``` + +When the set of roots changes, notify the server so it can re-query: + +```typescript +await client.sendRootsListChanged(); +``` + +Root URIs must use the `file://` scheme. The `listChanged: true` capability flag is required to send change notifications. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 000000000..2773e5e8d --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,200 @@ +## Protocol features + +This page covers cross-cutting protocol mechanics that apply to both clients and servers. + +## Ping + +Both client and server expose a `ping()` method for health checks. The remote side responds automatically — no handler registration is needed. + +```typescript +// Client pinging the server: +await client.ping(); + +// With a timeout (milliseconds): +await client.ping({ timeout: 5000 }); + +// Server pinging the client (via the low-level server, no timeout option): +await server.server.ping(); +``` + +## Progress notifications + +Long-running requests can report progress to the caller. The SDK handles `progressToken` assignment automatically when you provide an `onprogress` callback. + +**Receiving progress** (client side): + +```typescript +const result = await client.callTool({ name: 'long-task', arguments: {} }, CallToolResultSchema, { + onprogress: progress => { + // progress has: { progress: number, total?: number, message?: string } + console.log(`${progress.progress}/${progress.total}: ${progress.message}`); + }, + timeout: 30000, + resetTimeoutOnProgress: true +}); +``` + +**Sending progress** (server side, from a tool handler): + +```typescript +server.registerTool( + 'count', + { + description: 'Count to N with progress updates', + inputSchema: { n: z.number() } + }, + async ({ n }, extra) => { + for (let i = 1; i <= n; i++) { + if (extra._meta?.progressToken !== undefined) { + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: extra._meta.progressToken, + progress: i, + total: n, + message: `Counting: ${i}/${n}` + } + }); + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return { content: [{ type: 'text', text: `Counted to ${n}` }] }; + } +); +``` + +For a runnable example, see [`progressExample.ts`](../src/examples/server/progressExample.ts). + +## Cancellation + +Requests can be cancelled by the caller using an `AbortSignal`. The SDK sends a `notifications/cancelled` message to the remote side and aborts the handler via its `signal`. + +**Client cancelling a request**: + +```typescript +const controller = new AbortController(); + +const resultPromise = client.callTool({ name: 'slow-tool', arguments: {} }, CallToolResultSchema, { signal: controller.signal }); + +// Cancel after 5 seconds: +setTimeout(() => controller.abort('User cancelled'), 5000); +``` + +**Server handler responding to cancellation**: + +```typescript +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + for (let i = 0; i < 100; i++) { + if (extra.signal.aborted) { + return { content: [{ type: 'text', text: 'Cancelled' }], isError: true }; + } + await doWork(); + } + return { content: [{ type: 'text', text: 'Done' }] }; +}); +``` + +## Pagination + +All list methods (`listTools`, `listPrompts`, `listResources`, `listResourceTemplates`) support cursor-based pagination. Pass `cursor` from the previous response's `nextCursor` to fetch the next page. + +```typescript +let cursor: string | undefined; +const allTools: Tool[] = []; + +do { + const result = await client.listTools({ cursor }); + allTools.push(...result.tools); + cursor = result.nextCursor; +} while (cursor); +``` + +The same pattern applies to `listPrompts`, `listResources`, and `listResourceTemplates`. + +## Capability negotiation + +Both client and server declare their capabilities during the `initialize` handshake. The SDK enforces these — attempting to use an undeclared capability throws an error. + +**Client capabilities** are set at construction time: + +```typescript +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + roots: { listChanged: true }, + sampling: {}, + elicitation: { form: {} } + } + } +); +``` + +After connecting, inspect what the server supports: + +```typescript +await client.connect(transport); + +const caps = client.getServerCapabilities(); +if (caps?.tools) { + const tools = await client.listTools(); +} +if (caps?.resources?.subscribe) { + // server supports resource subscriptions +} +``` + +**Server capabilities** are inferred from registered handlers. When using `McpServer`, capabilities are set automatically based on what you register (tools, resources, prompts). With the low-level `Server`, you declare them in the constructor. + +## Protocol version negotiation + +The SDK automatically negotiates protocol versions during `initialize`. The client sends `LATEST_PROTOCOL_VERSION` and the server responds with the highest mutually supported version. + +Supported versions are defined in `SUPPORTED_PROTOCOL_VERSIONS` (currently `2025-11-25`, `2025-06-18`, `2025-03-26`, `2024-11-05`, `2024-10-07`). If the server responds with an unsupported version, the client throws an error. + +Version negotiation is handled automatically by `client.connect()`. After connecting, you can inspect the result: + +```typescript +await client.connect(transport); + +const serverVersion = client.getServerVersion(); +// { name: 'my-server', version: '1.0.0' } + +const serverCaps = client.getServerCapabilities(); +// { tools: { listChanged: true }, resources: { subscribe: true }, ... } +``` + +## JSON Schema 2020-12 + +MCP uses JSON Schema 2020-12 for tool input and output schemas. When using `McpServer` with Zod, schemas are converted to JSON Schema automatically: + +```typescript +server.registerTool( + 'calculate', + { + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() } + }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) +); +``` + +With the low-level `Server`, you provide JSON Schema directly: + +```typescript +{ + name: 'calculate', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number' }, + b: { type: 'number' } + }, + required: ['a', 'b'] + } +} +``` + +The SDK validates tool outputs against `outputSchema` (when provided) using a pluggable JSON Schema validator. The default validator uses Ajv; a Cloudflare Workers-compatible alternative is available via `CfWorkerJsonSchemaValidator`. diff --git a/docs/server.md b/docs/server.md index fb0766d5b..7dbf64290 100644 --- a/docs/server.md +++ b/docs/server.md @@ -45,6 +45,23 @@ Examples: - Stateless Streamable HTTP: [`simpleStatelessStreamableHttp.ts`](../src/examples/server/simpleStatelessStreamableHttp.ts) - Stateful with resumability: [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) +### stdio + +For local integrations where the client spawns the server as a child process, use `StdioServerTransport`. Communication happens over stdin/stdout using JSON-RPC: + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +// ... register tools, resources, prompts ... + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +This is the simplest transport — no HTTP server setup required. The client uses `StdioClientTransport` to spawn and communicate with the server process (see [docs/client.md](client.md#stdio-transport)). + ### Deprecated HTTP + SSE The older HTTP+SSE transport (protocol version 2024‑11‑05) is supported only for backwards compatibility. New implementations should prefer Streamable HTTP. @@ -128,11 +145,91 @@ This snippet is illustrative only; for runnable servers that expose tools, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) - [`toolWithSampleServer.ts`](../src/examples/server/toolWithSampleServer.ts) +#### Image and audio results + +Tools can return image and audio content alongside text. Use base64-encoded data with the appropriate MIME type: + +```typescript +// e.g. const chartPngBase64 = fs.readFileSync('chart.png').toString('base64'); +server.registerTool('generate-chart', { description: 'Generate a chart image' }, async () => ({ + content: [ + { + type: 'image', + data: chartPngBase64, + mimeType: 'image/png' + } + ] +})); + +// e.g. const audioBase64 = fs.readFileSync('speech.wav').toString('base64'); +server.registerTool( + 'text-to-speech', + { + description: 'Convert text to speech', + inputSchema: { text: z.string() } + }, + async ({ text }) => ({ + content: [ + { + type: 'audio', + data: audioBase64, + mimeType: 'audio/wav' + } + ] + }) +); +``` + +#### Embedded resource results + +Tools can return embedded resources, allowing the tool to attach full resource objects in its response: + +```typescript +server.registerTool('fetch-data', { description: 'Fetch and return data as a resource' }, async () => ({ + content: [ + { + type: 'resource', + resource: { + uri: 'data://result', + mimeType: 'application/json', + text: JSON.stringify({ key: 'value' }) + } + } + ] +})); +``` + +#### Error handling + +To indicate that a tool call failed, set `isError: true` in the result. The content describes what went wrong: + +```typescript +server.registerTool('risky-operation', { description: 'An operation that might fail' }, async () => { + try { + const result = await doSomething(); + return { content: [{ type: 'text', text: result }] }; + } catch (err) { + return { + content: [{ type: 'text', text: `Error: ${err.message}` }], + isError: true + }; + } +}); +``` + +#### Tool change notifications + +When tools are added, removed, or updated at runtime, the server automatically notifies connected clients. This happens when you call `registerTool()`, or use `remove()`, `enable()`, `disable()`, or `update()` on a `RegisteredTool`. You can also trigger it manually: + +```typescript +server.sendToolListChanged(); +``` + #### ResourceLink outputs Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need. -The README’s `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. +The README's `list-files` example shows the pattern conceptually; for concrete usage, see the Streamable HTTP examples in `src/examples/server`. ### Resources @@ -155,7 +252,70 @@ server.registerResource( ); ``` -Dynamic resources use `ResourceTemplate` and can support completions on path parameters. For full runnable examples of resources: +#### Binary resources + +Resources can return binary data using `blob` (base64-encoded) instead of `text`: + +```typescript +server.registerResource('logo', 'images://logo.png', { title: 'Logo', mimeType: 'image/png' }, async uri => ({ + contents: [{ uri: uri.href, blob: logoPngBase64 }] +})); +``` + +#### Resource templates + +Dynamic resources use `ResourceTemplate` to match URI patterns. The template parameters are passed to the read callback: + +```typescript +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; + +server.registerResource('user-profile', new ResourceTemplate('users://{userId}/profile', { list: undefined }), { title: 'User Profile', mimeType: 'application/json' }, async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify(await getUser(userId)) + } + ] +})); +``` + +#### Subscribing and unsubscribing + +Clients can subscribe to resource changes. The server declares subscription support via the `resources.subscribe` capability, which `McpServer` enables automatically when resources are registered. + +To handle subscriptions, register handlers on the low-level server for `SubscribeRequestSchema` and `UnsubscribeRequestSchema`: + +```typescript +import { SubscribeRequestSchema, UnsubscribeRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const subscriptions = new Set(); + +server.server.setRequestHandler(SubscribeRequestSchema, async request => { + subscriptions.add(request.params.uri); + return {}; +}); + +server.server.setRequestHandler(UnsubscribeRequestSchema, async request => { + subscriptions.delete(request.params.uri); + return {}; +}); +``` + +When a subscribed resource changes, notify the client: + +```typescript +if (subscriptions.has(resourceUri)) { + await server.server.sendResourceUpdated({ uri: resourceUri }); +} +``` + +Resource list changes (adding/removing resources) are notified automatically when using `registerResource()`, `remove()`, `enable()`, or `disable()`. You can also trigger it manually: + +```typescript +server.sendResourceListChanged(); +``` + +For full runnable examples of resources: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) @@ -187,15 +347,150 @@ server.registerPrompt( ); ``` +#### Image content in prompts + +Prompts can include image content in their messages: + +```typescript +server.registerPrompt( + 'analyze-image', + { + title: 'Analyze Image', + description: 'Analyze an image', + argsSchema: { imageBase64: z.string() } + }, + ({ imageBase64 }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'image', + data: imageBase64, + mimeType: 'image/png' + } + } + ] + }) +); +``` + +#### Embedded resources in prompts + +Prompts can embed resource content in their messages: + +```typescript +server.registerPrompt( + 'summarize-doc', + { + title: 'Summarize Document', + description: 'Summarize a document resource' + }, + () => ({ + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: 'docs://readme', + mimeType: 'text/plain', + text: 'Document content here...' + } + } + } + ] + }) +); +``` + +#### Prompt change notifications + +Like tools, prompt list changes are notified automatically when using `registerPrompt()`, `remove()`, `enable()`, or `disable()`. You can also trigger it manually: + +```typescript +server.sendPromptListChanged(); +``` + For prompts integrated into a full server, see: - [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) ### Completions -Both prompts and resources can support argument completions. On the client side, you use `client.complete()` with a reference to the prompt or resource and the partially‑typed argument. +Both prompts and resources can support argument completions using the `completable` wrapper. This lets clients offer autocomplete suggestions as users type. + +```typescript +import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; + +server.registerPrompt( + 'greet', + { + title: 'Greeting', + description: 'Generate a greeting', + argsSchema: { + name: completable(z.string(), value => { + // Return suggestions matching the partial input + const names = ['Alice', 'Bob', 'Charlie']; + return names.filter(n => n.toLowerCase().startsWith(value.toLowerCase())); + }) + } + }, + ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello, ${name}!` } }] + }) +); +``` + +Resource templates also support completions on their path parameters via `completable`. On the client side, use `client.complete()` with a reference to the prompt or resource and the partially-typed argument: + +```typescript +const result = await client.complete({ + ref: { type: 'ref/prompt', name: 'greet' }, + argument: { name: 'name', value: 'Al' } +}); +console.log(result.completion.values); // ['Alice'] +``` + +### Logging -See the MCP spec sections on prompts and resources for complete details, and [`simpleStreamableHttp.ts`](../src/examples/client/simpleStreamableHttp.ts) for client‑side usage patterns. +The server can send log messages to the client using `server.sendLoggingMessage()`. Clients can request a minimum log level via the `logging/setLevel` request, which `McpServer` handles automatically — messages below the requested level are suppressed. + +```typescript +// Send a log message from a tool handler: +server.registerTool( + 'process-data', + { + description: 'Process some data', + inputSchema: { data: z.string() } + }, + async ({ data }, extra) => { + await server.sendLoggingMessage({ level: 'info', data: `Processing: ${data}` }, extra.sessionId); + // ... do work ... + return { content: [{ type: 'text', text: 'Done' }] }; + } +); +``` + +For a full example, see [`simpleStreamableHttp.ts`](../src/examples/server/simpleStreamableHttp.ts) which uses `sendLoggingMessage` throughout. + +Log levels in order: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`. + +#### Log level filtering + +Clients can request a minimum log level via `logging/setLevel`. The low-level `Server` handles this automatically when the `logging` capability is enabled — it stores the requested level per session and suppresses messages below it. You can also send log messages directly using +`sendLoggingMessage`: + +```typescript +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + +// Client requests: only show 'warning' and above +// (handled automatically by the Server) + +// These will be sent or suppressed based on the client's requested level: +await server.sendLoggingMessage({ level: 'debug', data: 'verbose detail' }); // suppressed +await server.sendLoggingMessage({ level: 'warning', data: 'something is off' }); // sent +await server.sendLoggingMessage({ level: 'error', data: 'something broke' }); // sent +``` ### Display names and metadata diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts index d220806d3..0ea9c1934 100644 --- a/src/examples/server/elicitationFormExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -72,6 +72,29 @@ const getServer = () => { title: 'Newsletter', description: 'Subscribe to newsletter?', default: false + }, + role: { + type: 'string', + title: 'Role', + description: 'Your primary role', + oneOf: [ + { const: 'developer', title: 'Developer' }, + { const: 'designer', title: 'Designer' }, + { const: 'manager', title: 'Manager' }, + { const: 'other', title: 'Other' } + ], + default: 'developer' + }, + interests: { + type: 'array', + title: 'Interests', + description: 'Select your areas of interest', + items: { + type: 'string', + enum: ['frontend', 'backend', 'mobile', 'devops', 'ai'] + }, + minItems: 1, + maxItems: 3 } }, required: ['username', 'email', 'password'] diff --git a/src/examples/server/progressExample.ts b/src/examples/server/progressExample.ts new file mode 100644 index 000000000..da50c84eb --- /dev/null +++ b/src/examples/server/progressExample.ts @@ -0,0 +1,58 @@ +/** + * Example: Progress notifications over stdio. + * + * Demonstrates a tool that reports progress to the client while processing. + * + * Run: + * npx tsx src/examples/server/progressExample.ts + * + * Then connect a client with an `onprogress` callback (see docs/protocol.md). + */ + +import { McpServer } from '../../server/mcp.js'; +import { StdioServerTransport } from '../../server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ name: 'progress-example', version: '1.0.0' }, { capabilities: { logging: {} } }); + +server.registerTool( + 'count', + { + description: 'Count to N with progress updates', + inputSchema: { n: z.number().int().min(1).max(100) } + }, + async ({ n }, extra) => { + for (let i = 1; i <= n; i++) { + if (extra.signal.aborted) { + return { content: [{ type: 'text', text: `Cancelled at ${i}` }], isError: true }; + } + + if (extra._meta?.progressToken !== undefined) { + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken: extra._meta.progressToken, + progress: i, + total: n, + message: `Counting: ${i}/${n}` + } + }); + } + + // Simulate work + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return { content: [{ type: 'text', text: `Counted to ${n}` }] }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); From e79d14ab6d8fbcb49543cab3917a60a89d0a6df9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:06:14 +0000 Subject: [PATCH 09/11] fix: prevent command injection in example URL opening (v1.x backport) (#1579) --- src/examples/client/elicitationUrlExample.ts | 20 +++----------------- src/examples/client/simpleOAuthClient.ts | 20 +------------------- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts index b57927e3f..5e31fdaa7 100644 --- a/src/examples/client/elicitationUrlExample.ts +++ b/src/examples/client/elicitationUrlExample.ts @@ -25,7 +25,6 @@ import { } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { OAuthClientMetadata } from '../../shared/auth.js'; -import { exec } from 'node:child_process'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; import { UnauthorizedError } from '../../client/auth.js'; import { createServer } from 'node:http'; @@ -45,8 +44,7 @@ const clientMetadata: OAuthClientMetadata = { scope: 'mcp:tools' }; oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); - openBrowser(redirectUrl.toString()); + console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`); }); // Create readline interface for user input @@ -259,17 +257,6 @@ async function elicitationLoop(): Promise { } } -async function openBrowser(url: string): Promise { - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - console.log(`Please manually open: ${url}`); - } - }); -} - /** * Enqueues an elicitation request and returns the result. * @@ -402,9 +389,8 @@ async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - console.log(`🌐 Opening browser for authorization: ${url}`); - - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - console.log(`Please manually open: ${url}`); - } - }); - } /** * Example OAuth callback handler - in production, use a more robust approach * for handling callbacks and storing tokens @@ -166,9 +150,7 @@ class InteractiveOAuthClient { CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { - console.log(`📌 OAuth redirect handler called - opening browser`); - console.log(`Opening browser to: ${redirectUrl.toString()}`); - this.openBrowser(redirectUrl.toString()); + console.log(`\n🔗 Please open this URL in your browser to authorize:\n ${redirectUrl.toString()}`); }, this.clientMetadataUrl ); From 09a85a80439f0ca9e5556ac20bdec41461e35234 Mon Sep 17 00:00:00 2001 From: qing-ant Date: Tue, 24 Feb 2026 13:46:12 -0800 Subject: [PATCH 10/11] fix: call onerror for silently swallowed transport errors (#1580) Co-authored-by: Felix Weinberger --- src/server/webStandardStreamableHttp.ts | 24 +++- test/server/streamableHttp.test.ts | 160 ++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index 4943565a1..1f528427c 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -383,6 +383,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // The client MUST include an Accept header, listing text/event-stream as a supported content type. const acceptHeader = req.headers.get('accept'); if (!acceptHeader?.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); return this.createJsonErrorResponse(406, -32000, 'Not Acceptable: Client must accept text/event-stream'); } @@ -409,6 +410,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // Check if there's already an active standalone SSE stream for this session if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { // Only one GET SSE stream is allowed per session + this.onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); return this.createJsonErrorResponse(409, -32000, 'Conflict: Only one SSE stream is allowed per session'); } @@ -460,6 +462,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { */ private async replayEvents(lastEventId: string): Promise { if (!this._eventStore) { + this.onerror?.(new Error('Event store not configured')); return this.createJsonErrorResponse(400, -32000, 'Event store not configured'); } @@ -470,11 +473,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { streamId = await this._eventStore.getStreamIdForEventId(lastEventId); if (!streamId) { + this.onerror?.(new Error('Invalid event ID format')); return this.createJsonErrorResponse(400, -32000, 'Invalid event ID format'); } // Check conflict with the SAME streamId we'll use for mapping if (this._streamMapping.get(streamId) !== undefined) { + this.onerror?.(new Error('Conflict: Stream already has an active connection')); return this.createJsonErrorResponse(409, -32000, 'Conflict: Stream already has an active connection'); } } @@ -556,7 +561,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { eventData += `data: ${JSON.stringify(message)}\n\n`; controller.enqueue(encoder.encode(eventData)); return true; - } catch { + } catch (error) { + this.onerror?.(error as Error); return false; } } @@ -565,6 +571,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Handles unsupported requests (PUT, PATCH, etc.) */ private handleUnsupportedRequest(): Response { + this.onerror?.(new Error('Method not allowed.')); return new Response( JSON.stringify({ jsonrpc: '2.0', @@ -593,6 +600,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const acceptHeader = req.headers.get('accept'); // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); return this.createJsonErrorResponse( 406, -32000, @@ -602,6 +610,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const ct = req.headers.get('content-type'); if (!ct || !ct.includes('application/json')) { + this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); return this.createJsonErrorResponse(415, -32000, 'Unsupported Media Type: Content-Type must be application/json'); } @@ -618,6 +627,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { try { rawMessage = await req.json(); } catch { + this.onerror?.(new Error('Parse error: Invalid JSON')); return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON'); } } @@ -632,6 +642,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { messages = [JSONRPCMessageSchema.parse(rawMessage)]; } } catch { + this.onerror?.(new Error('Parse error: Invalid JSON-RPC message')); return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON-RPC message'); } @@ -642,9 +653,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // If it's a server with session management and the session ID is already set we should reject the request // to avoid re-initialization. if (this._initialized && this.sessionId !== undefined) { + this.onerror?.(new Error('Invalid Request: Server already initialized')); return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Server already initialized'); } if (messages.length > 1) { + this.onerror?.(new Error('Invalid Request: Only one initialization request is allowed')); return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Only one initialization request is allowed'); } this.sessionId = this.sessionIdGenerator?.(); @@ -824,6 +837,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } if (!this._initialized) { // If the server has not been initialized yet, reject all requests + this.onerror?.(new Error('Bad Request: Server not initialized')); return this.createJsonErrorResponse(400, -32000, 'Bad Request: Server not initialized'); } @@ -831,11 +845,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { if (!sessionId) { // Non-initialization requests without a session ID should return 400 Bad Request + this.onerror?.(new Error('Bad Request: Mcp-Session-Id header is required')); return this.createJsonErrorResponse(400, -32000, 'Bad Request: Mcp-Session-Id header is required'); } if (sessionId !== this.sessionId) { // Reject requests with invalid session ID with 404 Not Found + this.onerror?.(new Error('Session not found')); return this.createJsonErrorResponse(404, -32001, 'Session not found'); } @@ -859,6 +875,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { const protocolVersion = req.headers.get('mcp-protocol-version'); if (protocolVersion !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + this.onerror?.( + new Error( + `Bad Request: Unsupported protocol version: ${protocolVersion}` + + ` (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + ) + ); return this.createJsonErrorResponse( 400, -32000, diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 3968c21a5..4a4f7d824 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -2,6 +2,7 @@ import { createServer, type Server, IncomingMessage, ServerResponse } from 'node import { AddressInfo, createServer as netCreateServer } from 'node:net'; import { randomUUID } from 'node:crypto'; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from '../../src/server/streamableHttp.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/webStandardStreamableHttp.js'; import { McpServer } from '../../src/server/mcp.js'; import { CallToolResult, JSONRPCMessage } from '../../src/types.js'; import { AuthInfo } from '../../src/server/auth/types.js'; @@ -3112,3 +3113,162 @@ async function createTestServerWithDnsProtection(config: { baseUrl: serverUrl }; } + +describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let onerrorSpy: ReturnType void>>; + + /** Shorthand to build a Web Standard Request for direct transport testing. */ + function req(method: string, opts?: { body?: unknown; headers?: Record }): Request { + const headers: Record = { ...opts?.headers }; + if (method === 'POST') { + headers['Accept'] ??= 'application/json, text/event-stream'; + headers['Content-Type'] ??= 'application/json'; + } else if (method === 'GET') { + headers['Accept'] ??= 'text/event-stream'; + } + return new Request('http://localhost/mcp', { + method, + headers, + body: opts?.body !== undefined ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined + }); + } + + function withSession(sessionId: string, extra?: Record): Record { + return { 'mcp-session-id': sessionId, 'mcp-protocol-version': '2025-11-25', ...extra }; + } + + beforeEach(async () => { + onerrorSpy = vi.fn<(error: Error) => void>(); + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + transport.onerror = onerrorSpy; + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + onerrorSpy.mockClear(); + const response = await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(response.status).toBe(200); + return response.headers.get('mcp-session-id') as string; + } + + it('should call onerror for invalid JSON in POST', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: 'not valid json' })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Invalid JSON/); + }); + + it('should call onerror for invalid JSON-RPC message', async () => { + const sid = await initializeServer(); + await transport.handleRequest(req('POST', { body: { not: 'valid' }, headers: withSession(sid) })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Invalid JSON-RPC message/); + }); + + it('should call onerror for missing Accept header on POST', async () => { + await transport.handleRequest( + req('POST', { body: TEST_MESSAGES.initialize, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); + }); + + it('should call onerror for unsupported Content-Type', async () => { + await transport.handleRequest( + req('POST', { + body: TEST_MESSAGES.initialize, + headers: { Accept: 'application/json, text/event-stream', 'Content-Type': 'text/plain' } + }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Unsupported Media Type/); + }); + + it('should call onerror when server is not initialized', async () => { + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList })); + expect(onerrorSpy).toHaveBeenCalledTimes(1); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server not initialized/); + }); + + it('should call onerror for invalid session ID', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession('invalid-session-id') })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Session not found/); + }); + + it('should call onerror for re-initialization attempt', async () => { + await initializeServer(); + await transport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server already initialized/); + }); + + it('should call onerror for missing Accept header on GET', async () => { + const sid = await initializeServer(); + await transport.handleRequest(req('GET', { headers: { Accept: 'application/json', ...withSession(sid) } })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Not Acceptable/); + }); + + it('should call onerror for concurrent SSE streams', async () => { + const sid = await initializeServer(); + const response1 = await transport.handleRequest(req('GET', { headers: withSession(sid) })); + expect(response1.status).toBe(200); + await transport.handleRequest(req('GET', { headers: withSession(sid) })); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Only one SSE stream/); + }); + + it('should call onerror for unsupported protocol version', async () => { + const sid = await initializeServer(); + await transport.handleRequest( + req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession(sid, { 'mcp-protocol-version': 'unsupported-version' }) }) + ); + expect(onerrorSpy).toHaveBeenCalled(); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Unsupported protocol version/); + }); + + it('should call onerror for unsupported HTTP methods', async () => { + await transport.handleRequest(req('PUT')); + expect(onerrorSpy).toHaveBeenCalledTimes(1); + expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Method not allowed/); + }); + + it('should call onerror for invalid event ID in replay', async () => { + const eventStore: EventStore = { + async storeEvent(): Promise { + return 'evt-1'; + }, + async getStreamIdForEventId(): Promise { + return undefined; + }, + async replayEventsAfter(): Promise { + return 'stream-1'; + } + }; + const storeTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore }); + const storeSpy = vi.fn<(error: Error) => void>(); + storeTransport.onerror = storeSpy; + await new McpServer({ name: 'test', version: '1.0.0' }).connect(storeTransport); + + const initResp = await storeTransport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + const sid = initResp.headers.get('mcp-session-id') as string; + storeSpy.mockClear(); + + const response = await storeTransport.handleRequest( + req('GET', { headers: { ...withSession(sid), 'Last-Event-ID': 'unknown-event-id' } }) + ); + expect(response.status).toBe(400); + expect(storeSpy).toHaveBeenCalledTimes(1); + expect(storeSpy.mock.calls[0]![0]!.message).toMatch(/Invalid event ID format/); + await storeTransport.close(); + }); +}); From 4faa8c899c069a98f8a0c3f804ec1a50dc2bae64 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:53:52 +0000 Subject: [PATCH 11/11] chore: bump version to 1.27.1 (#1581) --- 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 54ef65919..ed383fcd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.27.0", + "version": "1.27.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.27.0", + "version": "1.27.1", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/package.json b/package.json index e81f5f88e..5d6c68e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.27.0", + "version": "1.27.1", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)",