test: add server-side negative test harness (#249)
Adds src/scenarios/server/negative.test.ts with spawn helpers. Wires two cases: the previously-orphaned no-dns-rebinding-protection.ts and a new sep-2164-empty-contents.ts. Strengthens the AGENTS.md guidance to point at the harness.
This commit is contained in:
parent
90b2334dcc
commit
5d8595b663
3 changed files with 176 additions and 1 deletions
|
|
@ -76,7 +76,7 @@ gh api "repos/modelcontextprotocol/modelcontextprotocol/contents/docs/specificat
|
|||
A new scenario should come with:
|
||||
|
||||
1. **A passing example** — usually by extending `examples/clients/typescript/everything-client.ts` or the everything-server, not a new file.
|
||||
2. **Evidence it fails when it should** — ideally a negative example (a deliberately broken client), or at minimum a manual run showing the failure mode.
|
||||
2. **A negative test** — a deliberately-broken implementation in `examples/{clients,servers}/typescript/` plus a vitest case asserting the check emits `FAILURE`/`WARNING` against it. See `src/scenarios/client/auth/index.test.ts` and `src/scenarios/server/negative.test.ts` for the pattern. A passing run against the everything-server proves the check doesn't false-positive, but not that it catches anything.
|
||||
|
||||
Delete unused example scenarios. If a scenario key in the everything-client has no corresponding test, remove it.
|
||||
|
||||
|
|
|
|||
66
examples/servers/typescript/sep-2164-empty-contents.ts
Normal file
66
examples/servers/typescript/sep-2164-empty-contents.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* SEP-2164 Negative Test Server
|
||||
*
|
||||
* Returns an empty contents array for any resources/read request, violating
|
||||
* the SEP-2164 MUST NOT. The sep-2164-resource-not-found scenario should
|
||||
* emit FAILURE for sep-2164-no-empty-contents against this server.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import express from 'express';
|
||||
|
||||
function createServer() {
|
||||
const server = new Server(
|
||||
{ name: 'sep-2164-empty-contents', version: '1.0.0' },
|
||||
{ capabilities: { resources: {} } }
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: []
|
||||
}));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async () => ({
|
||||
contents: []
|
||||
}));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
try {
|
||||
const server = createServer();
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3005', 10);
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(
|
||||
`SEP-2164 negative test server running on http://localhost:${PORT}/mcp`
|
||||
);
|
||||
});
|
||||
109
src/scenarios/server/negative.test.ts
Normal file
109
src/scenarios/server/negative.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { spawn, ChildProcess } from 'child_process';
|
||||
import path from 'path';
|
||||
import { DNSRebindingProtectionScenario } from './dns-rebinding';
|
||||
import { ResourcesNotFoundErrorScenario } from './resources';
|
||||
|
||||
function startServer(scriptPath: string, port: number): Promise<ChildProcess> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const proc = spawn('npx', ['tsx', scriptPath], {
|
||||
env: { ...process.env, PORT: port.toString() },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: isWindows
|
||||
});
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (d) => (stderr += d.toString()));
|
||||
const timeout = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
reject(
|
||||
new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`)
|
||||
);
|
||||
}, 30000);
|
||||
proc.stdout?.on('data', (data) => {
|
||||
if (data.toString().includes('running on')) {
|
||||
clearTimeout(timeout);
|
||||
resolve(proc);
|
||||
}
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopServer(proc: ChildProcess | null): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!proc || proc.killed) return resolve();
|
||||
const t = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 5000);
|
||||
proc.once('exit', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
proc.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
describe('Server scenario negative tests', () => {
|
||||
describe('dns-rebinding-protection', () => {
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
const PORT = 3004;
|
||||
|
||||
beforeAll(async () => {
|
||||
serverProcess = await startServer(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
'examples/servers/typescript/no-dns-rebinding-protection.ts'
|
||||
),
|
||||
PORT
|
||||
);
|
||||
}, 35000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stopServer(serverProcess);
|
||||
});
|
||||
|
||||
it('emits FAILURE against a server without rebinding protection', async () => {
|
||||
const scenario = new DNSRebindingProtectionScenario();
|
||||
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
|
||||
|
||||
const rebindingCheck = checks.find(
|
||||
(c) => c.id === 'localhost-host-rebinding-rejected'
|
||||
);
|
||||
expect(rebindingCheck?.status).toBe('FAILURE');
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('sep-2164-resource-not-found', () => {
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
const PORT = 3005;
|
||||
|
||||
beforeAll(async () => {
|
||||
serverProcess = await startServer(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
'examples/servers/typescript/sep-2164-empty-contents.ts'
|
||||
),
|
||||
PORT
|
||||
);
|
||||
}, 35000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stopServer(serverProcess);
|
||||
});
|
||||
|
||||
it('emits FAILURE for no-empty-contents and WARNING for error-code against a server returning empty contents', async () => {
|
||||
const scenario = new ResourcesNotFoundErrorScenario();
|
||||
const checks = await scenario.run(`http://localhost:${PORT}/mcp`);
|
||||
|
||||
const noEmpty = checks.find((c) => c.id === 'sep-2164-no-empty-contents');
|
||||
expect(noEmpty?.status).toBe('FAILURE');
|
||||
|
||||
const errorCode = checks.find((c) => c.id === 'sep-2164-error-code');
|
||||
expect(errorCode?.status).toBe('WARNING');
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue