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.
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 |
|
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 |
|
One request per run, machine-readable output and stable exit codes: shell scripts, CI and coding agents. |
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 a421, a406or a400that 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 |
|---|---|
|
|
|
Name and arguments of a |
|
Arguments of |
|
Auto-detected from a URL ending in |
|
The era to negotiate. Ad-hoc runs default to |
|
|
|
An extra HTTP header, for example |
|
Adds a key to the request |
|
Non-interactive OAuth control. |
|
Connection timeout, 15000 by default for ad-hoc runs. |
|
With |
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.
| Code | Meaning | Reproduced with |
|---|---|---|
|
Success. |
Any passing call. |
|
Usage error or an unexpected error, including a JSON-RPC protocol error. |
|
|
The server requires authentication ( |
A |
|
The server is unreachable: DNS, connection refused, timeout. |
A closed port. |
|
A tool error: |
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.
#!/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.
TARGETis either the URL plus--transport http, or everything after--as a stdio command.runwraps every Inspector call with the era and--format json, so each step is one line. The era defaults tomodern; the Java build setsPROTOCOL_ERA=legacy. -
Step 1, the tool and its schema.
tools/listmust containsearch_docswith anoutputSchemathat declareshits,readOnlyHintset totrue(an untrusted hint, but the build must state it) and a requiredquery. A regression that drops the output schema or forgets the annotation fails here. -
Step 2, structured content. A call with
componentreturnsstructuredContentwhose first hit is thematricespage. 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 be1, a success0, and a protocol error1. 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_citationsis listed and renders with its arguments, citingdocs://units/pages. -
Step 6, multi-round-trip elicitation. A call without
componentmust returnresultType: "input_required"with anelicitation/createrequest; the script echoes the server’srequestStateand answers the elicitation withinputResponses, and the retry must becomplete. It usescurl, with the per-request_metaenvelope and theMcp-MethodandMcp-Nameheaders spelled out, because the Inspector CLI declares no elicitation capability: calling the same tool with it fails withClient did not declare the form elicitation capability required by resolver …(exit1). The step runs only for the modern era over HTTP; Java has no multi-round-trip support and makescomponenta required argument.
Results
The script was run on 2026-09-29 against freshly started servers, with these outcomes:
| Server | Command | Result |
|---|---|---|
Python |
|
5 of 5 |
Python |
|
6 of 6 |
Python |
|
6 of 6 |
TypeScript, Streamable HTTP (Express) |
|
6 of 6 |
TypeScript, stdio |
|
5 of 5 |
Spring Boot |
|
5 of 5 |
Spring Boot |
|
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:
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.
#!/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.
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 keystraceparent,tracestateandbaggagecarry 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_metasection of the specification and the OpenTelemetry semantic conventions for MCP. -
Protocol logging is opt-in per request and deprecated.
logging/setLevelis gone; a client that wants server log notifications stampsio.modelcontextprotocol/logLevelon the request, and a server must not emitnotifications/messagefor 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:
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:
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 }));
}
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:
<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>
@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 |
|---|---|---|
|
The Python server allows only localhost |
Behind a real hostname pass |
|
A Python MCP app mounted in FastAPI without the lifespan that starts the session manager. The Inspector reports it as |
Wrap the app in a lifespan that does |
|
The request did not accept both |
Send |
|
The |
Send headers that mirror the body exactly (see Transports); make gateways route on the header but never modify it. |
|
The request’s |
Declare the capability in |
|
The request named a protocol version the server does not serve. The Python HTTP server answered |
Read |
Inspector CLI: |
A tool elicits through the multi-round-trip mechanism and the CLI declares no elicitation capability. |
Call the tool with |
|
The Java SDK and Spring AI 2.0.x speak protocol 2025-11-25 and do not implement |
Test Java servers with |
Exit code |
|
Read the payload on stdout and the JSON line on stderr; in CI, decide whether |
Exit code |
The server answered |
Sign in once with the web Inspector, or pass a short-lived token in a header, and use |
Exit code |
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 |
Log to stderr only, and run |
|
|
Use a separate virtual environment for them; see Python MCP Servers. |
A TypeScript server type-checks but |
Zod 3 is still installed; the v2 packages need Zod 4.2 or newer. |
Upgrade Zod and import from |
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-onlyand--headerrely on. -
Model Context Protocol is the sub-section overview.