v2: Move conformance into workspace (#1470)

This commit is contained in:
Konstantin Konstantinov 2026-02-03 19:56:49 +02:00 • committed by GitHub
commit 1fb7b25697
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 148 additions and 35 deletions

View file

@ -33,12 +33,12 @@
"lint:fix:all": "pnpm -r lint:fix",
"check:all": "pnpm -r typecheck && pnpm -r lint",
"test:all": "pnpm -r test",
"test:conformance:client": "conformance client --command 'npx tsx src/conformance/everything-client.ts'",
"test:conformance:client:all": "conformance client --command 'npx tsx src/conformance/everything-client.ts' --suite all",
"test:conformance:client:run": "npx tsx src/conformance/everything-client.ts",
"test:conformance:server": "scripts/run-server-conformance.sh",
"test:conformance:server:all": "scripts/run-server-conformance.sh --suite all",
"test:conformance:server:run": "npx tsx src/conformance/everything-server.ts",
"test:conformance:client": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client",
"test:conformance:client:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:all",
"test:conformance:client:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:run",
"test:conformance:server": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server",
"test:conformance:server:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:all",
"test:conformance:server:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:run",
"test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all"
},
"devDependencies": {
@ -47,7 +47,6 @@
"@changesets/cli": "^2.29.8",
"@eslint/js": "catalog:devTools",
"@modelcontextprotocol/client": "workspace:^",
"@modelcontextprotocol/conformance": "0.1.10",
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/node": "workspace:^",
"@types/content-type": "catalog:devTools",
@ -60,12 +59,10 @@
"@types/supertest": "catalog:devTools",
"@types/ws": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"cors": "catalog:runtimeServerOnly",
"eslint": "catalog:devTools",
"eslint-config-prettier": "catalog:devTools",
"eslint-plugin-n": "catalog:devTools",
"fast-glob": "^3.3.3",
"express": "catalog:runtimeServerOnly",
"prettier": "catalog:devTools",
"supertest": "catalog:devTools",
"tsdown": "catalog:devTools",

51
pnpm-lock.yaml generated
View file

@ -146,9 +146,6 @@ importers:
'@modelcontextprotocol/client':
specifier: workspace:^
version: link:packages/client
'@modelcontextprotocol/conformance':
specifier: 0.1.10
version: 0.1.10(@cfworker/json-schema@4.1.1)(hono@4.11.4)
'@modelcontextprotocol/node':
specifier: workspace:^
version: link:packages/middleware/node
@ -185,9 +182,6 @@ importers:
'@typescript/native-preview':
specifier: catalog:devTools
version: 7.0.0-dev.20260105.1
cors:
specifier: catalog:runtimeServerOnly
version: 2.8.5
eslint:
specifier: catalog:devTools
version: 9.39.2
@ -197,9 +191,6 @@ importers:
eslint-plugin-n:
specifier: catalog:devTools
version: 17.23.1(eslint@9.39.2)(typescript@5.9.3)
express:
specifier: catalog:runtimeServerOnly
version: 5.2.1
fast-glob:
specifier: ^3.3.3
version: 3.3.3
@ -845,6 +836,48 @@ importers:
specifier: catalog:devTools
version: 4.0.16(@types/node@24.10.4)(tsx@4.21.0)(yaml@2.8.2)
test/conformance:
devDependencies:
'@modelcontextprotocol/client':
specifier: workspace:^
version: link:../../packages/client
'@modelcontextprotocol/conformance':
specifier: 0.1.10
version: 0.1.10(@cfworker/json-schema@4.1.1)(hono@4.11.4)
'@modelcontextprotocol/core':
specifier: workspace:^
version: link:../../packages/core
'@modelcontextprotocol/eslint-config':
specifier: workspace:^
version: link:../../common/eslint-config
'@modelcontextprotocol/express':
specifier: workspace:^
version: link:../../packages/middleware/express
'@modelcontextprotocol/node':
specifier: workspace:^
version: link:../../packages/middleware/node
'@modelcontextprotocol/server':
specifier: workspace:^
version: link:../../packages/server
'@modelcontextprotocol/test-helpers':
specifier: workspace:^
version: link:../helpers
'@modelcontextprotocol/tsconfig':
specifier: workspace:^
version: link:../../common/tsconfig
'@modelcontextprotocol/vitest-config':
specifier: workspace:^
version: link:../../common/vitest-config
cors:
specifier: catalog:runtimeServerOnly
version: 2.8.5
express:
specifier: catalog:runtimeServerOnly
version: 5.2.1
zod:
specifier: catalog:runtimeShared
version: 4.3.5
test/helpers:
devDependencies:
'@modelcontextprotocol/core':

View file

@ -0,0 +1,67 @@
# Conformance Tests
This directory contains conformance test implementations for the TypeScript MCP SDK.
## Client Conformance Tests
Tests the SDK's client implementation against a conformance test server.
```bash
# Run all client tests
pnpm run test:conformance:client:all
# Run specific suite
pnpm run test:conformance:client -- --suite auth
# Run single scenario
pnpm run test:conformance:client -- --scenario auth/basic-cimd
```
## Server Conformance Tests
Tests the SDK's server implementation by running a conformance server.
```bash
# Run all active server tests
pnpm run test:conformance:server
# Run all server tests (including pending)
pnpm run test:conformance:server:all
```
## Local Development
### Running Tests Against Local Conformance Repo
Link the local conformance package:
```bash
cd ~/code/mcp/typescript-sdk
pnpm link ~/code/mcp/conformance
```
Then run tests as above.
### Debugging Server Tests
Start the server manually:
```bash
pnpm run test:conformance:server:run
```
In another terminal, run specific tests:
```bash
npx @modelcontextprotocol/conformance server \
--url http://localhost:3000/mcp \
--scenario server-initialize
```
## Files
- `everything-client.ts` - Client that handles all client conformance scenarios
- `everything-server.ts` - Server that implements all server conformance features
- `helpers/` - Shared utilities for conformance tests
Scripts are in `scripts/` at the repo root.

View file

@ -0,0 +1,5 @@
// @ts-check
import baseConfig from '@modelcontextprotocol/eslint-config';
export default baseConfig;

View file

@ -0,0 +1,54 @@
{
"name": "@modelcontextprotocol/test-conformance",
"private": true,
"version": "2.0.0-alpha.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git"
},
"engines": {
"node": ">=20",
"pnpm": ">=10.24.0"
},
"packageManager": "pnpm@10.24.0",
"keywords": [
"modelcontextprotocol",
"mcp"
],
"scripts": {
"lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .",
"lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .",
"check": "npm run typecheck && npm run lint",
"start": "npm run server",
"server": "tsx watch --clear-screen=false scripts/cli.ts server",
"client": "tsx scripts/cli.ts client",
"test:conformance:client": "conformance client --command 'npx tsx ./src/everythingClient.ts'",
"test:conformance:client:all": "conformance client --command 'npx tsx ./src/everythingClient.ts' --suite all",
"test:conformance:client:run": "npx tsx ./src/everythingClient.ts",
"test:conformance:server": "scripts/run-server-conformance.sh",
"test:conformance:server:all": "scripts/run-server-conformance.sh --suite all",
"test:conformance:server:run": "npx tsx ./src/everythingServer.ts",
"test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all"
},
"devDependencies": {
"@modelcontextprotocol/conformance": "0.1.10",
"@modelcontextprotocol/client": "workspace:^",
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/core": "workspace:^",
"@modelcontextprotocol/express": "workspace:^",
"@modelcontextprotocol/node": "workspace:^",
"@modelcontextprotocol/tsconfig": "workspace:^",
"@modelcontextprotocol/vitest-config": "workspace:^",
"@modelcontextprotocol/eslint-config": "workspace:^",
"@modelcontextprotocol/test-helpers": "workspace:^",
"cors": "catalog:runtimeServerOnly",
"express": "catalog:runtimeServerOnly",
"zod": "catalog:runtimeShared"
}
}

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 the 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 ./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,387 @@
#!/usr/bin/env node
/**
* Everything client - 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.
*
* This client routes to the appropriate behavior based on the scenario name,
* consolidating all the individual test clients into one.
*/
import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
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.
* When server supports client_id_metadata_document_supported, this URL
* will be used as the client_id instead of doing dynamic registration.
*/
const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json';
/**
* Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT.
*
* Each variant includes a `name` field matching the scenario name to enable
* discriminated union parsing and type-safe access to scenario-specific fields.
*/
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
// Note: client-credentials-jwt and client-credentials-basic have their own handlers below
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
// ============================================================================
/**
* Client credentials with private_key_jwt authentication.
*/
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);
/**
* Client credentials with client_secret_basic authentication.
*/
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('elicitation/create', async request => {
logger.debug('Received elicitation request:', JSON.stringify(request.params, null, 2));
logger.debug('Accepting with empty content - SDK should apply defaults');
// Return empty content - SDK should merge in defaults
return {
action: 'accept' as const,
content: {}
};
});
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
// List available tools
const tools = await client.listTools();
logger.debug(
'Available tools:',
tools.tools.map(t => t.name)
);
// Call the test tool which will trigger elicitation
const testTool = tools.tools.find(t => t.name === 'test_client_elicitation_defaults');
if (!testTool) {
throw new Error('Test tool not found: test_client_elicitation_defaults');
}
logger.debug('Calling test_client_elicitation_defaults tool...');
const result = await client.callTool({
name: 'test_client_elicitation_defaults',
arguments: {}
});
logger.debug('Tool result:', JSON.stringify(result, null, 2));
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('elicitation-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');
// List tools to get the reconnection test tool
const tools = await client.listTools();
logger.debug(
'Available tools:',
tools.tools.map(t => t.name)
);
// Call the test_reconnection tool which triggers stream closure
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).toSorted()) {
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).toSorted()) {
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);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,93 @@
import type {
OAuthClientInformation,
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthClientProvider,
OAuthTokens
} from '@modelcontextprotocol/client';
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' // Don't follow redirects automatically
});
// Get the Location header which contains the redirect with auth code
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,101 @@
import type { FetchLike, Middleware } from '@modelcontextprotocol/client';
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client';
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') {
// Ordinarily, we'd wait for the callback to be handled here,
// but in our conformance provider, we get the authorization code
// during the redirect handling, so we can go straight to
// retrying the auth step.
// await provider.waitForCallback();
const authorizationCode = await provider.getAuthCode();
// TODO: this retry logic should be incorporated into the typescript SDK
result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
authorizationCode,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError(`Authentication failed with result: ${result}`);
}
}
};
/**
* Creates a fetch wrapper that handles OAuth authentication with retry logic.
*
* Unlike the SDK's withOAuth, this version:
* - Automatically handles authorization redirects by retrying with fresh tokens
* - Does not throw UnauthorizedError on redirect, but instead retries
* - Calls next() instead of throwing for redirect-based auth
*
* @param provider - OAuth client provider for authentication
* @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain)
* @returns A fetch middleware function
*/
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);
// Add authorization header if tokens are available
const tokens = await provider.tokens();
if (tokens) {
headers.set('Authorization', `Bearer ${tokens.access_token}`);
}
return await next(input, { ...init, headers });
};
let response = await makeRequest();
// Handle 401 responses by attempting re-authentication
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 we still have a 401 after re-auth attempt, throw an error
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,17 @@
{
"extends": "@modelcontextprotocol/tsconfig",
"include": ["./"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"paths": {
"*": ["./*"],
"@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"],
"@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
"@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"],
"@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"],
"@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"]
}
}
}

View file

@ -0,0 +1,3 @@
import baseConfig from '../../common/vitest-config/vitest.config.js';
export default baseConfig;