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:
parent
fe9c07b465
commit
b0cf83716b
12 changed files with 1734 additions and 15 deletions
40
.github/workflows/conformance.yml
vendored
Normal file
40
.github/workflows/conformance.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -133,3 +133,4 @@ dist/
|
|||
|
||||
# IDE
|
||||
.idea/
|
||||
test/conformance/node_modules/
|
||||
|
|
|
|||
115
package-lock.json
generated
115
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
test/conformance/conformance-baseline.yml
Normal file
14
test/conformance/conformance-baseline.yml
Normal 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
|
||||
45
test/conformance/scripts/run-server-conformance.sh
Executable file
45
test/conformance/scripts/run-server-conformance.sh
Executable 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."
|
||||
370
test/conformance/src/everythingClient.ts
Normal file
370
test/conformance/src/everythingClient.ts
Normal 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);
|
||||
}
|
||||
953
test/conformance/src/everythingServer.ts
Normal file
953
test/conformance/src/everythingServer.ts
Normal 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`);
|
||||
});
|
||||
87
test/conformance/src/helpers/conformanceOAuthProvider.ts
Normal file
87
test/conformance/src/helpers/conformanceOAuthProvider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
test/conformance/src/helpers/logger.ts
Normal file
27
test/conformance/src/helpers/logger.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
81
test/conformance/src/helpers/withOAuthRetry.ts
Normal file
81
test/conformance/src/helpers/withOAuthRetry.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
8
test/conformance/tsconfig.json
Normal file
8
test/conformance/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["./src"],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue