The MCP Inspector is the official developer tool for MCP servers. It has two faces: a web UI for clicking through tools, resources, and prompts interactively, and a CLI mode that drives the same server headlessly and prints JSON — scriptable, diffable, and easy to drop into a Makefile or CI. This tutorial uses both against a small FastMCP server, then reproduces and diagnoses a broken stdio handshake, the most common failure when a client refuses to connect.
The Inspector runs through npx, so there is nothing to install: npx @modelcontextprotocol/inspector <launch command> fetches it and connects to
whatever server the launch command starts.
What you will build
- A small FastMCP server (
server.py) with two tools (add,greet) and a resource (info://server), run over stdio. - A set of Inspector CLI invocations that list tool schemas, call tools, and read the resource — each producing deterministic JSON.
- A deliberately broken copy of the server that fails the initialize handshake, plus the two-step technique that pinpoints why.
- A
pytestsuite that pins the same behavior, and aMakefilethat wraps every command.
Prerequisites
- uv 0.11+ (
brew install uv). Verify withuv --version. This tutorial was validated on uv 0.11.26. - Node.js 18+ with
npxonPATH(brew install node). The Inspector is a Node tool; validated here on Node 26 with Inspector 1.0.0. - Familiarity with MCP concepts (tools, resources, the stdio transport).
- macOS commands are shown; the project is cross-platform.
Step 1: Scaffold the project
Create the files
mkdir -p debug-mcp-server-inspector-macos
cd debug-mcp-server-inspector-macos
touch .gitignore
uv init --name macmcp .
rm -f main.py hello.py
uv add "fastmcp>=3.4.4"
uv add --dev "pytest>=9" "pytest-asyncio>=1.4"
Add the code: .gitignore
.venv/
__pycache__/
*.pyc
.pytest_cache/
.DS_Store
*.log
Detailed breakdown
uv init --name macmcp .createspyproject.tomland a virtual environment marker;rm -f main.py hello.pyclears the sample entry point uv scaffolds (the name varies by uv version), since this project’s entry point isserver.py.uv add "fastmcp>=3.4.4"pins the server framework; the dev group addspytestandpytest-asynciofor the lock-in suite in Step 8..gitignorefirst, before any other file, so the virtual environment and caches never get committed.
Step 2: Write the server
The subject under test is a normal FastMCP stdio server. Nothing about it is Inspector-specific — that is the point. The Inspector speaks plain MCP, so it works against any conformant server.
Create the file
touch server.py
Add the code: server.py
"""A small FastMCP server used as the subject under test for the MCP Inspector.
It exposes two tools (`add`, `greet`) and one resource (`info://server`), and
runs over the stdio transport so the Inspector launches it as a subprocess.
"""
from fastmcp import FastMCP
NAME = "inspector-demo"
VERSION = "0.1.0"
mcp = FastMCP(NAME)
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
@mcp.tool
def greet(name: str, shout: bool = False) -> str:
"""Return a greeting for a name, optionally shouting it."""
message = f"Hello, {name}!"
return message.upper() if shout else message
@mcp.resource("info://server")
def server_info() -> dict:
"""Static metadata about this server."""
return {"name": NAME, "version": VERSION}
if __name__ == "__main__":
# stdio transport: the client (Inspector, Claude) speaks JSON-RPC over
# stdin/stdout, so nothing else may write to stdout.
mcp.run()
Detailed breakdown
@mcp.toolregisters each function as a tool; the type hints become the input schema, and the docstring becomes the description the Inspector shows.@mcp.resource("info://server")registers a read-only resource under that URI. Returning adictmakes FastMCP serialize it to JSON text.mcp.run()with no arguments defaults to the stdio transport: the server reads JSON-RPC from stdin and writes it to stdout. The Inspector launches this exact command (uv run server.py) as a subprocess and speaks to it over those pipes. Because stdout is the protocol channel, the server must never print to it — the bug in Step 7 is exactly that mistake’s cousin.
Step 3: Open the Inspector UI (optional, interactive)
For exploratory work, launch the full UI. This step is interactive and opens a browser, so it is the one part of this tutorial you run by hand rather than in CI.
npx -y @modelcontextprotocol/inspector uv run server.py
The Inspector starts a local proxy and prints a URL (with a pre-filled session token) to open in your browser. From there you can click List Tools, fill in arguments and Call a tool, browse Resources, and watch the raw JSON-RPC traffic in the history pane. Stop it with Ctrl-C.
The UI is the fastest way to explore an unfamiliar server. For repeatable checks, the CLI is better — that is the rest of this tutorial.
Step 4: Inspect tool schemas with the CLI
CLI mode takes the same launch command plus --cli and a --method. Start by
listing the tools and their schemas, exactly what a client sees during discovery.
npx -y @modelcontextprotocol/inspector --cli uv run server.py --method tools/list
{
"tools": [
{
"name": "add",
"description": "Add two integers and return the sum.",
"inputSchema": {
"type": "object",
"properties": {
"a": {
"type": "integer"
},
"b": {
"type": "integer"
}
},
"required": [
"a",
"b"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"result": {
"type": "integer"
}
},
"required": [
"result"
],
"x-fastmcp-wrap-result": true
},
"_meta": {
"fastmcp": {
"tags": []
}
}
},
{
"name": "greet",
"description": "Return a greeting for a name, optionally shouting it.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"shout": {
"default": false,
"type": "boolean"
}
},
"required": [
"name"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"result": {
"type": "string"
}
},
"required": [
"result"
],
"x-fastmcp-wrap-result": true
},
"_meta": {
"fastmcp": {
"tags": []
}
}
}
]
}
Detailed breakdown
--cliswitches off the browser and runs a single request/response against the server. The launch command (uv run server.py) comes before--method; the Inspector’s own flags (--method, and later--tool-name,--tool-arg,--uri) come after.tools/listis the raw MCP method name. The output is the discovery payload a real client receives: each tool’sname,description, andinputSchema(derived from the Python type hints).addrequires two integers;greetrequiresnameand marksshoutoptional with"default": false, because the Python parameter has a default.outputSchemaandx-fastmcp-wrap-resultappear because FastMCP infers a structured output schema from the return type and wraps a bare return value under aresultkey. This is exactly the kind of detail the Inspector makes visible: what the schema actually says, not what you assume it says.
Step 5: Call a tool with the CLI
Pass --tool-name and one --tool-arg key=value per argument.
npx -y @modelcontextprotocol/inspector --cli uv run server.py \
--method tools/call --tool-name add --tool-arg a=2 b=3
{
"_meta": {
"fastmcp": {
"wrap_result": true
}
},
"content": [
{
"type": "text",
"text": "5"
}
],
"structuredContent": {
"result": 5
},
"isError": false
}
Call the second tool the same way:
npx -y @modelcontextprotocol/inspector --cli uv run server.py \
--method tools/call --tool-name greet --tool-arg name=Ada shout=true
{
"_meta": {
"fastmcp": {
"wrap_result": true
}
},
"content": [
{
"type": "text",
"text": "HELLO, ADA!"
}
],
"structuredContent": {
"result": "HELLO, ADA!"
},
"isError": false
}
Detailed breakdown
--tool-arg a=2 b=3passes multiple arguments after a single flag. The Inspector coerces the values against the tool’sinputSchema:a=2becomes the integer2, andshout=truebecomes the booleantrue.- The response carries both
contentandstructuredContent. Thecontentarray is the human-readable text block ("5");structuredContentis the typed payload ({"result": 5}) that matches theoutputSchemafrom Step 4."isError": falseconfirms the call succeeded — a tool that raised would set ittrueand put the message incontent.
Step 6: Read a resource with the CLI
Resources have their own methods. List them, then read one by URI.
npx -y @modelcontextprotocol/inspector --cli uv run server.py --method resources/list
{
"resources": [
{
"name": "server_info",
"uri": "info://server",
"description": "Static metadata about this server.",
"mimeType": "text/plain",
"_meta": {
"fastmcp": {
"tags": []
}
}
}
]
}
npx -y @modelcontextprotocol/inspector --cli uv run server.py \
--method resources/read --uri info://server
{
"contents": [
{
"uri": "info://server",
"mimeType": "text/plain",
"text": "{\"name\": \"inspector-demo\", \"version\": \"0.1.0\"}"
}
]
}
Detailed breakdown
resources/listreturns each resource’suri,name(the Python function name,server_info), andmimeType. FastMCP defaults the MIME type totext/plainhere.resources/read --uri info://serverfetches the content. Thedictthe function returned is serialized to JSON text, sotextholds the escaped string{"name": "inspector-demo", "version": "0.1.0"}. Reading a URI the server does not define returns an error instead — a quick way to confirm your URI templates match what clients will request.
Step 7: Diagnose a broken handshake
The most common report is “my client won’t connect.” Reproduce it with a server that crashes on startup, then use the Inspector to see the symptom and a direct run to find the cause.
Create the file
touch server_broken.py
Add the code: server_broken.py
from fastmcp import FastMCP
mcp = FastMCP("inspector-demo")
# BUG: a typo in the decorator — `mcp.tools` does not exist (it is `mcp.tool`),
# so importing/starting the server raises AttributeError before it can serve.
@mcp.tools
def add(a: int, b: int) -> int:
"""Add two integers and return the sum."""
return a + b
if __name__ == "__main__":
mcp.run()
Point the Inspector at it:
npx -y @modelcontextprotocol/inspector --cli uv run server_broken.py --method tools/list
Failed to connect to MCP server: MCP error -32000: Connection closed
Failed with exit code: 1
The Inspector reports the symptom — the server process closed the connection before completing the initialize handshake — but not the cause. To find the cause, run the launch command directly, without the Inspector in the way:
uv run server_broken.py
Traceback (most recent call last):
File ".../server_broken.py", line 7, in <module>
@mcp.tools
^^^^^^^^^
AttributeError: 'FastMCP' object has no attribute 'tools'. Did you mean: 'tool'?
There it is: mcp.tools should be mcp.tool. The server raised at import, exited
before serving, and the client saw only a closed pipe. Fixing the decorator (as in
server.py) makes tools/list succeed again.
Detailed breakdown
Connection closed(-32000) almost always means the server process died or never spoke MCP. The Inspector connects over the subprocess’s stdio; if that subprocess exits, all it can report is that the pipe closed.- Running the launch command directly is the key move. Under the Inspector,
the server’s stderr (where Python prints tracebacks) is easy to miss; run
uv run server_broken.pyin your shell and the traceback prints plainly. This separates “the server is broken” from “the client/config is broken.” - A subtler variant of this bug writes to stdout instead of crashing — a stray
print(...)in a stdio server injects non-JSON into the protocol stream. Some clients tolerate a stray line; others drop the connection. Keep all diagnostics on stderr (print(..., file=sys.stderr)or a logger), and reserve stdout for the protocol.
Step 8: Lock the behavior in with a test
The Inspector is for interactive debugging; a test suite pins the behavior so a regression fails automatically. Drive the same server in-memory with FastMCP’s client.
Create the files
mkdir -p tests
touch pytest.ini tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
pythonpath = .
filterwarnings =
ignore::DeprecationWarning
Add the code: tests/test_server.py
"""Lock in the server's behavior with the in-memory FastMCP client.
The Inspector is for interactive, exploratory debugging; these tests pin the same
tool and resource results so a regression fails in CI, not just under your eyes.
"""
import json
import pytest
from fastmcp import Client
from server import mcp
@pytest.fixture
async def client():
async with Client(mcp) as c:
yield c
async def test_lists_tools_and_resource(client):
tool_names = {t.name for t in await client.list_tools()}
assert tool_names == {"add", "greet"}
resource_uris = {str(r.uri) for r in await client.list_resources()}
assert resource_uris == {"info://server"}
async def test_add(client):
result = await client.call_tool("add", {"a": 40, "b": 2})
assert result.data == 42
async def test_greet_shout(client):
result = await client.call_tool("greet", {"name": "Ada", "shout": True})
assert result.data == "HELLO, ADA!"
async def test_read_info_resource(client):
contents = await client.read_resource("info://server")
payload = json.loads(contents[0].text)
assert payload == {"name": "inspector-demo", "version": "0.1.0"}
Run the suite:
uv run pytest -q
.... [100%]
4 passed in 0.28s
Detailed breakdown
pythonpath = .puts the project root onsys.pathsofrom server import mcpresolves from insidetests/.asyncio_mode = autolets the async test functions run without an explicit marker on each.Client(mcp)connects to the server object directly in-process — no subprocess, no stdio, no Inspector — which is fast and deterministic. It exercises the same tool and resource code the Inspector reached over the wire.result.datais FastMCP’s decoded structured result (42,"HELLO, ADA!"), the client-side counterpart to thestructuredContentyou saw in Step 5.
Step 9: The Makefile
Wrap every command so plain make prints help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# Launch the server the way a client does: `uv run server.py` over stdio.
SERVER := uv run server.py
INSPECT := npx -y @modelcontextprotocol/inspector --cli $(SERVER)
.PHONY: help ui inspect-tools inspect-call inspect-resource run test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-18s %s\n", $$1, $$2}'
ui: ## Open the Inspector web UI wired to the server (Ctrl-C to stop)
npx -y @modelcontextprotocol/inspector $(SERVER)
inspect-tools: ## List the server's tools and their schemas (Inspector CLI)
$(INSPECT) --method tools/list
inspect-call: ## Call the add tool with a=2 b=3 (Inspector CLI)
$(INSPECT) --method tools/call --tool-name add --tool-arg a=2 b=3
inspect-resource: ## Read the info://server resource (Inspector CLI)
$(INSPECT) --method resources/read --uri info://server
run: ## Run the server on stdio directly (surfaces startup tracebacks)
$(SERVER)
test: ## Run the pytest suite
uv run pytest -q
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Verify the default target prints help:
make
help Show this help screen
ui Open the Inspector web UI wired to the server (Ctrl-C to stop)
inspect-tools List the server's tools and their schemas (Inspector CLI)
inspect-call Call the add tool with a=2 b=3 (Inspector CLI)
inspect-resource Read the info://server resource (Inspector CLI)
run Run the server on stdio directly (surfaces startup tracebacks)
test Run the pytest suite
clean Remove caches
Detailed breakdown
SERVERandINSPECTvariables keep the launch command in one place, so the UI target, the CLI targets, and the direct-run target all agree on how the server starts.rundeliberately runs the server without the Inspector. It is the Step 7 diagnosis move as a target: when a handshake fails,make runsurfaces the traceback the Inspector hides..DEFAULT_GOAL := helpmakes baremakeprint the target list. Makefile recipes must be tab-indented.
Troubleshooting
npxprompts to install, or is slow the first time. The-yflag auto-confirms; the first run downloads the Inspector package and caches it. Later runs are fast.Failed to connect: Connection closed(-32000). The server exited before the handshake. Run the launch command directly (uv run server.py) to see the traceback on stderr — the technique from Step 7.- The connection works but a tool call returns
"isError": true. That is the server reporting a tool-level error, not a transport failure; read the message in thecontentblock. The handshake is fine. resources/readsays the resource was not found. The--urimust match a registered URI exactly (info://serverhere). Runresources/listfirst to see the available URIs.- A stray
print()breaks intermittent clients. On stdio, stdout is the protocol. Send all logging to stderr and keep stdout clean. - UI won’t open / token rejected. Copy the full URL (including the
?MCP_PROXY_AUTH_TOKEN=...query string) that the Inspector prints; opening the bare host without the token is rejected.
Recap
- The MCP Inspector needs no install:
npx @modelcontextprotocol/inspector <launch command>opens the UI, and adding--cli --method <name>runs a single, scriptable request that prints JSON. tools/listshows the schemas clients actually see;tools/call --tool-name X --tool-arg k=vinvokes a tool;resources/listandresources/read --uricover resources.- A broken handshake shows up as
Connection closed; the fix is to run the launch command directly and read the traceback, because the server’s stderr is where the real cause is. - Keep stdout clean on stdio servers, and pin behavior with an in-memory
pytestsuite so regressions fail on their own.
Next improvements
- Inspect prompts with
--method prompts/listand--method prompts/get --prompt-name ...once your server registers prompts. - Test an HTTP server by adding
--transport http --server-url http://127.0.0.1:8000/mcpinstead of a launch command. - Wire the CLI checks into CI so a schema change or a broken handshake fails a
pull request, using the same
make inspect-*targets. - Save a config file (
--config,--server) to store launch commands and headers for several servers you inspect regularly.