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 json

A 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 json

Read 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 json

This 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:

ClientStart it withUse it for
Webnpx @modelcontextprotocol/[email protected]Exploring a server by hand: forms for tools, a live view of the JSON-RPC traffic
Terminal UInpx @modelcontextprotocol/[email protected] --tuiThe same exploration where no browser is available
CLInpx @modelcontextprotocol/[email protected] --cliOne 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/list

Three 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 scenario stdio-server-flag starts the reference server with its own --name flagged flag before the --, and the server reports that name back.
  • The timeout. --connect-timeout is in milliseconds. A server that never answers produces exit 4 and the message Connection timed out after N ms (scenario stdio-silent, with --connect-timeout 4000).
  • State directory. Point MCP_STORAGE_DIR at practice OAuth state and MCP_CATALOG_PATH at a practice catalog. Use MCP_INSPECTOR_SECRET_STORE=memory for credential-free tests. Keep HOME unchanged 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=memory

3. 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:

EraWhat the Inspector does at connectWhat we saw on the wire
legacy (the Inspector’s default)A plain initialize; no probing at allinitialize, notifications/initialized, then the request
autoProbes server/discover first and falls back to initialize if the server is not modernserver/discover, then initialize when the stub answered -32601
modernPins exactly 2026-07-28, with no fallback, so a server that is not modern fails loudlyAn 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.

CheckWhat it doesPASS, WARN, FAIL
inspector--cli -VThe version is printed. If the Inspector cannot run at all, the script exits 2 without a result line
connect--method initializePASS with the server name and protocol version; FAIL with the exit code, the Inspector’s error code, its message and a hint
eraCompares the negotiated version with 2026-07-28PASS when equal; WARN otherwise
tools-list--method tools/list --strict --format jsonPASS with the tool names; WARN for portability warnings or an empty list; FAIL for an error-severity schema finding (exit 6)
tools-callWith --call NAME --arg KEY=VALUE: --method tools/callPASS when the result is not an error; with --expect-error, PASS only when the tool returns isError: true
stdout-cleanWith --check-stdout (stdio only): drives the server by hand with a raw JSON-RPC sessionPASS 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/mcp

By 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 code field of the one-line JSON error envelope that the CLI writes to stderr on every non-zero exit, and from --format json output. The wording of a message only feeds the hint.
  • --strict is on for tools/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 (true where a schema object is expected); a type array and an empty schema were warnings.
  • An empty list is asked twice. If the server advertised the tools capability but the first tools/list came back empty with exit 0, the script asks once more before it warns (see the limits in section 8).
  • stdout-clean is 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 the 2025-11-25 handshake 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 you
sdk_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 fail

6. 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.

ScenarioSetupResult
sdk-http-modernOfficial SDK server over HTTP, default era modern, --call add5 pass, 0 warn, 0 fail, exit 0
sdk-http-autoThe same server, --era auto4 pass, 0 warn, 0 fail, exit 0
sdk-http-legacyThe same server, --era legacy3 pass, 1 warn, 0 fail, exit 0
stub-modernStub that speaks only 2025-11-25, default era modern1 pass, 0 warn, 1 fail, exit 1
stub-autoThe same stub, --era auto3 pass, 1 warn, 0 fail, exit 0
stub-legacy-callThe same stub, --era legacy, --call echo4 pass, 1 warn, 0 fail, exit 0
stub-bad-schemaA tool with a bare boolean, a type array and an empty schema2 pass, 1 warn, 1 fail, exit 1
stub-warn-schemaA tool with only a type array2 pass, 2 warn, 0 fail, exit 0
stub-no-capabilityinitialize without capabilities.tools2 pass, 2 warn, 0 fail, exit 0
http-auth401 with a WWW-Authenticate header1 pass, 0 warn, 1 fail, exit 1
http-forbidden4031 pass, 0 warn, 1 fail, exit 1
http-not-found4041 pass, 0 warn, 1 fail, exit 1
http-error-5005001 pass, 0 warn, 1 fail, exit 1
http-non-json200 with an HTML body1 pass, 0 warn, 1 fail, exit 1
http-unreachableNothing listens on the port1 pass, 0 warn, 1 fail, exit 1
stdio-ok-modernSDK stdio server, modern, --call add, --check-stdout6 pass, 0 warn, 0 fail, exit 0
stdio-server-flagThe server’s own --name flagged before the --3 pass, 1 warn, 0 fail, exit 0
stdio-bannerPrints a banner to stdout before serving4 pass, 1 warn, 1 fail, exit 1
stdio-noisy-toolThe add tool prints to stdout5 pass, 1 warn, 0 fail, exit 0
stdio-crashWrites to stderr and exits with status 3 at startup1 pass, 0 warn, 2 fail, exit 1
stdio-silentNever answers, --timeout-ms 40001 pass, 0 warn, 1 fail, exit 1
stdio-unknown-tool--call nope3 pass, 1 warn, 1 fail, exit 1
stdio-bad-arg--call add --arg a=two --arg b=33 pass, 1 warn, 1 fail, exit 1
stdio-tool-error-expected--call explode --expect-error4 pass, 1 warn, 0 fail, exit 0
stdio-tool-error-unexpected--call explode3 pass, 1 warn, 1 fail, exit 1
stdio-bad-commandThe command does not exist1 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.

SymptomScenario and what the script printedCauseRecovery
The server cannot be reachedhttp-unreachable: FAIL connect: exit 4 unreachable: fetch failedNothing listens at that host and port, or a firewall or proxy blocks itOpen 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 outstdio-silent: exit 4 unreachable: Connection timed out after 4000 msThe process started but never answered initialize: it is not an MCP server on this transport, it waits for something, or it is very slowRun 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 startupstdio-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 lineRun 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 ENOENTThe command is not found from the Inspector’s environmentUse an absolute path, or check that the interpreter is on the PATH the Inspector sees
The server asks for credentialshttp-auth: exit 3 auth_required; http-forbidden: exit 3 auth_required: Error POSTing to endpointHTTP 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 cameIn 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 wronghttp-not-found: exit 1 error: Error POSTing to endpoint: not foundThe path answers 404, often / instead of /mcpCheck 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 statushttp-error-500: exit 1 error: Error POSTing to endpoint: boomThe server failed while handling the requestRead the server’s own log; the Inspector only shows the response body
The URL returns a web pagehttp-non-json: Unexpected content type: text/htmlA login page, a proxy error page, or a site that is not an MCP endpointcurl -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-28modern pins 2026-07-28 and the server speaks an older revisionEither upgrade the server, or choose --era legacy (or auto) deliberately for an older server
tools/list fails with exit 6stub-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 useReplace 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 warningstub-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 constraintUse 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 0stub-no-capability: capabilities.tools is not advertised, so the Inspector lists no tools without askingThe server did not declare the tools capability. The Inspector does not call tools/list for such a server, and the wire log showed no requestA server that supports tools MUST declare the tools capability. Declare it in initialize, then list again
stdout-clean fails but the Inspector run is greenstdio-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 notstdout carries only MCP messages; send logs and banners to stderr
A print inside a tool does not break the streamstdio-noisy-tool: PASS stdout-clean: 3 stdout lines, all JSON-RPCA 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_foundstdio-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 callCompare with the tools-list line, which prints the names, including case
A call fails with exit 5 and tool_is_errorstdio-tool-error-unexpected: returned isError true (exit 5 tool_is_error): Error executing tool explodeThe 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 runRead 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 Nonestdio-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 nullFix 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:

CodeMeaning (from the Inspector’s documentation)Seen in
0Successsdk-http-modern
1Usage or unexpected error, the catch-allstdio-crash, http-not-found, http-error-500, http-non-json, stub-modern, stdio-bad-command
2No MCP App found on the tool (--app-info probe)not exercised
3The server requires authentication (HTTP 401 or 403)http-auth, http-forbidden
4The server is unreachable (DNS, connection refused, timeout, fetch failed)http-unreachable, stdio-silent
5tools/call returned isError: true, or the tool was not foundstdio-unknown-tool, stdio-bad-arg, stdio-tool-error-unexpected
6--strict found an error-severity tool-schema portability problemstub-bad-schema
7, 8, 9The --verify checks for skills (skills/list, skills/get)not exercised

8. What was not verified

  • Interfaces. The web client, the terminal UI and mcpdo were 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-only and 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/list returned 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/list is clean under --strict, or every warning is understood.
  • The tools capability 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