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.
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-basednodehere, which also worked). - Python 3.10 or newer and uv, to build the test server. I used Python 3.13 and the
mcppackage at version 2.3.0. - No account or install step for the Inspector itself:
npx @modelcontextprotocol/inspectordownloads and runs it.
Step 1: Build a server to test against
Create a project and add the SDK:
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:
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:
npx @modelcontextprotocol/inspector --cli uv run python server.py --method tools/listWhat you should see is the tool list, each with its name, description and JSON schema. For add_note:
{
"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:
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."{
"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:
npx @modelcontextprotocol/inspector --cli uv run python server.py \
--method tools/call --tool-name read_note --tool-arg title="does-not-exist"{
"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:
uv run mcp run server.py --transport streamable-httpIt listens on http://127.0.0.1:8000/mcp. In a second terminal, point the Inspector at the URL and add --transport http:
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http --method tools/listThe 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:
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"{
"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:
npx @modelcontextprotocol/inspector uv run python server.pyWhat you should see in the terminal:
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
- How to build an MCP server in Python, the server this article tests.
- MCP stdio vs Streamable HTTP: what is the difference?, on what each transport sends.
- MCP resources and prompts: who controls each one, the other two things a server can expose besides tools.
- How to debug an MCP server that won't connect, for when the Inspector itself cannot reach a server.
- How to pin MCP server versions, once a server's tools look right and you want them to stay that way.
Questions
--transport http.--catalog. Server definitions in that mode cannot be edited or saved from the UI.