Skip to main content

Testing and Debugging MCP

This guide explains the techniques and tools for testing MCP servers and clients and isolating problems. For how the protocol works, see MCP Protocol Basics - JSON-RPC and Stateless Design; for HTTP headers, see Transports - stdio and Streamable HTTP; for tool design, see Tool Design Patterns and Anti-patterns. This document focuses on one question: at which layer, and with which tool, do you confirm that what you built works correctly?

1. What This Document Covers​

  • MCP Inspector
  • Using it in CI
  • Unit-testing tool handlers
  • Validating schemas
  • Conformance tests
  • Reproducing with curl
  • Testing authorization
  • Debugging stdio
  • Evaluations that include an LLM
  • Security testing

MCP Inspector​

MCP Inspector is the reference tool for testing and debugging MCP servers. A single package, @modelcontextprotocol/inspector, provides three clients behind one binary. It requires Node 22.19.0 or later and runs directly through npx.

ClientInvocationPurpose
Web (default)npx @modelcontextprotocol/inspectorA graphical UI in the browser. The richest surface
CLInpx @modelcontextprotocol/inspector --cliA scriptable, machine-readable client for scripts, CI, and coding agents
TUInpx @modelcontextprotocol/inspector --tuiA terminal UI for when a browser is unavailable or unwanted

All three are built on a shared core, so transports, configuration files, OAuth state on disk, and negotiation of the protocol era (legacy or 2026-07-28) behave the same in every client. The package also includes the experimental mcpdo, which connects once and keeps the connection open across many commands.

# Launch a local stdio server and open it in the web UI
npx @modelcontextprotocol/inspector node path/to/server/index.js

# Point at a remote HTTP server
npx @modelcontextprotocol/inspector --server-url https://api.example.com/mcp --transport http

On launch, the web client prints a URL carrying a per-launch API token. Inspector stores OAuth tokens and stdio env: values in the OS keychain when one is available, and otherwise in ~/.mcp-inspector/secrets.json, which is not encrypted unless you supply a key. Be careful where secrets are stored on shared machines and in CI.

Protocol Era​

Because the 2026-07-28 revision changed the protocol substantially, Inspector treats the protocol era as a per-server setting independent of the transport. It has three values, legacy, auto, and modern; in the CLI and TUI, --protocol-era <legacy|auto|modern> overrides it for a single run.

ValueBehavior at connect
legacy (default)Plain initialize. No probing
autoProbe server/discover first, and fall back to initialize if the server is not recognized as modern
modernPin 2026-07-28. There is no fallback, so a legacy server fails explicitly

The default is not auto because a debugging tool must not send probes on its own. A server/discover probe stalls against legacy stdio servers that stay silent, and it pollutes the transcript you came to read. To check a modern server, you must choose modern or auto yourself. Do not conclude that a modern server "doesn't work" after connecting with the default.

The behavior of each tab in the web client also changes by era.

FeatureLegacyModern (2026-07-28)
LoggingSet per session with logging/setLevelAttach io.modelcontextprotocol/logLevel in _meta on each request
Resource subscriptionsresources/subscribesubscriptions/listen (stream status is shown as a badge)
Taskscapabilities.tasks, tasks/list, tasks/resultThe io.modelcontextprotocol/tasks extension. Polling tasks/get returns the result inline
Questions from the serverServer-to-client requestsinput_required results and retries (MRTR)
Cancellationnotifications/cancelledOn Streamable HTTP, closing that request's response stream
SessionsMcp-Session-Id and DELETENo sessions. Each request is self-contained

The Tools tab shows header mappings such as city -> Mcp-Param-City for tools that carry an x-mcp-header annotation, and shows the reason when a tool is excluded because its annotation is invalid. The Network tab shows the HTTP layer (mirrored Mcp-* headers highlighted), and the Protocol tab shows the JSON-RPC layer (-32020 HeaderMismatch, -32021 MissingRequiredClientCapability, and -32022 UnsupportedProtocolVersion rendered as typed errors). You can check the HTTP status and the JSON-RPC error as separate layers.

Using it in CI​

The CLI client connects, sends the single request named by --method, prints the result, and exits, all in one run. That suits CI jobs, shell pipelines, and coding agents.

# List the tools and exit
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list

# Call a tool on a remote HTTP server and pipe the result 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

The main methods are initialize (a connect-only check), tools/list, tools/call, resources/list, resources/read, prompts/list, and prompts/get. --format json emits a single JSON object on stdout with no banners, so it pipes cleanly. --tool-arg key=value converts a value to a number or other type when it parses as JSON, and sends it as a string otherwise. To keep a value a string, pass the whole argument object with --tool-args-json (the two flags cannot be combined).

Exit codes​

Each failure class has its own exit code so CI can branch on the cause.

CodeMeaning
0Success
1Usage error or unexpected error
3The server requires authentication (HTTP 401/403, authorization error)
4The server is unreachable (DNS, connection refused, timeout)
5Tool error. tools/call returned isError: true, or the tool was not found
6--strict found an error-severity tool-schema portability problem

There are also exit codes for the MCP Apps probe (--app-info) and Skills verification (--verify). On any non-zero exit, a single JSON line of the form {"error": {"code": "auth_required", ...}} is written to stderr, so it can be parsed mechanically with 2>&1 | tail -1 | jq .error. A tool result with isError: true prints its payload but exits with 5, so an && chain does not proceed past a failed call.

In CI it is important never to start an interactive OAuth flow. With --stored-auth-only, the CLI never starts interactive OAuth or step-up, never opens a browser, and fails immediately with auth_required if no stored token exists. Without it, if neither stdin nor stderr is a TTY, the CLI still fails fast with auth_required instead of waiting fifteen minutes for a callback.

set -euo pipefail

# Fail the build if the server is unreachable or does not expose the expected tool
mcp-inspector --cli --config ./ci-servers.json --server my-server \
--stored-auth-only --method tools/list --format json \
| jq -e '.result.tools | map(.name) | index("get_weather")' > /dev/null

tools/list --strict writes tool-schema portability problems (path, issue, suggested fix) to stderr and fails with exit code 6 if any is error-severity. Use it to make schema validation a CI gate.

Unit-testing tool handlers​

Most MCP testing is not about the protocol but about business logic. If you write the tool handler as a function that takes input and returns a result, separated from the protocol layer, you can verify it quickly with just the language's standard test framework.

  • Put business logic (calling an API, computing a value) in functions that know nothing about MCP types
  • Keep the tool registration a thin layer that calls that function and repackages the outcome into content / structuredContent / isError
  • Replace external APIs with fakes or test servers (httptest in Go) rather than mocks. See Testing in Go for the approach

For the protocol layer, confirm only a few representative calls with an SDK test client or Inspector's CLI.

Validating schemas​

A tool must have an inputSchema and may have an outputSchema. When an outputSchema is present, the server must return structured results that conform to it, and clients are recommended to validate. In tests, check the following.

  • inputSchema is a valid JSON Schema object (null is not allowed). For a tool with no parameters, { "type": "object", "additionalProperties": false } is recommended
  • A schema without $schema is treated as JSON Schema 2020-12
  • The structuredContent of an actual tool result conforms to outputSchema
  • A tool that returns structured content also puts the serialized JSON in a TextContent block, for compatibility
  • Apply x-mcp-header only to properties of primitive types (string, integer, boolean), never to number types or properties inside arrays. A Streamable HTTP client excludes a tool with an invalid annotation from tools/list

Including a test that runs a real tool result through a JSON Schema validation library catches drift between the schema and the implementation early.

Conformance tests​

Conformance to the specification is built into the standardization process for SEPs (specification enhancement proposals). Under SEP-2484, a Standards Track SEP that changes observable protocol behavior must have a conformance scenario merged into the conformance repository before it can move from Accepted to Final. The scenario comes with a traceability file (sep-NNNN.yaml) that maps each MUST / SHOULD requirement in the SEP's specification section to a check or a documented exclusion.

For implementers this means the following.

  • The conformance suite is not limited to official SDKs; community SDKs and custom implementations can also run it and report a compliance percentage
  • Where a test and the specification text disagree, the specification is authoritative and the test is treated as a bug
  • SDK tier assessments run against a pinned conformance release, not the tip of the repository

For how to run it and the CLI options, see the README and CONTRIBUTING of the conformance repository (modelcontextprotocol/conformance). When you bring your own SDK or gateway up to the modern era, adding this suite to your regression tests surfaces differences in interpretation of the specification early.

Reproducing with curl​

Streamable HTTP in the 2026-07-28 revision is stateless: each request completes as a single POST. You can therefore reproduce a problem directly with curl without establishing a session. You do, however, have to include all of the required headers and _meta fields.

  • Accept lists both application/json and text/event-stream
  • MCP-Protocol-Version: 2026-07-28. It must match io.modelcontextprotocol/protocolVersion in the body's _meta (a mismatch yields 400 and HeaderMismatch)
  • Mcp-Method: the JSON-RPC method
  • Mcp-Name: params.name for tools/call, params.uri for resources/read, and params.name for prompts/get
  • In the body's _meta: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities (required), and io.modelcontextprotocol/clientInfo (recommended)

If a header value cannot be safely represented as plain ASCII, encode it with Base64 in the form =?base64?...?=.

curl -sS -i https://example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: get_weather' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "Seattle, WA" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "curl", "version": "0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'

When the response is text/event-stream, notifications and then the final response arrive as SSE events (add -N to avoid buffering). The following responses help with diagnosis.

SymptomHTTPJSON-RPC errorExample cause
Headers and body disagree, or a required header is missing400-32020 HeaderMismatchMcp-Name does not match params.name
A required _meta field is missing400-32602protocolVersion or clientCapabilities is absent
A required client capability was not declared400-32021The server needs elicitation but clientCapabilities does not declare it. data.requiredCapabilities lists what is missing
A version the server does not support400-32022 UnsupportedProtocolVersiondata.supported lists the versions the server supports
An unimplemented method404-32601Method not found
Invalid Origin403(none, or a response without an id)Rejected as a DNS rebinding defense

Mcp-Session-Id, Last-Event-ID, and fetching a stream with GET are removed in this revision. A server that supports only the modern revision returns 405 to GET and DELETE and ignores the 2025-era headers. If a legacy procedure fails when you try it with curl, that does not necessarily mean the server is faulty.

To find out whether a server accepts both the legacy initialize and the modern era, send a modern request first. On 400, read the body: a recognized modern error means a modern server, while an empty body or an unrecognized error means a legacy server. If you only want to know which versions are supported, call server/discover.

Testing authorization​

For authorization, the failure paths matter more than the success path. See MCP Authorization - OAuth 2.1 and Metadata Discovery for the specification details; here are the points to verify.

  • A request without a token gets 401, and resource_metadata in the WWW-Authenticate header points to the protected resource metadata
  • A request with insufficient scope gets 403 (insufficient_scope), and the required scopes are returned in scope of WWW-Authenticate
  • A token issued for a different resource (a different audience) is rejected. Token passthrough is not allowed
  • Expired and revoked tokens get 401
  • tools/list returns results that depend on the authorization presented with the request (not on per-connection state)
  • Even for a tool that is not visible in the list, scopes and permissions are verified on every tools/call

In all of its clients, Inspector implements the whole OAuth flow (401, metadata discovery, client registration or identification, browser authorization, callback, token exchange and retry). When a 401 or 403 insufficient_scope comes back mid-session, it re-authorizes or steps up the scope and retries the failed request. The web client shows a confirmation dialog, and the CLI asks Proceed with step-up authorization? [y/N] on stderr. The connection info panel shows the authorization status, the type of client registration, and the granted scopes.

The OAuth callback for the CLI and TUI is http://127.0.0.1:6276/oauth/callback by default. For an IdP that requires pre-registered redirect URIs, register this value. To an authorization server, localhost and 127.0.0.1 are different URIs, so match exactly. The callback can bind only to a loopback host.

Debugging stdio​

A stdio server is launched by the client as a child process and communicates over standard input and output. The following points matter when debugging.

  • Never write logs to stdout: stdout is reserved for MCP messages. Mixing in print or console.log breaks the protocol. Write logs to stderr, and let the host application capture them
  • The working directory may be undefined: when launched from a client's configuration, the working directory can be something like / on macOS. Use absolute paths in configuration files and .env
  • Only some environment variables are inherited: the exact set is platform-dependent. Specify what you need explicitly through env in the client configuration
  • Startup failures: check, in order, the executable path, missing files, permissions, JSON syntax errors in the configuration, and missing environment variables. Try an absolute path for command
  • Restart the client after changing configuration or code: for Claude Desktop, closing the window is not enough; fully quit and reopen it

Claude Desktop writes logs (mcp*.log) to ~/Library/Logs/Claude on macOS and %APPDATA%\Claude\logs on Windows. Putting {"allowDevTools": true} in developer_settings.json lets you inspect client-side errors and message payloads in Chrome DevTools. For other clients, check each client's documentation for its log location.

When a connection fails, isolate the cause in this order.

  1. Check the client logs
  2. Check that the server process is running
  3. Launch the server on its own and connect with Inspector
  4. Call server/discover to see which protocol versions the server supports. UnsupportedProtocolVersionError (-32022) carries the supported versions in data
  5. Check that the request's _meta has the required io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. Without them, the request is rejected with -32602

For HTTP servers, stderr is not captured by the client. Use your own log infrastructure or OpenTelemetry (see MCP Observability and Auditing for details), together with curl or the browser DevTools Network panel.

Evaluations that include an LLM​

The tests so far have deterministic outcomes. Whether a tool actually gets used, on the other hand, depends on the model reading the tool's name and description and choosing it, which is not deterministic. Evaluate the quality of tool design with the model in the loop.

  • Prepare a set of evaluation prompts, and record whether the expected tool is selected and whether the arguments are built correctly
  • When you change a description or schema, rerun the same prompt set and compare how selection changed. Confirm the success rate did not drop between before and after
  • When you add tools with similar roles, check that another tool is not selected by mistake
  • Record result size (token count) as well. Tool definitions and results consume the model's context (The Context Bloat Problem and Mitigations)
  • Model output varies, so judge by the proportion across multiple runs, not the outcome of a single run

Keep evaluations in a form that can run in CI as a regression test whenever you add a tool or change a description. Evaluations that use a model cost time and money, so run them separately from unit and protocol tests, at a different frequency.

Security testing​

Check security from a different angle than functional tests. For the threats themselves, see AI Agent-Specific Threats - Prompt Injection and Tool Poisoning and MCP Security Best Practices - Attacks and Mitigations.

  • Tool poisoning: whether instructions for the model are hidden in tool descriptions or results. Use Inspector to read the tool list without going through a model
  • Excessive scopes: whether scopes_supported lists every scope. Whether you start from minimal scopes and can step up through WWW-Authenticate when needed
  • Input validation: whether a tool returns an error (isError: true) and does not crash on invalid types, out-of-range values, path traversal, and oversized input
  • Output handling: whether tool results contain secrets or internal information
  • Header and body mismatch: whether requests where Mcp-Name or Mcp-Param-* disagrees with the body are rejected with 400. In a setup where a gateway decides on headers while the server executes on the body, this rejection is a prerequisite
  • Origin validation: whether an invalid Origin is rejected with 403 (local HTTP servers)
  • SSRF: whether a client avoids connecting to internal addresses during authorization metadata discovery (for clients running server-side)

2. Test Levels​

LevelTargetTools and techniques
UnitTool logicThe language's standard test framework
ProtocolRequest and response formatMCP Inspector, curl, SDK test clients
AuthorizationMetadata, token validation, scopesInspector's authorization features, OAuth test clients
ConformanceCompliance with the specificationConformance tests
End to endWhether the LLM uses tools correctlyEvaluation prompts against a real model

The lower the level, the faster, cheaper, and more deterministic. Find most bugs in unit tests, limit protocol and authorization tests to representative paths and failure paths, and run model-based evaluations when you change tool definitions.

3. Common Pitfalls for Developers​

Inspector's default is not the modern era​

Inspector's default protocol era is legacy. If you try a 2026-07-28 modern server with legacy, initialize is rejected and you cannot connect. Specify --protocol-era modern (or auto). Conversely, if you make auto your default, probing can stall against legacy stdio servers.

Writing logs to stdout and breaking the protocol​

If a stdio server writes to standard output, the text mixes into the JSON-RPC messages and the client cannot parse them. Send debugging print output to stderr.

Forgetting a header or _meta in a modern curl call​

If any of Mcp-Method, MCP-Protocol-Version, Accept (both types), or the _meta in the body is missing, the request is rejected with 400. Adding the Mcp-Session-Id you used with the 2025 revision is simply ignored. Read the code in the error body to see what is missing.

Letting header and body values disagree​

Mcp-Name and params.name, and MCP-Protocol-Version and protocolVersion in _meta, must match. When you use the Base64 form (=?base64?...?=), the server decodes it before comparing with the body. Encoding is easy to get wrong when you assemble requests by hand.

An interactive OAuth flow starts in CI and the job hangs​

In an environment without a TTY, the CLI fails immediately with auth_required, but on a runner that has a TTY it keeps waiting for browser authorization. In CI, add --stored-auth-only and use credentials obtained in advance.

Treating a failed tool call as a success​

Even if tools/call returns isError: true, it is a success as JSON-RPC. The CLI returns exit code 5, but with an SDK test client you must check the result's isError yourself. Write expectations that distinguish protocol errors (unknown tool, malformed request) from tool execution errors (input validation, API failures).

Judging an evaluation by a single model output​

Evaluations that include a model vary even for the same input. Judge by the proportion across multiple runs and by the difference between before and after a change, not by a single run.

4. Code Examples​

Inspecting a server with Inspector's CLI​

# Check only that it connects (serverInfo, protocolVersion, capabilities)
mcp-inspector --cli node build/index.js --method initialize

# Check a modern remote server with the era pinned
mcp-inspector --cli https://api.example.com/mcp --transport http \
--protocol-era modern --method tools/list --format json | jq '.result.tools[].name'

# Call a tool that returns structured content (pass arguments as JSON)
mcp-inspector --cli https://api.example.com/mcp --transport http \
--method tools/call --tool-name get_weather \
--tool-args-json '{"location":"Seattle, WA"}' --format json

# Make schema portability a gate (exit code 6 on any error-severity finding)
mcp-inspector --cli node build/index.js --method tools/list --strict > /dev/null

A shell script that branches on exit codes​

if out=$(mcp-inspector --cli "$URL" --transport http \
--stored-auth-only --method tools/list 2>err.json); then
echo "$out" | jq '.result.tools | length'
else
case $? in
3) echo "Authentication needed: sign in once with the web Inspector first" ;;
4) echo "Server unreachable" ;;
*) jq .error < err.json ;;
esac
fi

Unit-testing a tool handler (Go)​

If the business logic lives in a function that knows nothing about MCP types, you can verify it with a table-driven test and no protocol at all.

package weather

import (
"context"
"errors"
"testing"
)

var ErrInvalidLocation = errors.New("location is required")

type Result struct{ Summary string }

type Client interface {
Fetch(ctx context.Context, location string) (Result, error)
}

// Forecast is the business logic. It does not depend on MCP types.
func Forecast(ctx context.Context, client Client, location string) (Result, error) {
if location == "" {
return Result{}, ErrInvalidLocation
}
return client.Fetch(ctx, location)
}

type fakeClient struct{ result Result }

func (f fakeClient) Fetch(context.Context, string) (Result, error) { return f.result, nil }

func TestForecast(t *testing.T) {
tests := []struct {
name string
location string
wantErr error
}{
{"valid", "Seattle, WA", nil},
{"empty input is an error", "", ErrInvalidLocation},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Forecast(context.Background(), fakeClient{}, tt.location)
if err != tt.wantErr {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
})
}
}

Make the registration side a thin layer that calls this function and repackages the error into an isError: true result (with wording the model can use to correct itself). Confirm that layer with one representative call through Inspector's CLI or an SDK test client.

A test that validates conformance to outputSchema (TypeScript)​

import Ajv2020 from "ajv/dist/2020";
import { expect, test } from "vitest";

test("structuredContent of get_weather conforms to outputSchema", async () => {
const tools = await client.listTools();
const tool = tools.tools.find((t) => t.name === "get_weather")!;
const result = await client.callTool({
name: "get_weather",
arguments: { location: "Seattle, WA" },
});

const validate = new Ajv2020().compile(tool.outputSchema!);
expect(validate(result.structuredContent)).toBe(true);
});

client refers to the test client of the SDK you use. Method names vary by SDK version, so check your SDK's documentation.

Checking the 401 of a protected resource​

# Call without a token and check WWW-Authenticate
curl -sS -i https://example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}' \
| grep -i -E '^(HTTP/|www-authenticate)'

Confirm that you get HTTP/... 401 and a WWW-Authenticate that includes resource_metadata.

5. Summary​

  • Split MCP testing into five levels: unit, protocol, authorization, conformance, and evaluation with a model. The lower the level, the faster and more deterministic, and most bugs are found there
  • Inspector provides Web, CLI, and TUI in one package. The default protocol era is legacy, so for a 2026-07-28 server specify --protocol-era modern or auto
  • In CI, use the CLI and decide by --format json and exit codes (3 for authentication, 4 for unreachable, 5 for tool error, 6 for schema). Use --stored-auth-only to stop interactive OAuth
  • Separate business logic from the protocol layer and verify it with ordinary unit tests. Confirm conformance to outputSchema with a test that validates real results
  • The modern era is a stateless POST, so it can be reproduced with curl. It needs Mcp-Method, Mcp-Name, MCP-Protocol-Version, and the required _meta fields, and a header and body mismatch is rejected with -32020
  • For authorization, focus on the failure paths (401, 403, audience mismatch, insufficient scope)
  • With stdio, reserve stdout for the protocol and write logs to stderr. The working directory and environment variables may not be inherited
  • Tool selection changes when a description changes. Make model-inclusive evaluation a regression test, judged by the proportion across multiple runs
  • Under SEP-2484, Standards Track SEPs that change observable behavior now require conformance tests. Your own implementation can also be checked with the conformance suite

References​