Skip to content

Testing and Debugging

Debugging through a chat window is slow and noisy: you cannot tell whether the model chose wrong, the client encoded wrong, or your server answered wrong. The fix is to talk to the server directly, with a client you control, and look at the JSON-RPC on the wire.

flowchart LR
  i["Inspector: web, cli, tui"] --> s["your server"]
  t["in-process test client"] --> s
  h["real host, e.g. Claude Code"] --> s
  s --> w["wire: JSON-RPC you can read"]
Three ways to drive a server

The Inspector is the reference developer tool. One package, three clients, one shared core, so a connection behaves identically in each. It needs Node 22.19.0 or newer and runs through npx with no install:

Terminal window
# Web UI (default): launches a browser tab with a one-time session token
npx @modelcontextprotocol/inspector node dist/index.js
# Or start empty and add servers from the UI
npx @modelcontextprotocol/inspector
# A published npm server
npx -y @modelcontextprotocol/inspector npx @modelcontextprotocol/server-filesystem ~/Desktop
# A PyPI server
npx @modelcontextprotocol/inspector uvx mcp-server-git --repository ~/code/repo
# A remote Streamable HTTP server
npx @modelcontextprotocol/inspector --server-url https://api.example.com/mcp --transport http

The web client has tabs for tools, resources, and prompts, an OAuth flow for protected servers, and a monitoring sidebar that shows every message as it happens. It negotiates protocol era automatically, so you can point it at a legacy 2025-11-25 server or a modern 2026-07-28 one.

--cli turns the same core into a scriptable client with machine-readable output:

Terminal window
# List tools and exit
npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/list
# Call a tool and pipe into jq
npx @modelcontextprotocol/inspector --cli https://api.example.com/mcp --transport http \
--method tools/call --tool-name get_weather --tool-arg city=Boston --format json | jq .result

This is the fastest smoke test you can put in a pipeline: exit code, JSON out, no browser.

Terminal window
npx @modelcontextprotocol/inspector --tui node dist/index.js

Same features, in the terminal, for SSH sessions and machines without a browser.

The mode flag must come first: --web (default), --cli, or --tui. Everything after the first non-mode token is forwarded to the client and then to your server, which is how a server can itself accept a literal --cli argument.

The Inspector is for exploring. For regression tests you want a client in the same process as the server, no ports, no subprocess, running under your normal test runner.

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
function createServer() {
const server = new McpServer({ name: 'pricing', version: '1.0.0' });
server.registerTool(
'apply-discount',
{
description: 'Apply a percentage discount to a price',
inputSchema: z.object({ price: z.number(), percent: z.number().min(0).max(100) }),
outputSchema: z.object({ total: z.number() }),
},
async ({ price, percent }) => {
if (price < 0) {
return { content: [{ type: 'text', text: 'price must be >= 0' }], isError: true };
}
const total = price * (1 - percent / 100);
return { content: [{ type: 'text', text: `$${total}` }], structuredContent: { total } };
},
);
return server;
}
test('discount math and error path', async () => {
const handler = createMcpHandler(createServer);
// Route the client's fetch straight into the handler — no network.
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init)),
});
const client = new Client({ name: 'test', version: '0.0.0' });
await client.connect(transport);
const ok = await client.callTool({ name: 'apply-discount', arguments: { price: 100, percent: 25 } });
assert.equal(ok.isError, undefined);
assert.deepEqual(ok.structuredContent, { total: 75 });
const bad = await client.callTool({ name: 'apply-discount', arguments: { price: -1, percent: 0 } });
assert.equal(bad.isError, true);
});

Both patterns exercise the real protocol path: the request is serialized, _meta is filled, the handler validates the schema, and the result comes back as the model would see it. Assert on isError, on structuredContent, and on the text — the text is what the model reads.

On stdio, stdout is the wire. One stray console.log or print() corrupts framing and the client sees a parse error, not your message. Rules:

  • Log to stderr only. The spec allows any UTF-8 on stderr; clients may capture, forward, or ignore it.
  • In Node, console.error goes to stderr; in Python, use logging with a StreamHandler(sys.stderr) or print(..., file=sys.stderr).
  • Third-party libraries that print to stdout must be silenced or redirected before the server starts.

On Streamable HTTP there is no such hazard, but the protocol’s own logging feature is now deprecated (see the last lesson). If a client wants per-request log messages, it opts in with io.modelcontextprotocol/logLevel in _meta; a server MUST NOT emit notifications/message for requests that did not include it. For everything else, use your platform’s logger.

The reserved _meta keys traceparent, tracestate, and baggage carry W3C Trace Context through MCP, so a tool call can join the same distributed trace as the HTTP request that triggered the agent:

{
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Lisbon" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
}
}
}

Read traceparent in your handler, start a child span, and your tool’s downstream calls show up under the agent’s trace. This follows the OpenTelemetry semantic conventions for MCP.

When a server “does nothing” or a client “cannot connect”, it is almost always one of these:

SymptomLikely causeCheck
Client reports JSON parse error on stdioSomething wrote to stdoutgrep for console.log / print( without file=sys.stderr
HTTP 400, error -32020 HeaderMismatchMcp-Method / Mcp-Name / MCP-Protocol-Version missing or differ from the bodyCompare headers with params.name and _meta
HTTP 400, error -32022 UnsupportedProtocolVersionClient sent a version the server does not speakRead error.data.supported and retry with one of them
HTTP 400, error -32602 and “missing _meta”Request lacks io.modelcontextprotocol/protocolVersion or clientCapabilitiesUse an SDK client, or add the fields by hand
HTTP 403Origin header present and not allowedSet the server’s allowed hosts / origins
HTTP 406 or hanging responseClient Accept does not list both application/json and text/event-streamFix the client’s headers
Tool “works in Inspector, fails from host”inputSchema too strict or model sends a string for a numberLoosen or coerce; add description to the property
structuredContent rejectedResult does not match outputSchemaValidate before returning; keep the text fallback
Legacy client gets 405 on GETModern-only server, client expects the old GET streamServe dual-era or tell the client to upgrade

Read the wire first. The Inspector’s monitor sidebar, or the CLI’s --format json, shows you exactly which of these it is.

What is the minimum Node version for the MCP Inspector?
How do you run a TypeScript server in-process for tests without opening a port?
A stdio server prints "starting…" with console.log. What happens?
A remote server answers HTTP 400 with JSON-RPC error code -32020. What does that mean?