Refactor client testing to use 'everything' client with scenario routing (#54)
* Refactor client testing to pass scenario name and server URL The CLI now passes both scenario name and server URL as arguments to the client command (instead of just the server URL). This enables a single "everything" client that can handle all scenarios based on the scenario name it receives. Changes: - Update executeClient() to pass scenarioName as first argument to client - Add everything-client.ts that routes to appropriate behavior based on scenario name - Update README with new usage examples and documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Use env var for scenario name instead of positional arg Pass scenario name via MCP_CONFORMANCE_SCENARIO env var instead of as a positional argument. This is non-breaking for existing clients that just accept <server-url>. The env var approach is consistent with how context (including private keys for JWT auth) is already passed via MCP_CONFORMANCE_CONTEXT. * Add comment explaining env var design decision --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Olivier Chafik <ochafik@anthropic.com>
This commit is contained in:
parent
053e9bd9e0
commit
be51f872e1
3 changed files with 240 additions and 5 deletions
14
README.md
14
README.md
|
|
@ -9,7 +9,11 @@ A framework for testing MCP (Model Context Protocol) client and server implement
|
|||
### Testing Clients
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/conformance client --command "tsx examples/clients/typescript/test1.ts" --scenario initialize
|
||||
# Using the everything-client (recommended)
|
||||
npx @modelcontextprotocol/conformance client --command "tsx examples/clients/typescript/everything-client.ts" --scenario initialize
|
||||
|
||||
# Run an entire suite of tests
|
||||
npx @modelcontextprotocol/conformance client --command "tsx examples/clients/typescript/everything-client.ts" --suite auth
|
||||
```
|
||||
|
||||
### Testing Servers
|
||||
|
|
@ -59,10 +63,11 @@ npx @modelcontextprotocol/conformance client --command "<client-command>" --scen
|
|||
|
||||
- `--command` - The command to run your MCP client (can include flags)
|
||||
- `--scenario` - The test scenario to run (e.g., "initialize")
|
||||
- `--suite` - Run a suite of tests in parallel (e.g., "auth")
|
||||
- `--timeout` - Timeout in milliseconds (default: 30000)
|
||||
- `--verbose` - Show verbose output
|
||||
|
||||
The framework appends the server URL as the final argument to your command.
|
||||
The framework appends `<server-url>` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data.
|
||||
|
||||
### Server Testing
|
||||
|
||||
|
|
@ -89,8 +94,9 @@ npx @modelcontextprotocol/conformance server --url <url> [--scenario <scenario>]
|
|||
|
||||
## Example Clients
|
||||
|
||||
- `examples/clients/typescript/test1.ts` - Valid MCP client (passes all checks)
|
||||
- `examples/clients/typescript/test-broken.ts` - Invalid client missing required fields (fails checks)
|
||||
- `examples/clients/typescript/everything-client.ts` - Single client that handles all scenarios based on scenario name (recommended)
|
||||
- `examples/clients/typescript/test1.ts` - Simple MCP client (for reference)
|
||||
- `examples/clients/typescript/auth-test.ts` - Well-behaved OAuth client (for reference)
|
||||
|
||||
## Available Scenarios
|
||||
|
||||
|
|
|
|||
222
examples/clients/typescript/everything-client.ts
Normal file
222
examples/clients/typescript/everything-client.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/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 } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { withOAuthRetry } from './helpers/withOAuthRetry.js';
|
||||
import { logger } from './helpers/logger.js';
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
registerScenarios(['initialize', 'tools-call'], runBasicClient);
|
||||
|
||||
// ============================================================================
|
||||
// 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)
|
||||
)(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-dcr',
|
||||
'auth/basic-metadata-var1',
|
||||
'auth/basic-metadata-var2',
|
||||
'auth/basic-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'
|
||||
],
|
||||
runAuthClient
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 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: {
|
||||
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: {}
|
||||
};
|
||||
});
|
||||
|
||||
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-defaults', runElicitationDefaultsClient);
|
||||
|
||||
// ============================================================================
|
||||
// Main entry point
|
||||
// ============================================================================
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO;
|
||||
const serverUrl = process.argv[2];
|
||||
|
||||
if (!scenarioName || !serverUrl) {
|
||||
console.error(
|
||||
'Usage: MCP_CONFORMANCE_SCENARIO=<scenario> everything-client <server-url>'
|
||||
);
|
||||
console.error(
|
||||
'\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.'
|
||||
);
|
||||
console.error('\nAvailable scenarios:');
|
||||
for (const name of Object.keys(scenarioHandlers).sort()) {
|
||||
console.error(` - ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const handler = scenarioHandlers[scenarioName];
|
||||
if (!handler) {
|
||||
console.error(`Unknown scenario: ${scenarioName}`);
|
||||
console.error('\nAvailable scenarios:');
|
||||
for (const name of Object.keys(scenarioHandlers).sort()) {
|
||||
console.error(` - ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await handler(serverUrl);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ export interface ClientExecutionResult {
|
|||
|
||||
async function executeClient(
|
||||
command: string,
|
||||
scenarioName: string,
|
||||
serverUrl: string,
|
||||
timeout: number = 30000,
|
||||
context?: Record<string, unknown>
|
||||
|
|
@ -26,8 +27,13 @@ async function executeClient(
|
|||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
// Build environment with optional context
|
||||
// Build environment with scenario name and optional context.
|
||||
// We use separate env vars rather than putting scenario in context because:
|
||||
// 1. Scenario is always set, context is only set when there's scenario-specific data
|
||||
// 2. Simpler to read a string vs parsing JSON just to get the scenario name
|
||||
// 3. Semantic separation: scenario identifies "which test", context provides "test data"
|
||||
const env = { ...process.env };
|
||||
env.MCP_CONFORMANCE_SCENARIO = scenarioName;
|
||||
if (context) {
|
||||
env.MCP_CONFORMANCE_CONTEXT = JSON.stringify(context);
|
||||
}
|
||||
|
|
@ -105,6 +111,7 @@ export async function runConformanceTest(
|
|||
try {
|
||||
const clientOutput = await executeClient(
|
||||
clientCommand,
|
||||
scenarioName,
|
||||
urls.serverUrl,
|
||||
timeout,
|
||||
urls.context
|
||||
|
|
|
|||
Loading…
Reference in a new issue