MCP Inspector: Connect, Test and Debug an MCP Server
When an MCP server misbehaves, the first question is where it breaks: the process does not start, the connection is refused, the protocol version does not match, the tool list is empty, or a call fails. The MCP Inspector, the official debugging tool for MCP servers, answers that question, and its command-line client does it in a way a script can check, so the same test can run on your laptop and in CI.
The sections below use the Inspector 2.15.0 CLI client for six checks: connect to the server, confirm the protocol version, list tools, check portability, call a tool, and locate failures by exit code. The examples run against a reference server built on the official Python SDK and map common symptoms directly to their causes and fixes. For the web and terminal clients, flags and configuration files, follow the links in the sources to the official documentation.
For interactive exploration, choose the Web client in the client comparison. For a repeatable check, use the CLI smoke test. If a connection already fails, start with the target, separator and timeout; once a call reaches the server, classify what came back with this page’s failure modes and exit codes and the three error channels on the Tools concept page.
Quick start: list tools, make a call, check a failure
This local exercise uses Node.js 22.19.0 or newer, Python 3.11 or newer, Inspector 2.15.0, and Python SDK 2.3.0. It needs no model, API key, or remote MCP server. The commands below target macOS/Linux shells.
Create a working directory and save the complete code from the reference server as sdk_server.py in it. The code is in the expandable block; keep the filename because the commands start that file.
python3 -m venv .venv
.venv/bin/python -m pip install 'mcp==2.3.0'Discover tools. Run this from the directory containing sdk_server.py:
npx -y @modelcontextprotocol/[email protected] --cli \
.venv/bin/python sdk_server.py stdio ok -- \
--protocol-era modern --method tools/list --strict --format jsonA successful run exits 0; result.tools contains add, explode, and get_weather. Inspector starts and stops the stdio server itself. Do not launch the server in a second terminal for this transport.
Call a tool. Pass the whole argument object as JSON:
npx -y @modelcontextprotocol/[email protected] --cli \
.venv/bin/python sdk_server.py stdio ok -- \
--protocol-era modern --method tools/call --tool-name add \
--tool-args-json '{"a":2,"b":3}' --format jsonRead the result object: isError is false, its text content contains 5, and structuredContent.result is the number 5. Each command opens a new session; this example does not test state carried between calls.
Check the failure path. The reference server’s explode tool deliberately raises an exception:
npx -y @modelcontextprotocol/[email protected] --cli \
.venv/bin/python sdk_server.py stdio ok -- \
--protocol-era modern --method tools/call --tool-name explode \
--tool-args-json '{}' --format jsonThis command should exit 5 with result.isError: true. That is a tool failure after a successful connection. On stderr, 2.15.0 also forwards what the stdio server wrote; in the recorded run an exception diagnostic with the start of a traceback was observed there (Tool 'explode' raised an unexpected exception). The JSON result is printed on stdout; which of the two a reader sees first is not part of the recorded evidence. An exit 4 instead means the connection or protocol request failed; start with connection troubleshooting. Run the add command again after the failure to check that a fresh session still works.
When these checks pass, replace .venv/bin/python sdk_server.py stdio ok with your own server command, preserving the -- separator before Inspector options. Replace the tool name and arguments with values from your own tools/list response. For a URL-based server, use the HTTP connection command; for repeatable regression checks, continue with the CI smoke script.
1. Which Inspector client to use
The package ships three clients behind one binary, and they share the same connection logic:
| Client | Start it with | Use it for |
|---|---|---|
| Web | npx @modelcontextprotocol/[email protected] | Exploring a server by hand: forms for tools, a live view of the JSON-RPC traffic |
| Terminal UI | npx @modelcontextprotocol/[email protected] --tui | The same exploration where no browser is available |
| CLI | npx @modelcontextprotocol/[email protected] --cli | One request per run, JSON on stdout, a stable exit code: scripts, CI, and quick checks while you edit a server |
Every CLI run connects, sends the single request you name with --method, prints the result and exits. That makes failures easy to classify, and it is why everything below uses the CLI. Pin the version in anything you automate (@modelcontextprotocol/[email protected]); the CLI’s flags and exit codes are part of what the scripts below rely on. 2.15.0 also fixes four security issues that affect every 2.x release before it (fetch-guard, event-stream memory, server.json preview, mcpdo daemon), so an old pin is not just old — see the release notes linked from the sources.
One naming note: the package is @modelcontextprotocol/inspector. Searches often phrase the tool as npx mcp inspector or mcp inspector, but that is a query spelling, not a runnable command — with npx, only the scoped package name above resolves. If you prefer not to use npx at all, install the package into a project and start the same CLI directly: node node_modules/@modelcontextprotocol/inspector/clients/launcher/build/index.js --cli … (this is how the recorded 2.15.0 runs on this page were started; see Versions and Verification).
One boundary is worth naming: the web client renders MCP Apps and advertises io.modelcontextprotocol/ui to such servers, but the Inspector has no LLM, so nothing on the model-facing side of that extension is exercised. The official client matrix therefore lists the Inspector’s MCP Apps support as partial, and everything verified on this page is the CLI, not the Apps renderer.
2. Connecting: the target, the separator and the timeout
The CLI takes the server as a target: a command for a stdio server, or a URL for a Streamable HTTP server.
# stdio: the command and its own arguments first, then "--", then the Inspector's options
npx -y @modelcontextprotocol/[email protected] --cli python3 sdk_server.py stdio -- --method tools/list
# Streamable HTTP
npx -y @modelcontextprotocol/[email protected] --cli --server-url http://127.0.0.1:8300/mcp --transport http --method tools/listThree details cause most of the first failures:
- The
--separator. In the CLI, everything before--is the target and everything after it is the Inspector’s own options; without a--, the target is only the leading run of arguments that do not start with a dash, so a server that takes flags needs it. The web and terminal clients split the other way. The scenariostdio-server-flagstarts the reference server with its own--name flaggedflag before the--, and the server reports that name back. - The timeout.
--connect-timeoutis in milliseconds. A server that never answers produces exit 4 and the messageConnection timed out after N ms(scenariostdio-silent, with--connect-timeout 4000). - State directory. Point
MCP_STORAGE_DIRat practice OAuth state andMCP_CATALOG_PATHat a practice catalog. UseMCP_INSPECTOR_SECRET_STORE=memoryfor credential-free tests. KeepHOMEunchanged and leave existing catalogs, OAuth state and the keychain untouched. Memory storage is not suitable for cross-process OAuth sign-in.
Set these paths in a dedicated practice shell; exit that shell to clean up. Keep the existing npm and other download-cache configuration. This setup is for the credential-free CLI lab, not persistent OAuth.
PLAN034_INSPECTOR_WORK=$(mktemp -d)
printf '%s\n' "$PLAN034_INSPECTOR_WORK"
trap 'rm -rf "$PLAN034_INSPECTOR_WORK"' EXIT HUP INT TERM
export MCP_STORAGE_DIR="$PLAN034_INSPECTOR_WORK/storage"
export MCP_CATALOG_PATH="$PLAN034_INSPECTOR_WORK/catalog.json"
export MCP_INSPECTOR_SECRET_STORE=memory3. Protocol eras: what a green run proves
The 2026-07-28 revision changed the protocol enough that the Inspector treats the protocol era as a per-run setting, separate from the transport. --protocol-era takes legacy, auto or modern:
| Era | What the Inspector does at connect | What we saw on the wire |
|---|---|---|
legacy (the Inspector’s default) | A plain initialize; no probing at all | initialize, notifications/initialized, then the request |
auto | Probes server/discover first and falls back to initialize if the server is not modern | server/discover, then initialize when the stub answered -32601 |
modern | Pins exactly 2026-07-28, with no fallback, so a server that is not modern fails loudly | An error before any tool request (scenario stub-modern) |
The consequence is easy to miss: a green run in the default era says nothing about 2026-07-28. The same official SDK server negotiated 2025-11-25 under legacy and 2026-07-28 under modern and auto (scenarios sdk-http-legacy, sdk-http-modern, sdk-http-auto). The smoke script therefore defaults to --era modern, reports a WARN when the negotiated version is not 2026-07-28, and lets you choose legacy or auto on purpose for a server you know is older. To see the same two eras with plain curl — one copy-paste echo server covering server/discover, spec-shaped input_required retries with negative cases and the header errors — read Migrating Between Protocol Eras.
4. A smoke test you can run in CI
inspector_smoke.py runs the Inspector CLI against one server and prints one line per check. It needs only Python 3 and the Inspector; it does not import the MCP SDK.
| Check | What it does | PASS, WARN, FAIL |
|---|---|---|
inspector | --cli -V | The version is printed. If the Inspector cannot run at all, the script exits 2 without a result line |
connect | --method initialize | PASS with the server name and protocol version; FAIL with the exit code, the Inspector’s error code, its message and a hint |
era | Compares the negotiated version with 2026-07-28 | PASS when equal; WARN otherwise |
tools-list | --method tools/list --strict --format json | PASS with the tool names; WARN for portability warnings or an empty list; FAIL for an error-severity schema finding (exit 6) |
tools-call | With --call NAME --arg KEY=VALUE: --method tools/call | PASS when the result is not an error; with --expect-error, PASS only when the tool returns isError: true |
stdout-clean | With --check-stdout (stdio only): drives the server by hand with a raw JSON-RPC session | PASS when every stdout line is a JSON-RPC message; FAIL with the first offending line |
The exit status is 0 when there is no FAIL (warnings allowed), 1 when at least one check fails, and 2 for a usage error or when the Inspector cannot be started.
# stdio server: --check-stdout is available
python3 inspector_smoke.py --call add --arg a=2 --arg b=3 --check-stdout -- python3 sdk_server.py stdio ok
# Streamable HTTP server
python3 inspector_smoke.py --call add --arg a=2 --arg b=3 http://127.0.0.1:8300/mcp
# a server you know is older than 2026-07-28
python3 inspector_smoke.py --era legacy http://127.0.0.1:9000/mcpBy default the script starts the verified Inspector 2.15.0; --inspector CMD replaces that, for example with a local install. The 26 scenarios below were recorded with 2.12.0 and the key paths re-run on 2.15.0 with the same results (the one visible difference: a failing tools/call now also forwards the stdio server’s stderr, where the recorded run shows the server’s exception diagnostic).
inspector_smoke.py
#!/usr/bin/env python3
"""Smoke-test an MCP server through the MCP Inspector CLI (stdlib only).
inspector_smoke.py [options] URL
inspector_smoke.py [options] -- COMMAND [ARG...]
Checks, in order: inspector (version), connect (initialize), era (which protocol version was
negotiated), tools-list (tools/list --strict), tools-call (with --call), stdout-clean (with
--check-stdout, stdio only).
Options:
--inspector CMD how to start the Inspector (default: npx -y @modelcontextprotocol/[email protected])
--era ERA legacy | auto | modern (default: modern)
--transport T http | sse, for a URL target (default: http)
--timeout-ms N the Inspector's --connect-timeout, in milliseconds (default: 15000)
--call NAME also call this tool once, with the --arg values
--arg KEY=VALUE argument for --call (repeatable); a value is sent as JSON when it parses
--expect-error the --call is expected to return isError: true
--check-stdout stdio only: drive the server by hand and require stdout to carry only JSON-RPC
Exit status: 0 no FAIL (WARN allowed), 1 at least one FAIL, 2 usage error or Inspector cannot run.
"""
import argparse
import json
import queue
import shlex
import subprocess
import sys
import textwrap
import threading
PROCESS_TIMEOUT_S = 90
RAW_TIMEOUT_S = 10
MODERN_VERSION = "2026-07-28"
results = []
def report(level, name, detail):
"""Record and print one check result."""
results.append(level)
print(f"{level} {name}: {detail}")
def parse_json(text):
"""Parse text as JSON; return None when it is not JSON."""
try:
return json.loads(text)
except ValueError:
return None
def run_cli(base, args):
"""Run the Inspector CLI once; return (exit code, stdout, stderr), exit code None on timeout."""
try:
done = subprocess.run(base + ["--cli"] + args, capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=PROCESS_TIMEOUT_S)
except subprocess.TimeoutExpired:
return None, "", ""
return done.returncode, done.stdout, done.stderr
def error_envelope(stderr):
"""The Inspector writes a one-line JSON error envelope last on stderr; return its dict or {}."""
for line in reversed(stderr.strip().splitlines()):
value = parse_json(line)
if isinstance(value, dict):
return value.get("error") or {}
return {}
def server_stderr(stderr):
"""The last stderr line that is not the error envelope: what a stdio server wrote before it died."""
lines = [line for line in stderr.strip().splitlines() if line.strip() and not line.startswith('{"error"')]
return textwrap.shorten(lines[-1], 80, placeholder="...") if lines else ""
def explain(code, stderr):
"""One-line description of a failed run: exit code, error code, message and what to check."""
if code is None:
return f"no result within {PROCESS_TIMEOUT_S}s (the Inspector or the server hangs)"
err = error_envelope(stderr)
message = textwrap.shorten(str(err.get("message", "")), 150, placeholder="...")
hint = ""
if "timed out" in message:
hint = "the server did not answer in time: raise --timeout-ms, or check that it speaks MCP on this transport"
elif code == 4:
hint = "is the server running and the URL or command right?"
elif code == 3:
hint = "the server wants credentials (HTTP 401 or 403)"
elif err.get("status") == 404:
hint = "the endpoint answered 404: wrong path?"
elif "Unexpected content type" in message:
hint = "the URL answers with something that is not MCP (login page, proxy?)"
elif "Connection closed" in message:
hint = "the server process exited during startup: run it by hand and read its stderr"
elif "ENOENT" in message:
hint = "the command was not found"
elif "Error POSTing" in message:
hint = "the server answered with an HTTP error status"
elif "Version negotiation failed" in message:
hint = "the server does not speak 2026-07-28; try --era legacy"
text = f"exit {code} {err.get('code', '-')}: {message}"
if server_stderr(stderr):
text += f"; stderr: {server_stderr(stderr)!r}"
return f"{text} -> {hint}" if hint else text
def coerce(value):
"""Mirror the Inspector's --tool-arg rule for the raw session: JSON when it parses, else a string."""
try:
return json.loads(value)
except ValueError:
return value
def first_text(stdout):
"""The first text block of a tools/call result, shortened, or an empty string."""
content = (parse_json(stdout) or {}).get("content") or [{}]
return textwrap.shorten(str(content[0].get("text", "")), 150, placeholder="...")
def check_connect(run):
"""initialize; return the result dict, or None when the connection failed."""
code, out, err = run(["--method", "initialize"])
info = parse_json(out) if code == 0 else None
if not isinstance(info, dict):
report("FAIL", "connect", explain(code, err))
return None
version = info.get("protocolVersion")
name = (info.get("serverInfo") or {}).get("name")
if not (isinstance(version, str) and name and isinstance(info.get("capabilities"), dict)):
report("FAIL", "connect", "initialize result lacks serverInfo.name, protocolVersion or capabilities")
return None
report("PASS", "connect", f"server={name} protocolVersion={version}")
return info
def check_era(version):
"""WARN when the 2026-07-28 behavior was not exercised."""
if version == MODERN_VERSION:
report("PASS", "era", f"negotiated {MODERN_VERSION}")
else:
report("WARN", "era", f"negotiated {version}; 2026-07-28 behavior not exercised (--era modern pins it)")
def describe(tool, finding):
"""A short label for one schema finding: tool, location and rule."""
location = ".".join(part for part in (finding["schema"], finding["path"]) if part)
return f"{tool} {location} ({finding['rule']})"
def listed_tools(body):
"""The tools array of a --format json tools/list output, or an empty list."""
if not isinstance(body, dict):
return []
return (body.get("result") or {}).get("tools") or []
def check_tools_list(run, advertised):
"""tools/list with --strict; exit 6 means an error-severity schema portability finding.
The Inspector does not send tools/list when the server did not advertise capabilities.tools: it prints an
empty list and exits 0. An empty list from a server that did advertise it is repeated once before it is reported.
"""
options = ["--method", "tools/list", "--strict", "--format", "json"]
code, out, err = run(options)
body, again = parse_json(out), False
if code == 0 and advertised and not listed_tools(body):
code, out, err = run(options)
body, again = parse_json(out), True
if code not in (0, 6) or not isinstance(body, dict):
report("FAIL", "tools-list", explain(code, err))
return
tools = listed_tools(body)
where = [(tool["toolName"], f) for tool in body.get("schemaFindings", []) for f in tool["findings"]]
errors = [describe(n, f) for n, f in where if f["severity"] == "error"]
warnings = [describe(n, f) for n, f in where if f["severity"] != "error"]
if code == 6 or errors:
report("FAIL", "tools-list", f"exit {code}, {len(errors)} portability error(s): " + "; ".join(errors))
elif not advertised:
report("WARN", "tools-list", "capabilities.tools is not advertised, so the Inspector lists no tools without asking")
elif not tools:
report("WARN", "tools-list", "the server lists no tools (asked twice)")
elif warnings:
report("WARN", "tools-list", f"{len(tools)} tools, {len(warnings)} portability warning(s): " + "; ".join(warnings))
else:
names = ", ".join(sorted(t.get("name", "?") for t in tools))
report("PASS", "tools-list", f"{len(tools)} tools: {names}" + (" (second attempt)" if again else ""))
def check_tools_call(run, name, pairs, expect_error):
"""tools/call once; exit 5 is isError (tool_is_error) or an unknown tool (tool_not_found)."""
options = ["--method", "tools/call", "--tool-name", name]
for pair in pairs:
options += ["--tool-arg", pair]
code, out, err = run(options)
kind = error_envelope(err).get("code")
if code == 0 and not expect_error:
report("PASS", "tools-call", f"{name} returned isError false")
elif code == 0:
report("FAIL", "tools-call", f"{name} succeeded but isError true was expected")
elif code == 5 and kind == "tool_is_error" and expect_error:
report("PASS", "tools-call", f"{name} returned isError true (exit 5 tool_is_error), as expected")
elif code == 5 and kind == "tool_is_error":
report("FAIL", "tools-call", f"{name} returned isError true (exit 5 tool_is_error): {first_text(out)}")
else:
report("FAIL", "tools-call", explain(code, err))
def is_jsonrpc(line):
"""True when the line is a JSON object with jsonrpc 2.0."""
parsed = parse_json(line)
return isinstance(parsed, dict) and parsed.get("jsonrpc") == "2.0"
def pump(stream, out):
"""Copy a stream into a queue, line by line, and end with None."""
for line in stream:
out.put(line.rstrip("\n"))
out.put(None)
def check_stdout_clean(command, call, pairs):
"""Drive a stdio server by hand; every stdout line must be a JSON-RPC message."""
messages = [
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": {"name": "inspector-smoke", "version": "1"}}},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
]
if call:
arguments = {k: coerce(v) for k, v in (pair.split("=", 1) for pair in pairs)}
messages.append({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": call, "arguments": arguments}})
try:
proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
except OSError as exc:
report("FAIL", "stdout-clean", f"cannot start the command: {exc}")
return
lines, seen, answered = queue.Queue(), [], set()
threading.Thread(target=pump, args=(proc.stdout, lines), daemon=True).start()
why = ""
try:
for message in messages:
proc.stdin.write(json.dumps(message) + "\n")
proc.stdin.flush()
while message.get("id") is not None and message["id"] not in answered:
line = lines.get(timeout=RAW_TIMEOUT_S)
if line is None:
raise EOFError
if line.strip():
seen.append(line)
parsed = parse_json(line)
if isinstance(parsed, dict) and "id" in parsed and "method" not in parsed:
answered.add(parsed["id"])
except queue.Empty:
why = f"no response within {RAW_TIMEOUT_S}s"
except (EOFError, BrokenPipeError):
why = "the server closed stdout or its stdin before answering"
finally:
proc.kill()
proc.wait()
last_id = messages[-1]["id"]
bad = [(i, line) for i, line in enumerate(seen, 1) if not is_jsonrpc(line)]
if last_id not in answered:
report("FAIL", "stdout-clean", f"request {last_id} not answered: {why}")
elif bad:
i, line = bad[0]
report("FAIL", "stdout-clean", f"{len(bad)} of {len(seen)} stdout lines are not JSON-RPC; first is line {i}: {line[:60]!r}")
else:
report("PASS", "stdout-clean", f"{len(seen)} stdout lines, all JSON-RPC")
def main(argv):
parser = argparse.ArgumentParser(usage=__doc__, add_help=False)
parser.add_argument("--inspector", default="npx -y @modelcontextprotocol/[email protected]")
parser.add_argument("--era", choices=["legacy", "auto", "modern"], default="modern")
parser.add_argument("--transport", choices=["http", "sse"], default="http")
parser.add_argument("--timeout-ms", type=int, default=15000)
parser.add_argument("--call")
parser.add_argument("--arg", action="append", default=[])
parser.add_argument("--expect-error", action="store_true")
parser.add_argument("--check-stdout", action="store_true")
parser.add_argument("target", nargs="+")
try:
opts = parser.parse_args(argv)
except SystemExit:
return 2
is_url = len(opts.target) == 1 and opts.target[0].startswith(("http://", "https://"))
if (opts.check_stdout and is_url) or (opts.expect_error and not opts.call) \
or any("=" not in pair for pair in opts.arg) or opts.timeout_ms < 0:
sys.stderr.write(__doc__)
return 2
base = shlex.split(opts.inspector)
try:
version = subprocess.run(base + ["--cli", "-V"], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=PROCESS_TIMEOUT_S)
except (OSError, subprocess.TimeoutExpired) as exc:
sys.stderr.write(f"cannot run the Inspector ({opts.inspector!r}): {exc}\n")
return 2
if version.returncode != 0:
sys.stderr.write(f"the Inspector exited {version.returncode} on -V: {version.stderr.strip()[:200]}\n")
return 2
report("PASS", "inspector", version.stdout.strip())
options = ["--protocol-era", opts.era, "--connect-timeout", str(opts.timeout_ms)]
def run(method_options):
"""One CLI run against the target; the documented form puts the target before "--" and options after."""
if is_url:
return run_cli(base, ["--server-url", opts.target[0], "--transport", opts.transport] + method_options + options)
return run_cli(base, opts.target + ["--"] + method_options + options)
info = check_connect(run)
if info is not None:
check_era(info["protocolVersion"])
check_tools_list(run, "tools" in info["capabilities"])
if opts.call:
check_tools_call(run, opts.call, opts.arg, opts.expect_error)
if opts.check_stdout:
check_stdout_clean(opts.target, opts.call, opts.arg)
print(f"{results.count('PASS')} pass, {results.count('WARN')} warn, {results.count('FAIL')} fail")
return 1 if "FAIL" in results else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Four design choices are worth knowing before you adapt it:
- It reads the Inspector’s contract, not its prose. PASS and FAIL come from the exit code, from the
codefield of the one-line JSON error envelope that the CLI writes to stderr on every non-zero exit, and from--format jsonoutput. The wording of a message only feeds the hint. --strictis on fortools/list. The Inspector reports tool-schema portability problems in full and exits 6 for an error-severity one. In 2.12.0 the only error-severity rule that appeared in our runs was a bare boolean schema (truewhere a schema object is expected); atypearray and an empty schema were warnings.- An empty list is asked twice. If the server advertised the
toolscapability but the firsttools/listcame back empty with exit 0, the script asks once more before it warns (see the limits in section 8). stdout-cleanis a separate raw session. The Inspector tolerates stray text on a stdio server’s stdout, so a green Inspector run does not prove the stream is clean. The raw session uses the2025-11-25handshake and does not negotiate the era.
5. A reference server to test against
sdk_server.py is a small server built on the official Python SDK (pip install mcp==2.3.0). It has three tools (add, get_weather, explode) and serves stdio or Streamable HTTP. The stdio mode also has deliberate faults (banner, noisy-tool, crash and silent), so the scenarios in the next section can show them.
pip install mcp==2.3.0
python3 sdk_server.py http 8300 # Streamable HTTP at http://127.0.0.1:8300/mcp
python3 sdk_server.py stdio ok # stdio; the smoke script and the Inspector start it for yousdk_server.py
"""Reference server on the official Python SDK (mcp==2.3.0) for the Inspector checks.
sdk_server.py http PORT Streamable HTTP at http://127.0.0.1:PORT/mcp, no auth
sdk_server.py stdio [MODE] [--name NAME]
stdio; MODE is one of the values below; NAME is the serverInfo name
(default inspector-ref), a flag that must reach the server itself
MODE (stdio only):
ok well-behaved server
banner fault: prints a line to stdout (flushed) before the protocol starts
noisy-tool the add tool prints to stdout (flushed) while it runs; SDK 2.3.0 reroutes that to stderr
crash fault: writes to stderr and exits with status 3 before serving
silent fault: never answers (sleeps)
"""
import sys
import time
from typing import Annotated
USAGE = __doc__
MODES = ("ok", "banner", "noisy-tool", "crash", "silent")
def parse_args(argv):
"""Return (transport, port, mode, name) or exit with status 2 and the usage text."""
name = "inspector-ref"
if len(argv) >= 2 and argv[-2] == "--name":
argv, name = argv[:-2], argv[-1]
if len(argv) == 2 and argv[0] == "http" and argv[1].isdigit():
return "http", int(argv[1]), "ok", name
if argv and argv[0] == "stdio" and len(argv) <= 2:
mode = argv[1] if len(argv) == 2 else "ok"
if mode in MODES:
return "stdio", 0, mode, name
sys.stderr.write(USAGE)
sys.exit(2)
transport, port, mode, server_name = parse_args(sys.argv[1:])
if mode == "crash":
sys.stderr.write("fatal: cannot open database\n")
sys.exit(3)
if mode == "silent":
time.sleep(600)
if mode == "banner":
print("Server starting...", flush=True)
from mcp.server.mcpserver import MCPServer # noqa: E402 (after the fault modes on purpose)
from pydantic import Field # noqa: E402
server = MCPServer(server_name)
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
if mode == "noisy-tool":
print("debug: adding", a, b, flush=True)
return a + b
@server.tool()
def get_weather(location: Annotated[str, Field(min_length=2)]) -> str:
"""Return fixed weather for a location."""
return f"Partly cloudy in {location}"
@server.tool()
def explode() -> str:
"""Always raise."""
raise RuntimeError("boom")
if __name__ == "__main__":
if transport == "http":
server.run("streamable-http", port=port)
else:
server.run("stdio")Run against it, the smoke script prints this on the modern era (the first scenario of section 6, over stdio):
PASS inspector: 2.12.0
PASS connect: server=inspector-ref protocolVersion=2026-07-28
PASS era: negotiated 2026-07-28
PASS tools-list: 3 tools: add, explode, get_weather
PASS tools-call: add returned isError false
PASS stdout-clean: 3 stdout lines, all JSON-RPC
6 pass, 0 warn, 0 fail6. Scenarios
Each scenario is one run of the smoke script. The full output of all 26, with the exit status, is recorded and compared on every re-run. The table keeps the setup and the summary line; section 7 explains the failures.
| Scenario | Setup | Result |
|---|---|---|
sdk-http-modern | Official SDK server over HTTP, default era modern, --call add | 5 pass, 0 warn, 0 fail, exit 0 |
sdk-http-auto | The same server, --era auto | 4 pass, 0 warn, 0 fail, exit 0 |
sdk-http-legacy | The same server, --era legacy | 3 pass, 1 warn, 0 fail, exit 0 |
stub-modern | Stub that speaks only 2025-11-25, default era modern | 1 pass, 0 warn, 1 fail, exit 1 |
stub-auto | The same stub, --era auto | 3 pass, 1 warn, 0 fail, exit 0 |
stub-legacy-call | The same stub, --era legacy, --call echo | 4 pass, 1 warn, 0 fail, exit 0 |
stub-bad-schema | A tool with a bare boolean, a type array and an empty schema | 2 pass, 1 warn, 1 fail, exit 1 |
stub-warn-schema | A tool with only a type array | 2 pass, 2 warn, 0 fail, exit 0 |
stub-no-capability | initialize without capabilities.tools | 2 pass, 2 warn, 0 fail, exit 0 |
http-auth | 401 with a WWW-Authenticate header | 1 pass, 0 warn, 1 fail, exit 1 |
http-forbidden | 403 | 1 pass, 0 warn, 1 fail, exit 1 |
http-not-found | 404 | 1 pass, 0 warn, 1 fail, exit 1 |
http-error-500 | 500 | 1 pass, 0 warn, 1 fail, exit 1 |
http-non-json | 200 with an HTML body | 1 pass, 0 warn, 1 fail, exit 1 |
http-unreachable | Nothing listens on the port | 1 pass, 0 warn, 1 fail, exit 1 |
stdio-ok-modern | SDK stdio server, modern, --call add, --check-stdout | 6 pass, 0 warn, 0 fail, exit 0 |
stdio-server-flag | The server’s own --name flagged before the -- | 3 pass, 1 warn, 0 fail, exit 0 |
stdio-banner | Prints a banner to stdout before serving | 4 pass, 1 warn, 1 fail, exit 1 |
stdio-noisy-tool | The add tool prints to stdout | 5 pass, 1 warn, 0 fail, exit 0 |
stdio-crash | Writes to stderr and exits with status 3 at startup | 1 pass, 0 warn, 2 fail, exit 1 |
stdio-silent | Never answers, --timeout-ms 4000 | 1 pass, 0 warn, 1 fail, exit 1 |
stdio-unknown-tool | --call nope | 3 pass, 1 warn, 1 fail, exit 1 |
stdio-bad-arg | --call add --arg a=two --arg b=3 | 3 pass, 1 warn, 1 fail, exit 1 |
stdio-tool-error-expected | --call explode --expect-error | 4 pass, 1 warn, 0 fail, exit 0 |
stdio-tool-error-unexpected | --call explode | 3 pass, 1 warn, 1 fail, exit 1 |
stdio-bad-command | The command does not exist | 1 pass, 0 warn, 1 fail, exit 1 |
Three more runs read the traffic instead of the output, with stubs that record the method of every request: legacy never sent server/discover and auto sent it first; a stub that does not advertise the tools capability received no tools/list; and a call to an unknown tool produced a tools/list but no tools/call.
7. Failure modes and recovery
Read the table from the left: the symptom is what the script prints, the next column is the scenario that produced it, and the rest tells you what it means and what to do. The quoted fragments are copied from the recorded output.
| Symptom | Scenario and what the script printed | Cause | Recovery |
|---|---|---|---|
| The server cannot be reached | http-unreachable: FAIL connect: exit 4 unreachable: fetch failed | Nothing listens at that host and port, or a firewall or proxy blocks it | Open the URL with curl first. Confirm the host, the port and the path, and that the process is running |
| The connection hangs and then times out | stdio-silent: exit 4 unreachable: Connection timed out after 4000 ms | The process started but never answered initialize: it is not an MCP server on this transport, it waits for something, or it is very slow | Run the command by hand and type nothing: a stdio server should wait quietly for input. Raise --connect-timeout only if startup is legitimately slow |
| The server exits at startup | stdio-crash: exit 1 error: Connection closed; stderr: 'fatal: cannot open database' | The process died before serving; the Inspector passes its stderr through and the script shows the last line | Run the command by hand, read the whole stderr, and fix the startup error. Missing environment variables and wrong working directories are common causes |
| “spawn … ENOENT” | stdio-bad-command: spawn /nonexistent/mcp-server ENOENT | The command is not found from the Inspector’s environment | Use an absolute path, or check that the interpreter is on the PATH the Inspector sees |
| The server asks for credentials | http-auth: exit 3 auth_required; http-forbidden: exit 3 auth_required: Error POSTing to endpoint | HTTP 401 or 403. The text differs: with no terminal the 401 message says interactive OAuth needs a TTY, and the 403 body is shown as it came | In a terminal the Inspector starts a browser OAuth flow. In CI the official guidance is --stored-auth-only: use a stored token or fail at once instead of waiting for a browser nobody will use |
| The endpoint is wrong | http-not-found: exit 1 error: Error POSTing to endpoint: not found | The path answers 404, often / instead of /mcp | Check the path the server documents. A 404 is not an auth problem, and the exit code is 1, not 3 |
| The server answers with an error status | http-error-500: exit 1 error: Error POSTing to endpoint: boom | The server failed while handling the request | Read the server’s own log; the Inspector only shows the response body |
| The URL returns a web page | http-non-json: Unexpected content type: text/html | A login page, a proxy error page, or a site that is not an MCP endpoint | curl -i the URL and read the first lines of the body |
| “Version negotiation failed” | stub-modern: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 | modern pins 2026-07-28 and the server speaks an older revision | Either upgrade the server, or choose --era legacy (or auto) deliberately for an older server |
tools/list fails with exit 6 | stub-bad-schema: exit 6, 1 portability error(s): flag inputSchema.properties.x (boolean-schema) | A tool schema has a bare boolean where a schema object is expected, which some clients and model providers cannot use | Replace true with an object schema, for example {"type": "object", "additionalProperties": true} for a free-form object. The change is a contract change, not a rewrite |
| A portability warning | stub-warn-schema: portability warning(s): maybe inputSchema.properties.note (type-union) | A type array such as ["string", "null"] is valid JSON Schema, but a client that maps schemas to a single-type dialect may reject the tool or drop the constraint | Use anyOf with one type per branch if you serve such clients. A warning does not fail the run |
| The tool list is empty and the exit code is 0 | stub-no-capability: capabilities.tools is not advertised, so the Inspector lists no tools without asking | The server did not declare the tools capability. The Inspector does not call tools/list for such a server, and the wire log showed no request | A server that supports tools MUST declare the tools capability. Declare it in initialize, then list again |
stdout-clean fails but the Inspector run is green | stdio-banner: 1 of 4 stdout lines are not JSON-RPC; first is line 1: 'Server starting...' | The server printed a banner to stdout before the protocol started. The Inspector skips it, a stricter client may not | stdout carries only MCP messages; send logs and banners to stderr |
A print inside a tool does not break the stream | stdio-noisy-tool: PASS stdout-clean: 3 stdout lines, all JSON-RPC | A property of SDK 2.3.0, not of MCP: while it serves, its stdio transport keeps the protocol off the process’s stdout, and the debug line arrived on stderr. A print before server.run still breaks the stream (previous row) | Do not rely on it for other SDKs or hand-written servers; nothing here verifies them |
A call fails with exit 5 and tool_not_found | stdio-unknown-tool: exit 5 tool_not_found: Tool 'nope' not found on server. | The tool name is not in the server’s list; the Inspector looked it up in the list and did not send the call | Compare with the tools-list line, which prints the names, including case |
A call fails with exit 5 and tool_is_error | stdio-tool-error-unexpected: returned isError true (exit 5 tool_is_error): Error executing tool explode | The tool ran and returned isError: true: an execution error, not a protocol error. From 2.15.0 the CLI also forwards the stdio server’s own stderr, so the exception diagnostic that produced isError: true is visible in the same run | Read the text and the forwarded diagnostic; --expect-error turns the same result into a PASS when the error is what the test wants (stdio-tool-error-expected) |
A number argument arrives as None | stdio-bad-arg: Input should be a valid integer [type=int_type, input_value=None | --tool-arg a=two is not valid JSON, so it is sent as a string, and the Inspector converts it to the type the schema declares; the conversion of two gave null | Fix the argument, or pass the whole object with --tool-args-json when you need to control the types |
The exit codes
The CLI maps every failure to a stable exit code and writes a one-line JSON envelope to stderr ({"error": {"code": ..., "message": ...}}). The scripts branch on these, not on the text:
| Code | Meaning (from the Inspector’s documentation) | Seen in |
|---|---|---|
0 | Success | sdk-http-modern |
1 | Usage or unexpected error, the catch-all | stdio-crash, http-not-found, http-error-500, http-non-json, stub-modern, stdio-bad-command |
2 | No MCP App found on the tool (--app-info probe) | not exercised |
3 | The server requires authentication (HTTP 401 or 403) | http-auth, http-forbidden |
4 | The server is unreachable (DNS, connection refused, timeout, fetch failed) | http-unreachable, stdio-silent |
5 | tools/call returned isError: true, or the tool was not found | stdio-unknown-tool, stdio-bad-arg, stdio-tool-error-unexpected |
6 | --strict found an error-severity tool-schema portability problem | stub-bad-schema |
7, 8, 9 | The --verify checks for skills (skills/list, skills/get) | not exercised |
8. What was not verified
- Interfaces. The web client, the terminal UI and
mcpdowere not run. Everything here is the CLI client. MCP Apps rendering in the web client was not exercised either, which is why the official matrix marks the Inspector’s Apps support as partial. - Transports and auth. Streamable HTTP and stdio were run; the legacy SSE transport (
--transport sse) was not. OAuth flows,--stored-auth-onlyand custom headers were not run; the table row for credentials describes the failure, and the remedy comes from the Inspector’s documentation. - Other SDKs. The only independent implementation is the official Python SDK 2.3.0. The stub and the smoke script come from the same reading of the specification and can share a mistake. The stdout behavior in section 7 is specific to that SDK.
- A transient empty list. In one of roughly three hundred runs against a healthy stub,
tools/listreturned an empty list with exit 0. We could not reproduce it, and we did not find its cause. The script asks twice for that reason, and prints(second attempt)when the second request was the one that returned the tools. - Platforms. macOS only.
9. Before you ship a server
- It connects with
--era modern, or you have chosen an older era on purpose and know which. tools/listis clean under--strict, or every warning is understood.- The
toolscapability is declared if you serve tools. - stdout carries only protocol messages; logs and banners go to stderr.
- Authentication errors return 401 or 403, not a web page.
- The smoke script runs in CI with the Inspector’s version pinned, and you re-run it when you change the SDK, the gateway or the revision you serve.
Versions and Verification
Behavior was observed by running Inspector 2.12.0 (@modelcontextprotocol/inspector, which needs Node 22.19.0 or newer; the runs used Node 24.18.0) against the official Python SDK 2.3.0 (mcp==2.3.0, Python 3.14) and against a small standard-library stub that breaks one thing at a time, on macOS. The complete run covers 26 recorded scenarios. The wording of the specification and of the Inspector’s own documentation is read from one pinned commit of the official repository (dae694f6). A new Inspector or SDK release needs a re-run. What was not verified is listed in section 8.
On 2026-10-30, Node 24.18.0, Inspector 2.12.0 and Python 3.11.12 with mcp==2.3.0 reran the subset used by the connection guides: clean stdio with add returning 5, the flushed pre-start banner, process exit, legacy HTTP versus modern, and HTTP 401 / 403 / 404 / HTML. The table above retains the earlier complete recording; this follow-up did not rerun all 26 scenarios or the Web / TUI / OAuth flows.
On 2026-11-04, the quick start was run on macOS with Node 24.18.0, Inspector 2.12.0 and Python 3.11.12 with mcp==2.3.0. Tool discovery returned all three tools; add returned 5 with exit 0; explode returned isError: true with exit 5; and a new add session succeeded afterward. This follow-up checks the local stdio path only.
On 2026-11-13, the commands on this page were re-run on macOS with a locally installed Inspector 2.15.0 (started directly with node from node_modules/@modelcontextprotocol/inspector, without npx; the Node version was not recorded) and Python 3.12 with mcp==2.3.0 in a virtual environment: the quick start over stdio modern (tools/list --strict with the three tools, add returning 5 in both content and structuredContent with exit 0, explode with exit 5 and isError: true) and initialize over Streamable HTTP in all three eras (modern and auto negotiated 2026-07-28, legacy negotiated 2025-11-25, all exit 0). The one behavioral difference observed against the 2.12.0 recording: on a failing stdio tools/call, the server’s stderr is forwarded, and the recorded run shows an exception diagnostic there (the captured traceback excerpt is partial; stdout and stderr were captured separately, so no ordering between the traceback and the JSON result is claimed). The exit code and the isError: true result are unchanged. 2.15.0’s other changes (OAuth/fetch-guard failure classification, --oauth-debug, load-testing flags, the four security fixes) were not exercised.
Sources
- The Inspector’s pages in the official documentation: overview, CLI client, protocol eras, configuration and flags and authorization
- The specification: stdio transport and Tools
- The Inspector’s source repository and its 2.15.0 release notes
- On this site: MCP tools explained and MCP server best practices