From 5d8595b663c722250b6b5cbdc9f6a717d36f360c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 24 Apr 2026 12:09:12 +0100 Subject: [PATCH] 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. --- AGENTS.md | 2 +- .../typescript/sep-2164-empty-contents.ts | 66 +++++++++++ src/scenarios/server/negative.test.ts | 109 ++++++++++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 examples/servers/typescript/sep-2164-empty-contents.ts create mode 100644 src/scenarios/server/negative.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7a230da..1d8aef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/examples/servers/typescript/sep-2164-empty-contents.ts b/examples/servers/typescript/sep-2164-empty-contents.ts new file mode 100644 index 0000000..6ec1bdf --- /dev/null +++ b/examples/servers/typescript/sep-2164-empty-contents.ts @@ -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` + ); +}); diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts new file mode 100644 index 0000000..769374a --- /dev/null +++ b/src/scenarios/server/negative.test.ts @@ -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 { + 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 { + 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); + }); +});