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 pytest suite that pins the same behavior, and a Makefile that wraps every command.

Prerequisites

  • uv 0.11+ (brew install uv). Verify with uv --version. This tutorial was validated on uv 0.11.26.
  • Node.js 18+ with npx on PATH (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 . creates pyproject.toml and a virtual environment marker; rm -f main.py hello.py clears the sample entry point uv scaffolds (the name varies by uv version), since this project’s entry point is server.py.
  • uv add "fastmcp>=3.4.4" pins the server framework; the dev group adds pytest and pytest-asyncio for the lock-in suite in Step 8.
  • .gitignore first, 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.tool registers 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 a dict makes 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

  • --cli switches 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/list is the raw MCP method name. The output is the discovery payload a real client receives: each tool’s name, description, and inputSchema (derived from the Python type hints). add requires two integers; greet requires name and marks shout optional with "default": false, because the Python parameter has a default.
  • outputSchema and x-fastmcp-wrap-result appear because FastMCP infers a structured output schema from the return type and wraps a bare return value under a result key. 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=3 passes multiple arguments after a single flag. The Inspector coerces the values against the tool’s inputSchema: a=2 becomes the integer 2, and shout=true becomes the boolean true.
  • The response carries both content and structuredContent. The content array is the human-readable text block ("5"); structuredContent is the typed payload ({"result": 5}) that matches the outputSchema from Step 4. "isError": false confirms the call succeeded — a tool that raised would set it true and put the message in content.

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/list returns each resource’s uri, name (the Python function name, server_info), and mimeType. FastMCP defaults the MIME type to text/plain here.
  • resources/read --uri info://server fetches the content. The dict the function returned is serialized to JSON text, so text holds 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.py in 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 on sys.path so from server import mcp resolves from inside tests/. asyncio_mode = auto lets 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.data is FastMCP’s decoded structured result (42, "HELLO, ADA!"), the client-side counterpart to the structuredContent you 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

  • SERVER and INSPECT variables 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.
  • run deliberately runs the server without the Inspector. It is the Step 7 diagnosis move as a target: when a handshake fails, make run surfaces the traceback the Inspector hides.
  • .DEFAULT_GOAL := help makes bare make print the target list. Makefile recipes must be tab-indented.

Troubleshooting

  • npx prompts to install, or is slow the first time. The -y flag 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 the content block. The handshake is fine.
  • resources/read says the resource was not found. The --uri must match a registered URI exactly (info://server here). Run resources/list first 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/list shows the schemas clients actually see; tools/call --tool-name X --tool-arg k=v invokes a tool; resources/list and resources/read --uri cover 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 pytest suite so regressions fail on their own.

Next improvements

  • Inspect prompts with --method prompts/list and --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/mcp instead 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.