Build MCP servers

How to test an MCP server with the MCP Inspector

The Inspector connects to a server the way an AI app does, so you can see its tools and call them before any model does.

By
Dan Flanagan
Updated
October 15, 2026
Tested with
@modelcontextprotocol/inspector 2.11.0, mcp 2.3.0 and Python 3.13 on macOS, October 2026
Reading time
5 min

New to MCP? What is an MCP server? covers the basics first.

The MCP Inspector is the official tool for talking to an MCP server directly, without a model in the way. You give it a command or a URL, and it does what Claude Code or any other MCP client would do: list the server's tools, call one with arguments you choose, and show you exactly what comes back, including the errors.

This builds a small Python server, then uses the Inspector against it three ways: the CLI for scripted checks, the browser UI for poking around, and both of the transports a server can use.

Prerequisites

  • Node.js 22.19 or newer, since the Inspector runs through npx. I used Node 24 (via a Bun-based node here, which also worked).
  • Python 3.10 or newer and uv, to build the test server. I used Python 3.13 and the mcp package at version 2.3.0.
  • No account or install step for the Inspector itself: npx @modelcontextprotocol/inspector downloads and runs it.

Step 1: Build a server to test against

Create a project and add the SDK:

bash
uv init --name notes-server --no-readme .
uv add "mcp[cli]"

Write server.py, a notes server with three tools: two that read, one that writes:

python
import os
from pathlib import Path

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

NOTES_DIR = Path(os.environ.get("NOTES_DIR", "notes")).resolve()
NOTES_DIR.mkdir(parents=True, exist_ok=True)

mcp = MCPServer("notes")


def note_path(title: str) -> Path:
    slug = title.strip().lower().replace(" ", "-")
    if not slug:
        raise ToolError("A note needs a title.")
    return NOTES_DIR / f"{slug}.md"


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def list_notes() -> list[str]:
    """List the titles of every saved note."""
    return sorted(p.stem for p in NOTES_DIR.glob("*.md"))


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def read_note(title: str) -> str:
    """Return the text of one note."""
    path = note_path(title)
    if not path.exists():
        raise ToolError(f"No note called {title!r}.")
    return path.read_text()


@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False))
def add_note(title: str, body: str) -> str:
    """Save a note. Fails if a note with that title already exists."""
    path = note_path(title)
    if path.exists():
        raise ToolError(f"A note called {title!r} already exists.")
    path.write_text(body)
    return f"Saved {path.stem}."


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

This is the same shape as the server in How to build an MCP server in Python: type hints become the input schema, the docstring becomes the description a model reads, and ToolError is the failure path that reaches the caller as a message instead of a crash. What you should see: nothing yet. This just needs to import cleanly (uv run python -c "import server" prints nothing and exits 0).

Step 2: List tools with the CLI, over stdio

The Inspector's CLI mode connects, runs one JSON-RPC method, prints the result as JSON, and exits. That makes it scriptable in a way the browser UI is not. Run it against the server as a stdio target, which is a command the Inspector launches itself:

bash
npx @modelcontextprotocol/inspector --cli uv run python server.py --method tools/list

What you should see is the tool list, each with its name, description and JSON schema. For add_note:

json
{
  "name": "add_note",
  "description": "Save a note. Fails if a note with that title already exists.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "title": { "title": "Title", "type": "string" },
      "body": { "title": "Body", "type": "string" }
    },
    "required": ["title", "body"],
    "title": "add_noteArguments"
  },
  "outputSchema": {
    "type": "object",
    "properties": { "result": { "title": "Result", "type": "string" } },
    "required": ["result"],
    "title": "add_noteOutput"
  },
  "annotations": { "readOnlyHint": false, "destructiveHint": false }
}

Everything after --method tools/list and the arguments before it are the two halves of the command: what the Inspector forwards unchanged to launch your server, and what it asks the server once connected. The readOnlyHint and destructiveHint fields are the annotations from the Python code, carried over the wire. The Inspector does not enforce them. It only shows what the server declared.

Step 3: Call a tool, and trigger an error

Add --tool-name and one --tool-arg per argument to call a tool instead of just listing them:

bash
npx @modelcontextprotocol/inspector --cli uv run python server.py \
  --method tools/call --tool-name add_note \
  --tool-arg title="Release checklist" --tool-arg body="Tag, build, announce."
json
{
  "content": [{ "type": "text", "text": "Saved release-checklist." }],
  "structuredContent": { "result": "Saved release-checklist." },
  "isError": false
}

Now call read_note for a title that was never saved:

bash
npx @modelcontextprotocol/inspector --cli uv run python server.py \
  --method tools/call --tool-name read_note --tool-arg title="does-not-exist"
text
{
  "content": [
    { "type": "text", "text": "Error executing tool read_note: No note called 'does-not-exist'." }
  ],
  "isError": true
}
{"error":{"code":"tool_is_error","message":"Tool 'read_note' returned isError:true."}}

The server returned a normal MCP result with isError: true and the message from the ToolError you raised, which is what a model would read and could act on. The Inspector CLI then also exits non-zero and prints its own tool_is_error line, because a scripted check usually wants a failing exit code when the tool call failed. If you call a tool name the server never registered, you get a different error (tool_not_found) before the server is asked anything, which is a quick way to confirm a typo in a script versus a real tool failure.

Step 4: Point the CLI at Streamable HTTP instead

A server can also listen on the network instead of being launched as a subprocess. Start the same server over Streamable HTTP:

bash
uv run mcp run server.py --transport streamable-http

It listens on http://127.0.0.1:8000/mcp. In a second terminal, point the Inspector at the URL and add --transport http:

bash
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http --method tools/list

The output is the same shape as Step 2: three tools, same schemas. The command and the arguments before --method changed from a program the Inspector launches to a URL it connects to; nothing about --method, --tool-name or --tool-arg changes. Calling the same missing-note case from Step 3 over HTTP:

bash
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http \
  --method tools/call --tool-name read_note --tool-arg title="does-not-exist"
text
{
  "content": [
    { "type": "text", "text": "Error executing tool read_note: No note called 'does-not-exist'." }
  ],
  "isError": true
}
{"error":{"code":"tool_is_error","message":"Tool 'read_note' returned isError:true."}}

Identical to the stdio run. MCP stdio vs Streamable HTTP: what is the difference? shows how the two differ on the wire. For testing, you can develop against stdio locally and re-run the same --method checks once the server is deployed and reachable by URL.

Step 5: Open the browser UI

Run the Inspector with no mode flag to get the web UI, against the stdio server:

bash
npx @modelcontextprotocol/inspector uv run python server.py

What you should see in the terminal:

text
Starting MCP inspector...

MCP Inspector Web is up and running at:
   http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=33b24aab388c11462b8d5f9bfad34362d475efda19fc0b3a41d7bfb5ae9e01c6

   Sandbox (MCP Apps): http://127.0.0.1:6275/sandbox

   Auth token: 33b24aab388c11462b8d5f9bfad34362d475efda19fc0b3a41d7bfb5ae9e01c6

   Secrets: OS keychain

Opening browser...

It opens that URL in a browser automatically. The token in the query string matters: the Inspector's backend can launch arbitrary local commands and hold OAuth tokens and other secrets for whatever server you connect it to, so every /api/* request it serves has to carry that token as a bearer credential, checked on each call. Without it, anything else running on your machine that can reach 127.0.0.1:6274 could drive the same session. The backend binds to the loopback address by default, so the token is there to stop other processes and users on the same machine.

The page shows a server list with one entry, the command you passed, with transport STDIO, and a banner reading "This server list was launched with --config or an ad-hoc server and can't be edited here." That means server definitions are read-only for this session: start the Inspector with --catalog if you want to add, edit and save servers from the UI. The server shows as Disconnected until you switch its toggle on. Once connected, the card changes to Connected and adds a line reading MCP 2025-11-25, the protocol revision the Inspector and your server agreed on. That can be older than the newest published spec (2026-07-28), so check this line when a feature you expect is missing.

Where Statio fits

The Inspector connects as whoever is running it, with every tool the server exposes. That is the right way to check a server you built or are about to add. Once agents are the ones connecting, Statio sits between them and the server and grants tools one at a time (MCP permissions), so an agent can only call the tools it was granted, a denied tool is refused even if a grant exists elsewhere, and every call goes through its own short-lived identity and token rather than a shared one. Checking a server's tools with the Inspector first, then deciding which of them a given agent should be granted, is a reasonable order to do the two in.

Next

Questions

No. It is a development and debugging tool. An AI app like Claude Code or Cursor connects to a server directly and never runs the Inspector.

Yes. Point it at the server's URL instead of a local command, and choose the Streamable HTTP transport. The CLI takes the same URL with --transport http.

It is a bearer token the Inspector's backend checks on every request to its own API. It stops other processes or users on your machine from driving your Inspector session, which can run local commands and hold whatever secrets your test server needs.

You started it with a command or URL on the command line (an ad-hoc server) instead of --catalog. Server definitions in that mode cannot be edited or saved from the UI.