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" + } +}