readme
This commit is contained in:
parent
fec2c90778
commit
2c1cea291f
5 changed files with 139 additions and 40 deletions
68
README.md
Normal file
68
README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# MCP Conformance Test Framework
|
||||||
|
|
||||||
|
A framework for testing MCP (Model Context Protocol) client implementations against the specification.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run start -- --command "tsx examples/clients/typescript/test1.ts" --scenario initialize
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The conformance test framework validates MCP client implementations by:
|
||||||
|
1. Starting a test server for the specified scenario
|
||||||
|
2. Running the client implementation with the test server URL
|
||||||
|
3. Capturing MCP protocol interactions
|
||||||
|
4. Running conformance checks against the specification
|
||||||
|
5. Generating detailed test results
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start -- --command "<client-command>" --scenario <scenario-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--command` - The command to run your MCP client (can include flags)
|
||||||
|
- `--scenario` - The test scenario to run (e.g., "initialize")
|
||||||
|
|
||||||
|
The framework appends the server URL as the final argument to your command.
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
Results are saved to `results/<scenario>-<timestamp>/`:
|
||||||
|
- `checks.json` - Array of conformance check results with pass/fail status
|
||||||
|
- `stdout.txt` - Client stdout output
|
||||||
|
- `stderr.txt` - Client stderr output
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
## Available Scenarios
|
||||||
|
|
||||||
|
- **initialize** - Tests MCP client initialization handshake
|
||||||
|
- Validates protocol version
|
||||||
|
- Validates clientInfo (name and version)
|
||||||
|
- Validates server response handling
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See `src/runner/DESIGN.md` for detailed architecture documentation.
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
- **Runner** (`src/runner/`) - Orchestrates test execution and result generation
|
||||||
|
- **Scenarios** (`src/scenarios/`) - Test scenarios with custom server implementations
|
||||||
|
- **Checks** (`src/checks.ts`) - Conformance validation functions
|
||||||
|
- **Types** (`src/types.ts`) - Shared type definitions
|
||||||
|
|
||||||
|
## Adding New Scenarios
|
||||||
|
|
||||||
|
1. Create a new directory in `src/scenarios/<scenario-name>/`
|
||||||
|
2. Implement the `Scenario` interface with `start()`, `stop()`, and `getChecks()`
|
||||||
|
3. Register the scenario in `src/scenarios/index.ts`
|
||||||
|
|
||||||
|
See `src/scenarios/initialize/` for a reference implementation.
|
||||||
59
examples/clients/typescript/test-broken.ts
Normal file
59
examples/clients/typescript/test-broken.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const serverUrl = process.argv[2];
|
||||||
|
|
||||||
|
if (!serverUrl) {
|
||||||
|
console.error('Usage: test-broken-client <server-url>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Connecting to MCP server at: ${serverUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPClientTransport(
|
||||||
|
new URL(serverUrl)
|
||||||
|
);
|
||||||
|
|
||||||
|
await transport.start();
|
||||||
|
|
||||||
|
const initRequest = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2025-06-18',
|
||||||
|
clientInfo: {
|
||||||
|
version: '1.0.0'
|
||||||
|
},
|
||||||
|
capabilities: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(serverUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(initRequest)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('✅ Received response:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
await transport.close();
|
||||||
|
console.log('✅ Connection closed');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Unhandled error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Scenario } from '../types.js';
|
import { Scenario } from '../types.js';
|
||||||
import { initializeScenario } from './initialize/index.js';
|
import { InitializeScenario } from './initialize.js';
|
||||||
|
|
||||||
export const scenarios = new Map<string, Scenario>([
|
export const scenarios = new Map<string, Scenario>([
|
||||||
['initialize', initializeScenario],
|
['initialize', new InitializeScenario()],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function registerScenario(name: string, scenario: Scenario): void {
|
export function registerScenario(name: string, scenario: Scenario): void {
|
||||||
|
|
|
||||||
132
src/scenarios/initialize.ts
Normal file
132
src/scenarios/initialize.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import http from 'http';
|
||||||
|
import { Scenario, ScenarioUrls, ConformanceCheck } from '../types.js';
|
||||||
|
import { createClientInitializationCheck, createServerInfoCheck } from '../checks.js';
|
||||||
|
|
||||||
|
export class InitializeScenario implements Scenario {
|
||||||
|
name = 'initialize';
|
||||||
|
description = 'Tests MCP client initialization handshake';
|
||||||
|
|
||||||
|
private server: http.Server | null = null;
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
private port: number = 0;
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.server = http.createServer((req, res) => {
|
||||||
|
this.handleRequest(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.on('error', reject);
|
||||||
|
|
||||||
|
this.server.listen(0, () => {
|
||||||
|
const address = this.server!.address();
|
||||||
|
if (address && typeof address === 'object') {
|
||||||
|
this.port = address.port;
|
||||||
|
resolve({
|
||||||
|
serverUrl: `http://localhost:${this.port}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to get server address'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
this.server = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const request = JSON.parse(body);
|
||||||
|
|
||||||
|
if (request.method === 'initialize') {
|
||||||
|
this.handleInitialize(request, res);
|
||||||
|
} else if (request.method === 'tools/list') {
|
||||||
|
this.handleToolsList(request, res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: request.id,
|
||||||
|
result: {}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32700,
|
||||||
|
message: 'Parse error'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInitialize(request: any, res: http.ServerResponse): void {
|
||||||
|
const initializeRequest = request.params;
|
||||||
|
|
||||||
|
const check = createClientInitializationCheck(initializeRequest);
|
||||||
|
this.checks.push(check);
|
||||||
|
|
||||||
|
const serverInfo = {
|
||||||
|
name: 'test-server',
|
||||||
|
version: '1.0.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverInfoCheck = createServerInfoCheck(serverInfo);
|
||||||
|
this.checks.push(serverInfoCheck);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: request.id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: '2025-06-18',
|
||||||
|
serverInfo,
|
||||||
|
capabilities: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToolsList(request: any, res: http.ServerResponse): void {
|
||||||
|
const response = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: request.id,
|
||||||
|
result: {
|
||||||
|
tools: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js';
|
|
||||||
import { InitializeTestServer } from './server.js';
|
|
||||||
|
|
||||||
export class InitializeScenario implements Scenario {
|
|
||||||
name = 'initialize';
|
|
||||||
description = 'Tests MCP client initialization handshake';
|
|
||||||
|
|
||||||
private server: InitializeTestServer | null = null;
|
|
||||||
|
|
||||||
async start(): Promise<ScenarioUrls> {
|
|
||||||
this.server = new InitializeTestServer();
|
|
||||||
const port = await this.server.start();
|
|
||||||
return {
|
|
||||||
serverUrl: `http://localhost:${port}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (this.server) {
|
|
||||||
await this.server.stop();
|
|
||||||
this.server = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getChecks(): ConformanceCheck[] {
|
|
||||||
if (!this.server) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return this.server.getChecks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const initializeScenario = new InitializeScenario();
|
|
||||||
Loading…
Reference in a new issue