Recipes for the most frequent test failures, grouped by category. Every recipe names the test ID so you can cross-reference with the testing methodology and the --only <test-id> flag.
Code samples use the official MCP TypeScript SDK where applicable and vanilla HTTP / stdio where not.
Failure: HTTP 401 (auth required — pass --auth) or HTTP 404.
Fix: Your server is reachable but rejecting the probe. If auth is required, pass --auth 'Bearer <token>' to the CLI. If the URL is wrong, check the path — most MCP servers serve at /mcp or / specifically.
Failure: Content-Type: text/html or application/xml.
Fix: set the response Content-Type explicitly for JSON-RPC responses:
res.setHeader('Content-Type', 'application/json');…or for streaming:
res.setHeader('Content-Type', 'text/event-stream');Never fall through to your HTTP framework's default (which is often text/html).
Failure: server returned 200 with an empty body for a notification (message without id).
Fix:
const isNotification = msg.id === undefined;
if (isNotification) {
res.statusCode = 202;
res.end(); // no body
return;
}Per spec: notifications MUST return exactly 202 Accepted.
Failure: server accepted a request missing the session header without returning 400.
Fix: after your server issues a session ID (in Mcp-Session-Id response header on initialize), reject subsequent requests that don't include it:
const sid = req.headers['mcp-session-id'];
if (!sid || !sessions.has(sid)) {
res.writeHead(sid ? 404 : 400);
res.end();
return;
}Failure: server returned 400 for a fabricated session ID instead of 404.
Fix: distinguish the two: missing → 400, unknown → 404. Same spec rule, two error codes.
Failure: server processed a batch array.
Fix: MCP explicitly forbids JSON-RPC batching. Detect arrays early and reject:
const body = JSON.parse(rawBody);
if (Array.isArray(body)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32600, message: 'Batch requests not supported' }, id: null }));
return;
}Failure: server accepted text/plain body.
Fix: validate the incoming Content-Type:
if (!req.headers['content-type']?.includes('application/json')) {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Content-Type must be application/json' }));
return;
}Failure: SSE stream emitted data: lines without event: message prefix.
Fix:
// WRONG
res.write(`data: ${JSON.stringify(msg)}\n\n`);
// RIGHT
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);Per spec: JSON-RPC messages in SSE streams MUST be tagged with event: message.
Failure: No result in response or result missing protocolVersion.
Fix: Return a proper result object for initialize:
{
jsonrpc: '2.0',
id: msg.id,
result: {
protocolVersion: '2025-11-25',
capabilities: { /* your capabilities */ },
serverInfo: { name: 'my-server', version: '1.0.0' }
}
}Failure: Version: invalid or Version: latest.
Fix: protocolVersion must be an exact date string, YYYY-MM-DD, matching one of the published MCP spec versions. Don't use keywords like "latest" or "current".
Failure: Missing jsonrpc field or id missing.
Fix: every response must include:
jsonrpc: "2.0"(literal string)id(same type as the request's id)- Exactly one of
resultORerror, never both, never neither
Failure: Request id=1001, response id=1002.
Fix: preserve the request's id exactly in your response. Don't generate a new one.
Failure: server accepted a second initialize on the same session.
Fix (optional/advisory): the spec doesn't explicitly mandate rejection, but strict servers enforce it:
if (session.initialized) {
return { jsonrpc: '2.0', id: msg.id, error: { code: -32600, message: 'Already initialized' } };
}Failure: No tools field in result.
Fix: return { tools: [...] } even when you have zero tools:
return { jsonrpc: '2.0', id: msg.id, result: { tools: [] } };Failure: Tool "foo" missing type: object wrapper.
Fix: every tool's inputSchema must be a JSON Schema object with type: "object" at the root:
{
name: 'my_tool',
description: '...',
inputSchema: {
type: 'object', // required
properties: { message: { type: 'string' } },
required: ['message'],
}
}Failure: Unknown content type: markdown.
Fix: content type must be one of: text, image, audio, resource, resource_link. For markdown, use text with the markdown string inside.
Failure: server returned a result for an unknown method.
Fix:
const handler = handlers[msg.method];
if (!handler) {
return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } };
}Failure: server returned error but with wrong code (e.g., -32600 instead of -32601).
Fix: memorize the canonical JSON-RPC codes:
-32700Parse error (invalid JSON)-32600Invalid Request (valid JSON, invalid JSON-RPC structure)-32601Method not found-32602Invalid params-32603Internal error
Failure: server crashed or returned 200 on a message missing method.
Fix: validate the message shape before dispatching:
if (typeof msg.jsonrpc !== 'string' || typeof msg.method !== 'string') {
return { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32600, message: 'Invalid Request' } };
}Failure: Tool at index 2 missing name field.
Fix: every entry in your tools list needs at minimum name: string and inputSchema: {...}. These aren't optional per spec.
Failure: server accepted requests without an Authorization header.
Fix: if you're running on the public internet, require auth:
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
res.writeHead(401, { 'WWW-Authenticate': 'Bearer' });
res.end();
return;
}
// validate the tokenSkip this for stdio servers (no external caller) or tightly-scoped internal HTTP servers.
Failure: server processed 50 rapid requests without throttling.
Fix: add per-IP or per-session limits. With Express + express-rate-limit:
import rateLimit from 'express-rate-limit';
app.use('/mcp', rateLimit({ windowMs: 60_000, max: 100 }));Tune windows to your workload.
Failure: your tool echoed && echo pwned in its output without apparent rejection.
Fix: never pass tool arguments to shell commands. Two rules:
- Use
execFile()orspawn()with an array of args, neverexec()with a concatenated string.
// WRONG
exec(`convert ${userPath} output.png`);
// RIGHT
execFile('convert', [userPath, 'output.png']);- Validate inputs against an allowlist before using them. If a parameter is supposed to be a filename, reject strings with
&,|,;, backticks, or$().
If the test is a false positive (your server DID block the payload but the error message echoed it back), check that your error responses start with something like "Access denied" or "Permission denied" — the heuristic recognizes those as defense signals.
Failure: ../../etc/passwd in a tool param caused file content to return.
Fix: always resolve paths against an allowed root and reject results outside:
import { resolve, relative } from 'node:path';
const ROOT = '/var/data';
const resolved = resolve(ROOT, userPath);
if (relative(ROOT, resolved).startsWith('..')) {
throw new Error('Access denied - path outside allowed directories');
}Failure: a tool accepted http://169.254.169.254/ (AWS metadata) and returned internal data.
Fix: resolve hostnames and reject private IP ranges before fetching:
import { resolveDns } from 'node:dns/promises';
const addrs = await resolveDns.resolve(hostname);
const isPrivate = (ip) => /^(10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(ip);
if (addrs.some(isPrivate)) throw new Error('URL resolves to private network');Failure: a tool missing inputSchema.
Fix: same as tools-schema. No exceptions — even a zero-argument tool needs inputSchema: { type: "object" }.
Failure: a tool description contains SQL keywords or shell payloads.
Fix: this almost always means the description was accidentally populated from user-supplied data (a DB field, a README fetched from a URL). Hardcode descriptions or derive them from trusted sources.
Failure: Tool "read_file" description references "read_text_file".
Fix (advisory): avoid naming other tools in description strings — an LLM may get confused about which tool to use. If you have multiple related tools, explain their relationship in server-level instructions field, not inside individual tool descriptions.
See above. Don't skip even for stdio — limit tool calls per session to avoid runaway LLM behavior.
Failure: 3/5 rapid pings failed — framing likely broken.
Fix: emit exactly one JSON message per line on stdout, terminated by \n. Never split a message across lines, never merge messages onto one line. With Node's process.stdout:
process.stdout.write(JSON.stringify(msg) + '\n');Not console.log, which pretty-prints and may split.
Failure: tool output corrupted non-ASCII characters.
Fix: don't override Node's default stdout encoding (UTF-8). On Windows specifically, check chcp isn't set to a non-UTF-8 code page if you're spawning child processes.
Failure: server crashed or disconnected after receiving an unknown method.
Fix: per-method dispatch should go through a try/catch that emits JSON-RPC errors without tearing down the session:
try {
const result = await handlers[msg.method]?.(msg.params);
if (!result) throw { code: -32601, message: 'Method not found' };
send({ jsonrpc: '2.0', id: msg.id, result });
} catch (err) {
send({ jsonrpc: '2.0', id: msg.id, error: err.code ? err : { code: -32603, message: err.message } });
}
// loop continues; next line gets read- Re-run with
--verboseto see each test as it runs. - Use
--only <test-id>to iterate on one test at a time. - Compare before/after with
mcp-compliance diff baseline.json current.json. - File an issue on YawLabs/mcp-compliance if the test output doesn't clearly point at the fix. We treat opaque error messages as bugs in this tool.