v2 ESlint fixes (#1403)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
This commit is contained in:
parent
9296459ac0
commit
9fc7e28f48
6 changed files with 1672 additions and 1346 deletions
|
|
@ -34,6 +34,7 @@ pnpm run test:conformance:server:all
|
|||
### Running Tests Against Local Conformance Repo
|
||||
|
||||
Link the local conformance package:
|
||||
|
||||
```bash
|
||||
cd ~/code/mcp/typescript-sdk
|
||||
pnpm link ~/code/mcp/conformance
|
||||
|
|
@ -44,11 +45,13 @@ 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 \
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
Client,
|
||||
StreamableHTTPClientTransport,
|
||||
ElicitRequestSchema,
|
||||
ClientCredentialsProvider,
|
||||
PrivateKeyJwtProvider
|
||||
Client,
|
||||
StreamableHTTPClientTransport,
|
||||
ElicitRequestSchema,
|
||||
ClientCredentialsProvider,
|
||||
PrivateKeyJwtProvider
|
||||
} from '@modelcontextprotocol/client';
|
||||
import { z } from 'zod';
|
||||
import { withOAuthRetry, handle401 } from './helpers/withOAuthRetry.js';
|
||||
|
|
@ -28,8 +28,7 @@ import { logger } from './helpers/logger.js';
|
|||
* 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';
|
||||
const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json';
|
||||
|
||||
/**
|
||||
* Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT.
|
||||
|
|
@ -38,28 +37,28 @@ const CIMD_CLIENT_METADATA_URL =
|
|||
* 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()
|
||||
})
|
||||
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));
|
||||
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
|
||||
|
|
@ -70,14 +69,14 @@ const scenarioHandlers: Record<string, ScenarioHandler> = {};
|
|||
|
||||
// Helper to register a scenario handler
|
||||
function registerScenario(name: string, handler: ScenarioHandler): void {
|
||||
scenarioHandlers[name] = handler;
|
||||
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;
|
||||
}
|
||||
for (const name of names) {
|
||||
scenarioHandlers[name] = handler;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -85,50 +84,44 @@ function registerScenarios(names: string[], handler: ScenarioHandler): void {
|
|||
// ============================================================================
|
||||
|
||||
async function runBasicClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'test-client', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
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 client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
|
||||
const tools = await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
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));
|
||||
}
|
||||
// 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');
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenario('initialize', runBasicClient);
|
||||
|
|
@ -139,56 +132,48 @@ registerScenario('tools_call', runToolsCallClient);
|
|||
// ============================================================================
|
||||
|
||||
async function runAuthClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'test-auth-client', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
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 oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: oauthFetch
|
||||
});
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: oauthFetch
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await client.callTool({ name: 'test-tool', arguments: {} });
|
||||
logger.debug('Successfully called tool');
|
||||
await client.callTool({ name: 'test-tool', arguments: {} });
|
||||
logger.debug('Successfully called tool');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
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
|
||||
[
|
||||
'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
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -199,34 +184,31 @@ registerScenarios(
|
|||
* 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 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 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 client = new Client({ name: 'conformance-client-credentials-jwt', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
authProvider: provider
|
||||
});
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
authProvider: provider
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected with private_key_jwt auth');
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected with private_key_jwt auth');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt);
|
||||
|
|
@ -235,33 +217,30 @@ 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 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 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 client = new Client({ name: 'conformance-client-credentials-basic', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
authProvider: provider
|
||||
});
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
authProvider: provider
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected with client_secret_basic auth');
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected with client_secret_basic auth');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
|
||||
|
|
@ -271,65 +250,60 @@ registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
|
|||
// ============================================================================
|
||||
|
||||
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
|
||||
}
|
||||
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 empty content - SDK should merge in defaults
|
||||
return {
|
||||
action: 'accept' as const,
|
||||
content: {}
|
||||
};
|
||||
});
|
||||
// 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');
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
// Return empty content - SDK should merge in defaults
|
||||
return {
|
||||
action: 'accept' as const,
|
||||
content: {}
|
||||
};
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
// List available tools
|
||||
const tools = await client.listTools();
|
||||
logger.debug(
|
||||
'Available tools:',
|
||||
tools.tools.map((t) => t.name)
|
||||
);
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
|
||||
// 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');
|
||||
}
|
||||
// List available tools
|
||||
const tools = await client.listTools();
|
||||
logger.debug(
|
||||
'Available tools:',
|
||||
tools.tools.map(t => t.name)
|
||||
);
|
||||
|
||||
logger.debug('Calling test_client_elicitation_defaults tool...');
|
||||
const result = await client.callTool({
|
||||
name: 'test_client_elicitation_defaults',
|
||||
arguments: {}
|
||||
});
|
||||
// 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('Tool result:', JSON.stringify(result, null, 2));
|
||||
logger.debug('Calling test_client_elicitation_defaults tool...');
|
||||
const result = await client.callTool({
|
||||
name: 'test_client_elicitation_defaults',
|
||||
arguments: {}
|
||||
});
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
logger.debug('Tool result:', JSON.stringify(result, null, 2));
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenario('elicitation-sep1034-client-defaults', runElicitationDefaultsClient);
|
||||
|
|
@ -339,39 +313,36 @@ registerScenario('elicitation-sep1034-client-defaults', runElicitationDefaultsCl
|
|||
// ============================================================================
|
||||
|
||||
async function runSSERetryClient(serverUrl: string): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: 'sse-retry-test-client', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
const client = new Client({ name: 'sse-retry-test-client', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected to MCP server');
|
||||
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)
|
||||
);
|
||||
// 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');
|
||||
}
|
||||
// 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('Calling test_reconnection tool...');
|
||||
const result = await client.callTool({
|
||||
name: 'test_reconnection',
|
||||
arguments: {}
|
||||
});
|
||||
|
||||
logger.debug('Tool result:', JSON.stringify(result, null, 2));
|
||||
logger.debug('Tool result:', JSON.stringify(result, null, 2));
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenario('sse-retry', runSSERetryClient);
|
||||
|
|
@ -381,43 +352,39 @@ registerScenario('sse-retry', runSSERetryClient);
|
|||
// ============================================================================
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO;
|
||||
const serverUrl = process.argv[2];
|
||||
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}`);
|
||||
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);
|
||||
}
|
||||
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}`);
|
||||
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);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await handler(serverUrl);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await handler(serverUrl);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
main().catch(error => {
|
||||
logger.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,14 +9,7 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
CallToolResult,
|
||||
GetPromptResult,
|
||||
ReadResourceResult,
|
||||
EventId,
|
||||
EventStore,
|
||||
StreamId
|
||||
} from '@modelcontextprotocol/server';
|
||||
import type { CallToolResult, GetPromptResult, ReadResourceResult, EventId, EventStore, StreamId } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
CompleteRequestSchema,
|
||||
ElicitResultSchema,
|
||||
|
|
@ -74,8 +67,7 @@ function createEventStore(): EventStore {
|
|||
}
|
||||
|
||||
// Sample base64 encoded 1x1 red PNG pixel for testing
|
||||
const TEST_IMAGE_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
|
||||
// Sample base64 encoded minimal WAV file for testing
|
||||
const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
|
||||
|
|
@ -378,8 +370,7 @@ function createMcpServer(sessionId?: string) {
|
|||
z.object({ method: z.literal('sampling/createMessage') }).passthrough()
|
||||
)) as { content?: { text?: string }; message?: { content?: { text?: string } } };
|
||||
|
||||
const modelResponse =
|
||||
result.content?.text || result.message?.content?.text || 'No response';
|
||||
const modelResponse = result.content?.text || result.message?.content?.text || 'No response';
|
||||
|
||||
return {
|
||||
content: [
|
||||
|
|
|
|||
|
|
@ -1,95 +1,93 @@
|
|||
import type {
|
||||
OAuthClientProvider,
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens
|
||||
OAuthClientProvider,
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
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>;
|
||||
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
|
||||
) {}
|
||||
constructor(
|
||||
private readonly _redirectUrl: string | URL,
|
||||
private readonly _clientMetadata: OAuthClientMetadata,
|
||||
private readonly _clientMetadataUrl?: string | URL
|
||||
) {}
|
||||
|
||||
get redirectUrl(): string | URL {
|
||||
return this._redirectUrl;
|
||||
}
|
||||
get redirectUrl(): string | URL {
|
||||
return this._redirectUrl;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
get clientMetadataUrl(): string | undefined {
|
||||
return this._clientMetadataUrl?.toString();
|
||||
}
|
||||
get clientMetadataUrl(): string | undefined {
|
||||
return this._clientMetadataUrl?.toString();
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation | undefined {
|
||||
return this._clientInformation;
|
||||
}
|
||||
clientInformation(): OAuthClientInformation | undefined {
|
||||
return this._clientInformation;
|
||||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
|
||||
this._clientInformation = clientInformation;
|
||||
}
|
||||
saveClientInformation(clientInformation: OAuthClientInformationFull): void {
|
||||
this._clientInformation = clientInformation;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = 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
|
||||
});
|
||||
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');
|
||||
// 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;
|
||||
}
|
||||
} 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;
|
||||
async getAuthCode(): Promise<string> {
|
||||
if (this._authCode) {
|
||||
return this._authCode;
|
||||
}
|
||||
throw new Error('No authorization code');
|
||||
}
|
||||
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');
|
||||
saveCodeVerifier(codeVerifier: string): void {
|
||||
this._codeVerifier = codeVerifier;
|
||||
}
|
||||
|
||||
codeVerifier(): string {
|
||||
if (!this._codeVerifier) {
|
||||
throw new Error('No code verifier saved');
|
||||
}
|
||||
return this._codeVerifier;
|
||||
}
|
||||
return this._codeVerifier;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,20 +8,20 @@ export type LogLevel = 'debug' | 'error';
|
|||
let currentLogLevel: LogLevel = 'debug';
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLogLevel = level;
|
||||
currentLogLevel = level;
|
||||
}
|
||||
|
||||
export function getLogLevel(): LogLevel {
|
||||
return currentLogLevel;
|
||||
return currentLogLevel;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (currentLogLevel === 'debug') {
|
||||
console.log(...args);
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (currentLogLevel === 'debug') {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
error: (...args: unknown[]): void => {
|
||||
console.error(...args);
|
||||
}
|
||||
},
|
||||
error: (...args: unknown[]): void => {
|
||||
console.error(...args);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,48 +1,42 @@
|
|||
import {
|
||||
auth,
|
||||
extractWWWAuthenticateParams,
|
||||
UnauthorizedError
|
||||
} from '@modelcontextprotocol/client';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
import type { FetchLike, Middleware } from '@modelcontextprotocol/client';
|
||||
import { ConformanceOAuthProvider } from './ConformanceOAuthProvider.js';
|
||||
|
||||
export const handle401 = async (
|
||||
response: Response,
|
||||
provider: ConformanceOAuthProvider,
|
||||
next: FetchLike,
|
||||
serverUrl: string | URL
|
||||
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
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
let result = await auth(provider, {
|
||||
serverUrl,
|
||||
resourceMetadataUrl,
|
||||
scope,
|
||||
fetchFn: next
|
||||
});
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError(
|
||||
`Authentication failed with result: ${result}`
|
||||
);
|
||||
|
||||
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.
|
||||
|
|
@ -57,55 +51,50 @@ export const handle401 = async (
|
|||
* @returns A fetch middleware function
|
||||
*/
|
||||
export const withOAuthRetry = (
|
||||
clientName: string,
|
||||
baseUrl?: string | URL,
|
||||
handle401Fn: typeof handle401 = handle401,
|
||||
clientMetadataUrl?: string
|
||||
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 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}`);
|
||||
}
|
||||
// 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 });
|
||||
};
|
||||
return await next(input, { ...init, headers });
|
||||
};
|
||||
|
||||
let response = await makeRequest();
|
||||
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);
|
||||
// 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();
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
// 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;
|
||||
return response;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue