feat: add conformance test infrastructure for v1.x (#1518)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
This commit is contained in:
Felix Weinberger 2026-02-11 11:18:45 +00:00 • committed by GitHub
commit b0cf83716b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1734 additions and 15 deletions

40
.github/workflows/conformance.yml vendored Normal file
View file

@ -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

1
.gitignore vendored
View file

@ -133,3 +133,4 @@ dist/
# IDE
.idea/
test/conformance/node_modules/

115
package-lock.json generated
View file

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

View file

@ -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",

View file

@ -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

View file

@ -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."

View file

@ -0,0 +1,370 @@
#!/usr/bin/env node
/**
* Everything client (v1.x) - a single conformance test client that handles all scenarios.
*
* Usage: everything-client <server-url>
*
* 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<void>;
// Registry of scenario handlers
const scenarioHandlers: Record<string, ScenarioHandler> = {};
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO;
const serverUrl = process.argv[2];
if (!scenarioName || !serverUrl) {
logger.error('Usage: MCP_CONFORMANCE_SCENARIO=<scenario> everything-client <server-url>');
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);
}

View file

@ -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<string>();
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<string, { eventId: string; message: unknown; streamId: string }>();
function createEventStore(): EventStore {
return {
async storeEvent(streamId: StreamId, message: unknown): Promise<EventId> {
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<void> }
): Promise<StreamId> {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<CallToolResult> => {
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<ReadResourceResult> => {
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<ReadResourceResult> => {
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<ReadResourceResult> => {
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<ReadResourceResult> => {
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<GetPromptResult> => {
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<GetPromptResult> => {
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<GetPromptResult> => {
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<GetPromptResult> => {
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`);
});

View file

@ -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<string>;
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<void> {
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<string> {
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;
}
}

View file

@ -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);
}
};

View file

@ -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<void> => {
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<Response> => {
const makeRequest = async (): Promise<Response> => {
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;
};
};
};

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist"
}
}