Testing & Debugging

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the 2026-07-28 revision of the specification and the MCP Inspector 2.8.0 (@modelcontextprotocol/inspector, binary mcp-inspector, Node.js 22.19 or newer), as checked on 2026-09-29 (see the Model Context Protocol baseline). Every command, script and error message below was run against the three docs-assistant builds — Python mcp 2.2.0, TypeScript @modelcontextprotocol/server 2.2.0 and Spring Boot 4.1.1 with Spring AI 2.0.1 — on macOS with Node.js 24.20.0, Python 3.12 and JDK 25 building for Java 21. The Inspector’s web UI and TUI need a browser and a terminal, so for those two the page relies on the Inspector documentation and on --help output instead of a run.

An MCP server is an ordinary program with a very strict wire format, and most of its bugs are about that format: a header that does not match the body, a log line on the wrong stream, a capability the client never declared. The tools on this page are aimed at those bugs. The Inspector shows you exactly what crossed the wire, the contract script turns that into a repeatable pass or fail, the CI recipe runs it on every pull request, and the logging section tells you where to look when something fails in production.

The test pyramid of an MCP server

Most of an MCP server is business logic that has nothing to do with MCP, and that part deserves the ordinary unit tests of its language. What is specific to MCP is the contract between the server and any client: which tools exist, what their schemas promise, and how errors look. That layer is tested through the protocol, with a real client, and it is worth doing once for every build because it is what a host sees. On top of it sits a handful of manual or scripted checks in a real host.

flowchart TB A["Host smoke test (manual, few): Claude Code, VS Code, Copilot"] B["Contract tests through the protocol (this page): mcp-inspector --cli, curl for multi-round-trip, in CI"] C["Unit tests of the tool functions and the store (pytest, Vitest, JUnit) plus in-process client tests"] A --> B --> C
Figure 1. Test layers of an MCP server, from the fewest tests at the top to the most at the bottom

The same contract runs against every implementation, and that is the point of this sub-section: the Python, Java and TypeScript builds of docs-assistant are different programs that must be indistinguishable from the outside. Connecting them to real hosts is the subject of Connecting Hosts, and writing your own client code is on Building MCP Clients.

The MCP Inspector

The MCP Inspector is the reference developer tool for MCP servers. It is one npm package that provides three clients behind one binary, all sharing the same transports, the same configuration files, the same OAuth state on disk and the same protocol-era negotiation:

Client Invocation Use it for

Web

npx @modelcontextprotocol/inspector

A graphical client in the browser, and the default. Browse tools, resources and prompts, fill argument forms, and watch the JSON-RPC and HTTP traffic.

CLI

npx @modelcontextprotocol/inspector --cli

One request per run, machine-readable output and stable exit codes: shell scripts, CI and coding agents.

TUI

npx @modelcontextprotocol/inspector --tui

An interactive terminal interface for a remote host over SSH or a locked-down machine. It needs a real TTY, so it is not for CI.

The mcp-inspector binary is a thin launcher. It owns only the mode flag (--web, --cli or --tui, at most one, and only at the front of the command line) and -h/--help; everything after that is forwarded to the client unchanged. That is why mcp-inspector --help shows almost nothing while mcp-inspector --cli --help prints the full CLI flag reference. The examples below use an installed mcp-inspector; without a global install prefix each command with npx @modelcontextprotocol/inspector.

The web client

npx @modelcontextprotocol/inspector .venv/bin/python server.py       # a stdio server
npx @modelcontextprotocol/inspector --server-url http://127.0.0.1:8000/mcp --transport http

The launcher prints a URL that contains a one-time session token, and you open that URL. The token exists because the Node process behind the page can spawn processes on your machine, so every /api/* route is guarded by a per-launch token. Treat the token like a password: do not paste the URL into a ticket, a chat or a screen share, do not commit it to a script, and if you must pin one for a scripted launch set the MCP_INSPECTOR_API_TOKEN environment variable from a secret store instead of typing a value. The Inspector documents an environment switch that disables the check entirely; use it only on a machine where nothing else can reach the port. By default the web client binds localhost and accepts only loopback origins, and binding all interfaces is refused unless you explicitly opt in, which you should not do on a shared network.

The tab bar adapts to what the server declares. The ones you will use most:

  • Tools, Resources and Prompts render each schema as a form, call it, and show structured content and annotations.

  • Protocol is the JSON-RPC transcript; a multi-round-trip conversation is grouped as one exchange, and each specification error is shown by its class.

  • Network is the HTTP layer for Streamable HTTP servers: status codes, headers and bodies, with the Mcp-* headers highlighted. It is where you see a 421, a 406 or a 400 that never becomes a JSON-RPC message.

  • Console is the standard error of a stdio server, which is where its own logs should go (see Logging to stderr and OpenTelemetry).

  • Logs holds protocol log notifications; on the modern era it offers a per-request level instead of a session-wide one.

The web client is also a good place to complete an OAuth flow by hand: it stores the resulting tokens on disk, where the CLI can reuse them (see Authorization).

Protocol eras

The Inspector treats the protocol era as a per-server setting. legacy (before 2026-07-28: initialize and sessions) is the default, modern pins exactly 2026-07-28 with no fallback, and auto probes server/discover and falls back to initialize. A debugging tool does not probe by default because a probe can stall against a silent stdio server and pollutes the transcript you are trying to read. The consequence for scripts is important: an ad-hoc CLI run is legacy unless you pass --protocol-era modern, so every command on this page states its era. The Python and TypeScript builds serve both eras, while the Java SDK and Spring AI 2.0.x speak 2025-11-25 only and must be tested with --protocol-era legacy (see Spring Boot MCP Servers). Running the modern era against them fails as expected:

mcp-inspector --cli http://127.0.0.1:8080/mcp --transport http --protocol-era modern --method tools/list
# {"error":{"code":"error","message":"Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode)"}}

The CLI

Each CLI run connects, sends the single request named by --method, prints the result and exits. The server is a positional command for stdio, a URL with --transport http for Streamable HTTP, or a named entry of a catalog or config file (--config with --server). The flags that matter for testing, all confirmed with mcp-inspector --cli --help (2.8.0):

Flag Meaning

--method <method>

tools/list, tools/call, resources/list, resources/read, resources/templates/list, prompts/list, prompts/get, plus initialize and the catalog methods servers/list and servers/show. Session-only methods are rejected because a process that exits cannot hold a stream open.

--tool-name, --tool-args-json, --tool-arg key=value

Name and arguments of a tools/call. --tool-args-json passes the object verbatim; --tool-arg JSON-parses each value (012 becomes 12). They are mutually exclusive.

--uri, --prompt-name, --prompt-args key=value

Arguments of resources/read and prompts/get.

--transport <sse|http|stdio>

Auto-detected from a URL ending in /mcp (http) or /sse (sse), and from a command (stdio).

--protocol-era <legacy|auto|modern>

The era to negotiate. Ad-hoc runs default to legacy.

--format <text|json>

text pretty-prints; json emits one JSON object on stdout with no banners, which is what you pipe into jq.

--header "Name: Value"

An extra HTTP header, for example Authorization with a token read from an environment variable.

--metadata key=value

Adds a key to the request _meta of every method. It is how a test sends traceparent (see below).

--stored-auth-only, --use-stored-auth, --wait-for-auth <sec>, --list-stored-auth, --print-handoff, --relogin

Non-interactive OAuth control. --stored-auth-only never starts a browser flow and fails with auth_required unless a stored token exists; it is a no-op on a server that needs no authentication.

--connect-timeout <ms>

Connection timeout, 15000 by default for ad-hoc runs.

--strict

With tools/list, reports tool-schema portability problems on stderr and exits 6 if any is an error.

The CLI declares no elicitation capability, so a tool that asks the user a question cannot complete through it; that is why the contract script below tests the multi-round-trip step with curl.

Table 1. The exit codes of the Inspector CLI
Code Meaning Reproduced with

0

Success.

Any passing call.

1

Usage error or an unexpected error, including a JSON-RPC protocol error.

--method tools/call without --tool-name; a tool that needs elicitation.

3

The server requires authentication (401, 403 or an OAuth requirement).

A 401 with a WWW-Authenticate challenge and --stored-auth-only.

4

The server is unreachable: DNS, connection refused, timeout.

A closed port.

5

A tool error: tools/call returned isError: true, or the tool does not exist.

An unknown component, an unknown tool name.

On any non-zero exit the CLI writes one JSON line to stderr, {"error":{"code":"unreachable","message":"..."}} for instance, so a script can branch on code instead of scraping prose. A tools/call with isError: true still prints its payload on stdout and then exits 5, so a && chain stops there. The full reference is the Inspector’s CLI client page; the launcher, web, TUI, era and authorization pages are linked from the Inspector overview.

The shared contract test

The contract script is the centrepiece of this page and of the whole sub-section. It talks to a docs-assistant server the way any client would and checks the six promises that the three implementations make: the tool with its schema and annotations, a structured result, an error that does not crash, a resource template, a prompt, and the multi-round-trip elicitation. It is a plain bash script that needs the Inspector CLI, jq and, for step 6, curl.

contract.sh — the contract shared by the Python, Spring Boot and TypeScript servers
#!/usr/bin/env bash
# Contract test for the docs-assistant MCP server, shared by the Python, Spring Boot and TypeScript builds.
#   contract.sh <http-url>            Streamable HTTP
#   contract.sh -- <command> [args]   stdio (the Inspector spawns the command)
# Environment: INSPECTOR (default mcp-inspector), PROTOCOL_ERA (modern = 2026-07-28, legacy = 2025-11-25).
# Requires: the Inspector CLI, jq and, for step 6, curl.
set -euo pipefail

INSPECTOR="${INSPECTOR:-mcp-inspector}"
ERA="${PROTOCOL_ERA:-modern}"
if [ "${1:-}" = "--" ]; then shift; TARGET=("$@"); else TARGET=("${1:?usage: contract.sh <url> | -- <cmd>...}" --transport http); fi

run() { "$INSPECTOR" --cli "${TARGET[@]}" --protocol-era "$ERA" --format json "$@"; }
ok()  { echo "PASS $1"; }

# 1. tools/list: search_docs exists, is read-only and declares an outputSchema
run --method tools/list | jq -e '.result.tools[] | select(.name=="search_docs")
  | .outputSchema.properties.hits and .annotations.readOnlyHint == true and (.inputSchema.required | index("query"))' >/dev/null
ok "tools/list exposes search_docs with outputSchema and readOnlyHint"

# 2. tools/call with a component returns structuredContent
run --method tools/call --tool-name search_docs --tool-args-json '{"query":"matrix decomposition","component":"algebra","top_k":2}' \
  | jq -e '.result.structuredContent | .component=="algebra" and (.hits|length)>=1 and .hits[0].page=="matrices"' >/dev/null
ok "tools/call search_docs returns structuredContent"

# 3. an unknown component is a tool error: the CLI must exit with code 5, not crash or succeed
rc=0
run --method tools/call --tool-name search_docs --tool-args-json '{"query":"x","component":"nope"}' >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 5 ] || { echo "FAIL unknown component: expected exit code 5, got $rc"; exit 1; }
ok "unknown component fails with exit code 5"

# 4. resource template + read
run --method resources/templates/list | jq -e '.result.resourceTemplates[] | select(.uriTemplate=="docs://{component}/{page}")' >/dev/null
run --method resources/read --uri docs://algebra/matrices | jq -e '.result.contents[0].text | contains("Matrix decompositions")' >/dev/null
ok "resource template docs://{component}/{page} reads"

# 5. prompt
run --method prompts/list | jq -e '.result.prompts[] | select(.name=="answer_with_citations")' >/dev/null
run --method prompts/get --prompt-name answer_with_citations --prompt-args question=hello component=units \
  | jq -e '.result.messages[0].content.text | contains("docs://units/")' >/dev/null
ok "prompt answer_with_citations renders"

# 6. multi-round-trip elicitation, over raw HTTP because the Inspector CLI declares no elicitation capability.
#    Omit `component` -> input_required; echo requestState + inputResponses -> complete. Modern era, HTTP only.
if [ "$ERA" = "modern" ] && [ "${TARGET[1]:-}" = "--transport" ]; then
  URL="${TARGET[0]}"
  META='{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}},"io.modelcontextprotocol/clientInfo":{"name":"contract","version":"0"}}'
  post() {  # first JSON body of the response, whether it arrives as plain JSON or as one SSE event
    curl -s -X POST "$URL" -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: search_docs' -d "$1" \
      | sed -n 's/^data: //p;/^{/p' | head -1
  }
  R1=$(post "$(jq -nc --argjson m "$META" '{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:"search_docs",arguments:{query:"matrix"},_meta:$m}}')")
  echo "$R1" | jq -e '.result.resultType=="input_required" and (.result.inputRequests|to_entries[0].value.method=="elicitation/create")' >/dev/null
  KEY=$(echo "$R1" | jq -r '.result.inputRequests|keys[0]'); STATE=$(echo "$R1" | jq -r '.result.requestState // empty')
  R2=$(post "$(jq -nc --argjson m "$META" --arg k "$KEY" --arg s "$STATE" \
    '{jsonrpc:"2.0",id:2,method:"tools/call",params:({name:"search_docs",arguments:{query:"matrix"},inputResponses:{($k):{action:"accept",content:{component:"algebra"}}}}
      + (if $s=="" then {} else {requestState:$s} end) + {_meta:$m})}')")
  echo "$R2" | jq -e '.result.resultType=="complete" and .result.structuredContent.component=="algebra"' >/dev/null
  ok "MRTR: input_required then complete"
fi
echo "ALL PASS"

The script prints PASS lines and exits 0, or stops at the first failing step: set -e and jq -e (which exits non-zero when its expression is false or null) do all the asserting. It derives from the Inspector’s own CLI recipes and from the raw walkthrough on Transports.

The script step by step

  • Setup. TARGET is either the URL plus --transport http, or everything after -- as a stdio command. run wraps every Inspector call with the era and --format json, so each step is one line. The era defaults to modern; the Java build sets PROTOCOL_ERA=legacy.

  • Step 1, the tool and its schema. tools/list must contain search_docs with an outputSchema that declares hits, readOnlyHint set to true (an untrusted hint, but the build must state it) and a required query. A regression that drops the output schema or forgets the annotation fails here.

  • Step 2, structured content. A call with component returns structuredContent whose first hit is the matrices page. It proves argument passing, the stubbed or real index, and that the structured result matches the schema.

  • Step 3, errors are results. An unknown component must come back as a tool error, and the script checks the exit code is exactly 5: a crash would be 1, a success 0, and a protocol error 1. The model reads a tool error and can retry; see Tool Design for Agents.

  • Step 4, resources. The template docs://{component}/{page} is listed and a concrete URI reads back the page text.

  • Step 5, prompts. answer_with_citations is listed and renders with its arguments, citing docs://units/ pages.

  • Step 6, multi-round-trip elicitation. A call without component must return resultType: "input_required" with an elicitation/create request; the script echoes the server’s requestState and answers the elicitation with inputResponses, and the retry must be complete. It uses curl, with the per-request _meta envelope and the Mcp-Method and Mcp-Name headers spelled out, because the Inspector CLI declares no elicitation capability: calling the same tool with it fails with Client did not declare the form elicitation capability required by resolver …​ (exit 1). The step runs only for the modern era over HTTP; Java has no multi-round-trip support and makes component a required argument.

sequenceDiagram participant J as CI job or shell participant S as contract.sh participant I as mcp-inspector --cli participant M as docs-assistant server J->>S: contract.sh url loop steps 1 to 5 S->>I: --method X --format json I->>M: one request M-->>I: result I-->>S: JSON on stdout, exit code S->>S: jq -e assertion end S->>M: step 6: curl input_required M-->>S: resultType input_required and requestState S->>M: curl retry with inputResponses M-->>S: resultType complete S-->>J: ALL PASS, exit 0
Figure 2. The flow of the contract script

Results

The script was run on 2026-09-29 against freshly started servers, with these outcomes:

Server Command Result

Python mcp v2, stdio

./contract.sh — .venv/bin/python server.py

5 of 5

Python mcp v2, Streamable HTTP

./contract.sh http://127.0.0.1:8000/mcp

6 of 6

Python mcp v2 mounted in FastAPI

./contract.sh http://127.0.0.1:8001/mcp

6 of 6

TypeScript, Streamable HTTP (Express)

./contract.sh http://127.0.0.1:3000/mcp

6 of 6

TypeScript, stdio

./contract.sh — npx tsx src/stdio.ts

5 of 5

Spring Boot STREAMABLE

PROTOCOL_ERA=legacy ./contract.sh http://127.0.0.1:8080/mcp

5 of 5

Spring Boot STATELESS

PROTOCOL_ERA=legacy ./contract.sh http://127.0.0.1:8081/mcp

5 of 5

The stdio runs have five steps because step 6 needs HTTP, and the two Spring runs have five because they use the legacy era, where the multi-round-trip step is skipped. The standalone FastMCP 4 build on Python MCP Servers is not part of this table. The server pages show how each build is started: Python, Python in FastAPI, Node.js and Spring Boot. Point INSPECTOR at a local node_modules/.bin/mcp-inspector when the Inspector is not installed globally.

Tests below the contract

The contract needs a running server. The layer under it does not, and it is much faster. The Python SDK’s Client accepts the MCPServer object itself and connects to it in-process, with no subprocess and no port:

test_server.py — an in-process test of the Python server
import anyio

from mcp import Client

from server import mcp


async def main() -> None:
    # In-process: the same MCPServer object the real transports serve.
    async with Client(mcp) as client:
        tools = await client.list_tools()
        assert [t.name for t in tools.tools] == ["search_docs"]
        result = await client.call_tool("search_docs", {"query": "matrix", "component": "algebra"})
        assert not result.is_error
        assert result.structured_content["hits"][0]["page"] == "matrices"
        print("in-process test passed")


anyio.run(main)

This ran against mcp 2.2.0 and printed in-process test passed. The Client docstring says this in-process form is meant for tests. See the Python SDK documentation. For the Java build, @SpringBootTest with a random port and the MCP Java SDK client is the equivalent, in the style of Unit and Integration Testing; the TypeScript SDK ships a client that connects over stdio or Streamable HTTP for the same purpose (see Building MCP Clients).

A stdio server has one more property that the Inspector will not catch, because it tolerates stray lines on stdout: nothing but JSON-RPC may be written there. This script sends one request and fails if any stdout line is not JSON. It passed for the Python and TypeScript stdio servers and failed, naming the offending line, for a copy of the Python server with one print() added.

stdout-clean.sh — fail when a stdio server writes anything but JSON to stdout
#!/usr/bin/env bash
# usage: stdout-clean.sh <command> [args...]
set -euo pipefail
REQ='{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
bad=0
while IFS= read -r line; do
  printf '%s\n' "$line" | jq -e . >/dev/null 2>&1 || { echo "stdout is not JSON: $line" >&2; bad=1; }
done < <( (echo "$REQ"; sleep 3) | "$@" 2>/dev/null )
exit "$bad"

A CI recipe

The recipe starts the Python server, waits until it answers, runs the contract, makes one focused assertion with --cli --method tools/call --format json and jq -e, and prints the server log even when a step fails. It is a GitHub Actions job; the action versions are the ones this site’s own workflows use for checkout and Node.js. See CI/CD with GitHub Actions for the workflow syntax.

mcp-contract.yml — a GitHub Actions job that runs the contract
name: mcp-contract
on:
  pull_request:
  push:
    branches: [main]

jobs:
  contract:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      MCP_URL: http://127.0.0.1:8000/mcp
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: 24          # the Inspector needs Node 22.19 or newer
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install the server and the Inspector
        run: |
          pip install -r requirements.txt
          npm install --global @modelcontextprotocol/inspector@2.8.0

      - name: Start the server
        run: python server.py streamable-http > server.log 2>&1 &

      - name: Wait until it answers
        run: |
          for i in $(seq 1 30); do
            mcp-inspector --cli "$MCP_URL" --transport http --protocol-era modern \
              --method tools/list >/dev/null 2>&1 && exit 0
            sleep 1
          done
          echo "server did not become ready" >&2; cat server.log >&2; exit 1

      - name: Contract test
        run: ./contract.sh "$MCP_URL"

      - name: Assert one tool call
        run: |
          set -euo pipefail
          mcp-inspector --cli "$MCP_URL" --transport http --protocol-era modern --format json \
            --method tools/call --tool-name search_docs \
            --tool-args-json '{"query":"matrix decomposition","component":"algebra","top_k":1}' \
            | jq -e '.result.isError == false and .result.structuredContent.hits[0].page == "matrices"'

      - name: Server log
        if: always()
        run: cat server.log

The workflow parses as valid YAML and its commands are the ones that were run locally: the readiness loop returns as soon as the server answers and keeps failing quietly against a closed port, the assertion printed true and exited 0, and the same call for an unknown component printed its payload and exited 5. Two details are worth explaining. The readiness probe uses the Inspector itself rather than curl, so "ready" means "answers MCP", not "the port is open". And set -o pipefail (part of set -euo pipefail) matters in the assertion step, because without it the exit code of a pipeline is the exit code of jq alone and a failing Inspector call could go unnoticed if jq still succeeded on the error JSON.

Branching on the failure class

A script that must react differently to an unreachable server and to a tool error uses the exit code and the single JSON line on stderr:

if out=$(mcp-inspector --cli "$MCP_URL" --transport http --protocol-era modern --method tools/list 2>err.json); then
  echo "$out"
else
  case $? in
    3) echo "needs auth: sign in once with the web Inspector, or pass a token" ;;
    4) echo "server unreachable" ;;
    5) echo "tool error" ;;
    *) jq .error < err.json ;;
  esac
fi

Run against a closed port, this printed unreachable (the error object was {"code":"unreachable","message":"Version negotiation probe failed: fetch failed"}, with exit 4).

Servers that need authentication

Where the server sits behind OAuth, use --stored-auth-only so that a CI run can never start a browser flow and hang: with no usable token the CLI fails fast with auth_required and exit 3, and with a token it proceeds. Two ways to supply the credential without putting a secret in the repository are a token that was already obtained on the runner and stored by the Inspector, or a short-lived token that the job requests from your identity provider and passes in an environment variable:

mcp-inspector --cli "$MCP_URL" --transport http --protocol-era modern --stored-auth-only \
  --header "Authorization: Bearer ${MCP_TEST_TOKEN}" --method tools/list --format json

MCP_TEST_TOKEN comes from the repository’s secret store (in a workflow, a secrets expression mapped to env), is never echoed, and belongs to a test client with the least scope needed. Against a stub that answered every request with 401, this command sent the Authorization header and exited 3; a real token-issuing setup is described on Authorization. Do not reuse a token that was issued for another service: an MCP server must accept only tokens issued for itself. The CLI also offers --print-handoff, --wait-for-auth and --use-stored-auth for the case where a human completes the browser flow on another machine; they are described on the Inspector’s Authorization page.

Logging to stderr and OpenTelemetry

Two rules cover most of MCP logging. On stdio, a server writes its own logs to standard error and never to standard output, because standard output is the protocol stream. On Streamable HTTP, standard error is not seen by the client at all, so logs go to your normal log aggregation and traces go through OpenTelemetry. The 2026-07-28 revision supports both directly:

  • Trace context travels in the request _meta. The keys traceparent, tracestate and baggage carry the W3C trace context and baggage, as the specification’s own example shows, so a span that started in the host continues through the MCP server. There is no connection identity to correlate on any more, so this is the supported way to follow one user action. See the _meta section of the specification and the OpenTelemetry semantic conventions for MCP.

  • Protocol logging is opt-in per request and deprecated. logging/setLevel is gone; a client that wants server log notifications stamps io.modelcontextprotocol/logLevel on the request, and a server must not emit notifications/message for a request without it. The whole logging feature is deprecated as of 2026-07-28 and stays available for a deprecation window of at least twelve months.

The per-request opt-in was reproduced with a tool that calls ctx.info() (which the Python SDK marks deprecated for this reason). Without the key the response was a single JSON body; with "io.modelcontextprotocol/logLevel": "debug" in _meta the server answered with an SSE stream that carried the log notification before the result:

event: message
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"indexing started"}}

event: message
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"done","type":"text"}],"isError":false,"resultType":"complete", ... }}

Treat that channel as a debugging aid, and treat stderr and OpenTelemetry as the production mechanism. The rest of this section shows each stack doing that, and every snippet below was run through the Inspector with a traceparent supplied by --metadata traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01 (the value is the specification’s example).

Python

The Python SDK emits an OpenTelemetry server span for every request when a tracer provider is configured, and it continues the trace from the traceparent in _meta. In a run with a console exporter, the tools/call search_docs span carried the parent span id 00f067aa0ba902b7 from the header, with the attributes mcp.method.name and gen_ai.tool.name. The server code only has to configure the provider, keep logs on stderr, and add its own child spans:

logdemo.py — stderr logging with trace ids and OpenTelemetry spans
import logging
import sys

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor

from mcp.server import MCPServer

import store


class TraceIdFilter(logging.Filter):
    """Stamp every log record with the active trace id so logs and traces can be joined."""

    def filter(self, record: logging.LogRecord) -> bool:
        ctx = trace.get_current_span().get_span_context()
        record.trace_id = format(ctx.trace_id, "032x") if ctx.is_valid else "-"
        return True


# stdio servers own stdout for the protocol: every log line goes to stderr.
handler = logging.StreamHandler(sys.stderr)
handler.addFilter(TraceIdFilter())
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s trace=%(trace_id)s %(name)s %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler])
log = logging.getLogger("docs-assistant")

# Any exporter works here (OTLP in production); the console exporter writes to stderr for the demo.
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter(out=sys.stderr)))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("docs-assistant")

mcp = MCPServer("docs-assistant", version="1.0.0")


@mcp.tool()
async def search_docs(query: str, component: str) -> list[str]:
    """Search one component and return page names."""
    with tracer.start_as_current_span("retrieval"):  # child of the SDK's tools/call span
        log.info("search_docs component=%s query_len=%d", component, len(query))
        return [h["page"] for h in store.search(query, component, 3)]


if __name__ == "__main__":
    mcp.run("stdio")

The run printed only the JSON result on stdout and, on stderr, the log line INFO trace=0af7651916cd43dd8448eb211c80319c docs-assistant search_docs component=algebra query_len=6 together with the spans. It needs opentelemetry-api and opentelemetry-sdk (1.45.0 here) next to mcp. Because the stdout of a stdio server is the protocol, logging.basicConfig() alone would also be safe, since the standard library logs to stderr by default; the explicit handler is there to attach the trace id. See the OpenTelemetry Python documentation.

TypeScript

The TypeScript SDK exposes the request’s _meta as ctx.mcpReq._meta, with the reserved io.modelcontextprotocol/* keys already lifted out, so traceparent is readable there. The installed @modelcontextprotocol packages contain no OpenTelemetry references, so the server continues the trace itself, with the OpenTelemetry API’s propagator:

src/telemetry.ts — trace propagation from _meta and JSON logs on stderr
import { context, propagation, trace, type Span } from '@opentelemetry/api';
import { ExportResultCode } from '@opentelemetry/core';
import { SimpleSpanProcessor, type ReadableSpan, type SpanExporter } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';

// ConsoleSpanExporter prints to stdout, which corrupts a stdio server; this demo exporter writes to stderr.
// In production use an OTLP exporter instead.
const stderrExporter: SpanExporter = {
    export(spans: ReadableSpan[], done) {
        for (const s of spans) {
            console.error(JSON.stringify({ span: s.name, traceId: s.spanContext().traceId, parentSpanId: s.parentSpanContext?.spanId }));
        }
        done({ code: ExportResultCode.SUCCESS });
    },
    shutdown: async () => {}
};

// register() installs the W3C propagator and an async-hooks context manager.
new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(stderrExporter)] }).register();

const tracer = trace.getTracer('docs-assistant');

/** Run fn inside a span that continues the trace carried in the request's _meta. */
export function withMcpSpan<T>(name: string, meta: Record<string, unknown> | undefined, fn: (span: Span) => T): T {
    const carrier: Record<string, string> = {};
    for (const key of ['traceparent', 'tracestate', 'baggage']) {
        const value = meta?.[key];
        if (typeof value === 'string') carrier[key] = value;
    }
    const parent = propagation.extract(context.active(), carrier);
    return tracer.startActiveSpan(name, {}, parent, span => {
        try {
            return fn(span);
        } finally {
            span.end();
        }
    });
}

/** One JSON line per event, on stderr: stdout belongs to the protocol on stdio servers. */
export function log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields: Record<string, unknown> = {}): void {
    const ctx = trace.getActiveSpan()?.spanContext();
    console.error(JSON.stringify({ ts: new Date().toISOString(), level, message, traceId: ctx?.traceId, ...fields }));
}
src/logdemo.ts — a tool that uses them
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { search } from './store.js';
import { log, withMcpSpan } from './telemetry.js';

function create(): McpServer {
    const server = new McpServer({ name: 'docs-assistant', version: '1.0.0' });
    server.registerTool(
        'search_docs',
        { description: 'Search one component.', inputSchema: z.object({ query: z.string(), component: z.string() }) },
        async ({ query, component }, ctx) =>
            withMcpSpan('tools/call search_docs', ctx.mcpReq._meta, () => {
                log('info', 'search_docs', { component, queryLength: query.length });
                const pages = search(query, component, 3).map(h => h.page);
                return { content: [{ type: 'text' as const, text: pages.join(',') }] };
            })
    );
    return server;
}

serveStdio(create);

Install @opentelemetry/api, @opentelemetry/core, @opentelemetry/sdk-trace-base and @opentelemetry/sdk-trace-node (1.9.1 for the API and 2.11.0 for the others in this run) next to the MCP packages. The files type-check with tsc --noEmit. Through the Inspector the log line carried traceId 0af7651916cd43dd8448eb211c80319c and the exported span had parentSpanId 00f067aa0ba902b7, with only the JSON result on stdout. The default ConsoleSpanExporter of the OpenTelemetry SDK prints to standard output, which is exactly the mistake a stdio server cannot afford, so the demo exporter writes to stderr instead.

Spring Boot

Spring Boot logs to the console, which is standard output. On a stdio Spring server that would corrupt the protocol, and it did: without configuration the server wrote its log lines on stdout. The cure is either logging.threshold.console=off (see Spring Boot MCP Servers) or a Logback configuration whose console appender targets System.err. The second keeps the logs, and a tool method can receive the request’s _meta as an McpMeta parameter (a Spring AI annotation-package type whose get(String) reads a key) and copy traceparent into the logging MDC:

src/main/resources/logback-spring.xml — Spring logs on stderr with the trace context
<configuration>
    <!-- stdio servers own stdout for the protocol: send every log line to stderr. -->
    <appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
        <target>System.err</target>
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level [traceparent=%X{traceparent:--}] %logger{20} - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="STDERR"/>
    </root>
</configuration>
DocsTools.java — read traceparent from the request _meta
@McpTool(name = "search_docs",
        description = "Search the documentation of one component and return the best matching pages.",
        generateOutputSchema = true,
        annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false))
public DocStore.SearchResult searchDocs(
        @McpToolParam(description = "Free-text query", required = true) String query,
        @McpToolParam(description = "algebra, units or statistics", required = true) String component,
        @McpToolParam(description = "Maximum number of hits", required = false) Integer topK,
        McpMeta meta) {
    // _meta.traceparent is not an argument: McpMeta injects the request's _meta map
    Object traceparent = meta.get("traceparent");
    MDC.put("traceparent", traceparent == null ? "-" : traceparent.toString());
    try {
        log.info("search_docs component={} queryLength={}", component, query.length());
        return doSearch(query, component, topK);
    } finally {
        MDC.remove("traceparent");
    }
}

Started in stdio mode through a wrapper script (passing Spring’s own -- options directly after java -jar made the Inspector hand its trailing flags to java, which printed its usage text, so a run.sh wrapper that holds the whole command is the reliable form), the server wrote every log line to stderr, and the line for the tool call read INFO [traceparent=00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01] d.e.d.DocsTools - search_docs component=algebra queryLength=6, while stdout carried only the JSON result. The McpSyncRequestContext type also offers debug, info, warn and error; those send protocol log notifications, which belong to the deprecated channel, so prefer SLF4J. For real tracing, Spring Boot’s own Micrometer-based tracing can export to an OpenTelemetry collector, and the same traceparent value is what a bridge would read from McpMeta; that setup was not run, so only the MDC correlation above is shown. The Java SDK speaks protocol 2025-11-25, where _meta carries what the client puts there; hosts that support trace propagation send traceparent in it. See the Spring AI MCP server annotations and the Logback ConsoleAppender documentation. The agent-level view of the same data is on Observability for Agents.

Logs and traces must never contain secrets. Log the tool name, the sizes and identifiers of arguments, and the outcome; do not log bearer tokens, Authorization headers or whole arguments that may carry personal data. The web Inspector masks secrets in its own views, but your server’s log files do not.

Common errors and fixes

Everything in this table was reproduced against the servers of this sub-section on 2026-09-29, except the last two rows, which come from the notes of the server pages and the specification and are marked.

Symptom Cause Fix

HTTP 421 Misdirected Request, body Invalid Host header (Python HTTP)

The Python server allows only localhost Host headers by default (DNS-rebinding protection). A request with Host: docs.example.com got 421.

Behind a real hostname pass transport_security= with an allowlist of the names you serve. The TypeScript createMcpExpressApp() applies the same kind of check on localhost.

HTTP 500 Internal Server Error; log line RuntimeError: Task group is not initialized. Make sure to use run().

A Python MCP app mounted in FastAPI without the lifespan that starts the session manager. The Inspector reports it as Version negotiation failed: the server answered the probe with HTTP 500.

Wrap the app in a lifespan that does async with mcp.session_manager.run(), or use the combined-lifespan helper of standalone FastMCP; see Python FastAPI Integration.

HTTP 406 Not Acceptable with an empty body

The request did not accept both application/json and text/event-stream. A Streamable HTTP client must list both in Accept.

Send Accept: application/json, text/event-stream, as the contract script does.

-32020 HeaderMismatch, 400, message mcp-method header does not match the request body’s method

The Mcp-Method or Mcp-Name header disagrees with the body. A gateway or a hand-written client rewrote one of them.

Send headers that mirror the body exactly (see Transports); make gateways route on the header but never modify it.

-32021 MissingRequiredClientCapability, data requiredCapabilities

The request’s clientCapabilities did not declare something the tool needs, for example elicitation with form.

Declare the capability in _meta; in the Inspector CLI, which cannot, test the tool with curl as in step 6.

-32022 UnsupportedProtocolVersion, 400, data supported and requested

The request named a protocol version the server does not serve. The Python HTTP server answered "supported":["2026-07-28"] for 1999-01-01.

Read data.supported and retry with one of the versions; server/discover lists them too.

Inspector CLI: Client did not declare the form elicitation capability required by resolver …​ (exit 1)

A tool elicits through the multi-round-trip mechanism and the CLI declares no elicitation capability.

Call the tool with component supplied, or test the elicitation branch with curl.

-32601 Session ID missing (Spring STREAMABLE) or -32603 (Spring STATELESS) for server/discover; Inspector: the server did not offer pinned protocol version 2026-07-28

The Java SDK and Spring AI 2.0.x speak protocol 2025-11-25 and do not implement server/discover.

Test Java servers with --protocol-era legacy (PROTOCOL_ERA=legacy in the script); expect no multi-round-trip step.

Exit code 5 from the Inspector CLI

isError: true in the result, or a tool name the server does not list (tool_not_found).

Read the payload on stdout and the JSON line on stderr; in CI, decide whether 5 is a failure or the expected result of a negative test, as step 3 does.

Exit code 3, auth_required

The server answered 401 or 403 and there is no stored token or no interactive TTY.

Sign in once with the web Inspector, or pass a short-lived token in a header, and use --stored-auth-only in CI.

Exit code 4, unreachable

Nothing is listening: the server did not start yet, crashed, or the URL or port is wrong.

Use a readiness loop in CI and print the server log when it times out.

A stdio server that prints or logs to standard output

Standard output is the protocol stream; a stray print(), console.log(), or Spring’s default console logging corrupts it. The Inspector tolerated one stray line in a run, so it may still pass while another host fails.

Log to stderr only, and run stdout-clean.sh. In Spring, use the Logback System.err appender or logging.threshold.console=off, plus spring.main.banner-mode=off.

No module named mcp.server.request_state when starting FastMCP 4 (from the notes of the server pages)

fastapi-mcp and langchain-mcp-adapters pin mcp below 2, and installing either into an environment with mcp 2 downgrades it to 1.30.0.

Use a separate virtual environment for them; see Python MCP Servers.

A TypeScript server type-checks but tools/list fails at runtime (from the notes of the server pages)

Zod 3 is still installed; the v2 packages need Zod 4.2 or newer.

Upgrade Zod and import from zod/v4; see Node.js MCP Servers.

When none of these matches, work in this order: reproduce the failure with mcp-inspector --cli --format json so that you have the exact request and the error JSON; run the modern era and the legacy era to see whether it is a negotiation problem; open the web client’s Network and Protocol tabs to compare the HTTP layer with the JSON-RPC layer; then read the server’s stderr or log aggregation for the trace id of the failing request. The broader guidance, including how to read the logs of an MCP host such as Claude Desktop, is in the MCP debugging guide.

Where to go next

  • Deploying Remote Servers puts the servers behind a gateway and a load balancer, where these tests run against the real URL.

  • MCP Security covers what a test suite should also assert: input validation, least privilege and human approval of destructive tools.

  • Connecting Hosts takes a server that passes the contract into Claude Code, Claude Desktop and VS Code.

  • Authorization explains the OAuth flow that --stored-auth-only and --header rely on.

  • Model Context Protocol is the sub-section overview.