Skip to content
HUIAHUB

TutorialAgents & automation

How to test MCP server tools with the Inspector, unit tests and a client script

Test MCP server tools in three layers: explore them in the MCP Inspector, unit test the tool logic, and run a scripted client check in CI.

Edited by Published 7 min read11 sources

To test MCP server tools before an agent uses them, run three checks that catch different faults. Open the server in the MCP Inspector to call each tool by hand, unit test the tool code with pytest, and run a scripted client session in CI that lists and calls the tools over the real transport.

The examples below use the official Python SDK (v2, where the high-level server class is MCPServer) and the Inspector package @modelcontextprotocol/inspector. The Inspector needs Node 22.19.0 or newer, and the Python SDK needs Python 3.11 or newer.

What can break in an MCP server

Most MCP bugs fall into a few groups, and each one shows up at a different layer.

  • Tool logic. The function returns the wrong value or crashes on an edge case.
  • Schema. The Python SDK builds each tool's input schema from its type hints, so a missing default makes an argument required, and a wrong type rejects valid calls.
  • Errors. A tool that returns an error string instead of raising one reports success. The SDK docs warn that a returned string has is_error=False, so the client treats it as a normal answer.
  • Transport. On stdio, stdout is the wire. The SDK docs note that output printed to stdout before serving starts still lands on the wire and can corrupt it.
  • Description. The docstring becomes the description the model reads. A vague one passes every test and still confuses an agent.

Exploring the server with the MCP Inspector

The MCP Inspector (opens in a new tab) is the reference tool for testing and debugging MCP servers. One package ships three clients: a web UI (the default), a CLI with --cli and a terminal UI with --tui. It runs through npx without an install step, and its source lives in the modelcontextprotocol/inspector repository (opens in a new tab).

Launch the web client

Pass the command that starts your server as arguments. For a Python server, the SDK also has a shortcut that opens the Inspector for you.

# Any stdio server: the Inspector spawns it as a subprocess
npx @modelcontextprotocol/inspector uv run server.py

# Python SDK shortcut (needs the "mcp[cli]" extra)
uv run mcp dev server.py

The launcher prints a URL that carries a per-launch API token. Open that URL rather than typing the port from memory, because the backend rejects requests without the token.

What to check in the Tools tab

The Tools tab shows each tool's description, renders its input schema as a form and displays the result of a call. Work through every tool once:

  1. Read the description as if you were the model choosing between tools.
  2. Check which fields the form marks as required.
  3. Call the tool with good input, then with bad input, and compare the results.
  4. Watch the Protocol tab for the raw JSON-RPC exchange. For stdio servers, the Console tab shows the process's stderr, which is where your logs and tracebacks go.

The Inspector is the fastest way to see what a client sees. It is also manual, so it belongs at the start of the work, not in the merge gate.

Unit tests for tool functions

Two kinds of test fit here. Plain unit tests cover the logic, and in-memory protocol tests cover the tool as a client calls it.

Keep the logic in plain functions

Put the real work in ordinary functions and keep the decorated tool thin. That way pytest can test the logic without any MCP machinery.

# catalog.py
CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}


def find_author(title: str) -> str | None:
    return CATALOG.get(title.strip())
# server.py
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

from catalog import find_author

mcp = MCPServer("Bookshop")


@mcp.tool()
def get_author(title: str) -> str:
    """Look up the author of a book in the catalog."""
    author = find_author(title)
    if author is None:
        raise ToolError(f"No book titled {title!r} in the catalog.")
    return author


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

The if __name__ == "__main__": guard matters. The SDK docs point out that mcp dev, mcp run and your tests all import this file, and the guard stops an import from starting a server.

Test through the protocol in memory

The SDK's Client connects straight to a server object, with no subprocess, port or network. Calls still pass through the protocol layer, so tools are listed, validated and invoked as they would be over HTTP. Install the test dependencies with uv add --dev pytest inline-snapshot (the snapshot library is optional).

# test_server.py
import pytest
from mcp import Client

from server import mcp


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture
async def client():
    async with Client(mcp, raise_exceptions=True) as c:
        yield c


@pytest.mark.anyio
async def test_tool_is_listed(client: Client):
    result = await client.list_tools()
    assert "get_author" in [tool.name for tool in result.tools]


@pytest.mark.anyio
async def test_known_title(client: Client):
    result = await client.call_tool("get_author", {"title": "Dune"})
    assert not result.is_error
    assert result.content[0].text == "Frank Herbert"


@pytest.mark.anyio
async def test_unknown_title_is_a_tool_error(client: Client):
    result = await client.call_tool("get_author", {"title": "Nothing"})
    assert result.is_error

Keep raise_exceptions=True in tests. Per the SDK testing guide, an exception inside a tool still comes back as a result with is_error=True. A failure outside the tool body is normally sanitized to a generic internal error, and this flag lets the test see the real message instead.

A client script that lists and calls tools

The in-memory test skips the transport. A short client script closes that gap by launching the server the way a host does. The same Client class accepts StdioServerParameters and spawns the server as a subprocess.

# smoke_test.py
import asyncio
import sys

from mcp import Client, StdioServerParameters

EXPECTED_TOOLS = {"get_author"}


async def main() -> int:
    server = StdioServerParameters(command="uv", args=["run", "server.py"])
    async with Client(server) as client:
        listed = await client.list_tools()
        missing = EXPECTED_TOOLS - {tool.name for tool in listed.tools}
        if missing:
            print(f"missing tools: {sorted(missing)}", file=sys.stderr)
            return 1
        result = await client.call_tool("get_author", {"title": "Dune"})
        if result.is_error:
            print(f"tool error: {result.content}", file=sys.stderr)
            return 1
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))

The child process does not inherit your whole environment. On POSIX it gets a short allow-list (HOME, LOGNAME, PATH, SHELL, TERM, USER), and anything else must go in env=. A server that reads an API key from the environment can pass its unit tests and still fail here, which is the point of this layer.

How to test MCP server transports: stdio and HTTP

The transport decides how bytes move between client and server. The Python SDK supports stdio (the default for mcp.run()), Streamable HTTP and the older SSE transport, which the SDK docs say not to use for new work.

For stdio, the Inspector CLI runs a single request and exits. It is the quickest transport check.

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

npx @modelcontextprotocol/inspector --cli uv run server.py \
  --method tools/call --tool-name get_author --tool-arg title=Dune

For Streamable HTTP, start the server on a port and point the client at its URL. The SDK README pairs mcp run with a client at http://localhost:8000/mcp.

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

npx @modelcontextprotocol/inspector --cli http://localhost:8000/mcp \
  --transport http --method tools/list --format json

In Python, passing a URL string to Client selects Streamable HTTP, so the smoke test above works over HTTP when you swap StdioServerParameters for "http://localhost:8000/mcp". Test both transports if your users will run both, because stdout handling only affects stdio and network failures only affect HTTP.

Running the checks in CI

The Inspector CLI gives each failure its own exit code, which makes it easy to gate a build. Per the CLI reference (opens in a new tab), exit code 4 means the server is unreachable, 5 means a tool call returned isError: true or the tool was not found, and 6 means --strict found an error-level schema portability problem.

#!/usr/bin/env bash
set -euo pipefail

# 1. Logic and in-memory protocol tests
uv run pytest

# 2. Schema portability gate (exits 6 on an error-level finding)
npx -y @modelcontextprotocol/inspector --cli uv run server.py \
  --method tools/list --strict > /dev/null

# 3. The tool exists under the expected name
npx -y @modelcontextprotocol/inspector --cli uv run server.py \
  --method tools/list --format json \
  | jq -e '.result.tools | map(.name) | index("get_author")' > /dev/null

# 4. A real call over stdio (exits 5 if the tool reports an error)
npx -y @modelcontextprotocol/inspector --cli uv run server.py \
  --method tools/call --tool-name get_author --tool-arg title=Dune > /dev/null

# 5. The scripted client session
uv run python smoke_test.py

If the server needs OAuth, add --stored-auth-only. Without it, the CLI would try to open a browser for the login flow, which a CI job cannot complete. On any non-zero exit, the CLI also writes a single JSON error line to stderr with a stable code field such as unreachable or tool_is_error.

Which layer catches which problem

Use this table to decide where a new test belongs. It reflects the behavior described above; the bookshop server is illustrative.

ProblemInspector by handUnit tests (plain + in-memory)Client script and CLI in CIReal agent session
Wrong return valueYes, onceYes, every runPartlyRarely obvious
Argument required by mistakeYes, in the formYes, if a test omits itOnly if a call omits itSometimes
Schema other clients cannot useNoNoYes, --strict exit code 6Depends on the host
Error returned as a stringEasy to missYes, assert is_errorYes, exit code 5Model acts on bad data
Stray stdout output on stdioUsually, as a broken connectionNo (no transport)YesYes
Missing environment variableDepends on your shellNoYesYes
HTTP endpoint unreachableYesNoYes, exit code 4Yes
Vague tool descriptionOnly if you read itNoNoYes
Model picks the wrong toolNoNoNoYes

What only a real agent session shows

The three layers prove the server behaves correctly when called correctly. They cannot show whether a model will call it correctly. That depends on tool names, docstrings and argument descriptions, which no assertion checks.

Connect the server to a host such as Claude Code and give the agent a task that should need each tool. Read the transcript, not just the final answer. Look for the wrong tool being picked, arguments the agent guesses, and errors it fails to recover from. The SDK docs describe a ToolError message as something the model reads and can act on, so check that your error text tells it what to try next.

When the agent misbehaves, fix the description or the schema, then add a regression test at the lowest layer that can catch the same fault. If you are still building the server, the MCP server builder skill covers the design side, and the Python SDK repository (opens in a new tab) links the full client and testing docs.

Frequently asked questions

Do I need the Python SDK to use the MCP Inspector?

No. The Inspector is a Node package that connects to any MCP server over stdio, Streamable HTTP or SSE, whatever language the server is written in. You pass it the command that starts the server or the server's URL. The Python SDK only adds the `mcp dev` shortcut that opens the Inspector for a Python file.

Can I run the Inspector web UI on a shared machine or in a container?

The web backend binds to 127.0.0.1 by default and guards its API with a per-launch token, because it can spawn processes on your machine. Binding all interfaces is refused unless you set `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Read the Inspector's security page before exposing it, and prefer the CLI for headless runs.

Where does the Inspector keep tokens and environment values?

It stores OAuth tokens, client secrets and stdio `env:` values in the OS keychain when one is available. On machines without one, such as headless Linux, SSH sessions and many containers, it writes them to `~/.mcp-inspector/secrets.json`, unencrypted unless you supply a key. Check this before running the Inspector on a CI runner with real credentials.

My server was written with FastMCP from the v1 SDK. Do these tests still apply?

The approach does, but the imports changed. In v2, `FastMCP` is now `MCPServer`, imported from `mcp.server`, and the old `mcp.server.fastmcp` path is gone. The in-memory `Client(server)` pattern replaces v1's `create_connected_server_and_client_session()` helper, and the migration guide lists the other renames.

Sources

  1. 1.MCP Inspector - Model Context ProtocolOfficial docsPrimarymodelcontextprotocol.io
  2. 2.GitHub - modelcontextprotocol/inspector: Visual testing tool for MCP servers · GitHubRepositoryPrimarygithub.com
  3. 3.GitHub - modelcontextprotocol/python-sdk: The official Python SDK for Model Context Protocol servers and clients · GitHubRepositoryPrimarygithub.com
  4. 4.modelcontextprotocol.io/docs/2026-07-28/tools/inspector/webWeb
  5. 5.modelcontextprotocol.io/docs/2026-07-28/tools/inspector/cliWeb
  6. 6.py.sdk.modelcontextprotocol.io/whats-newWeb
  7. 7.py.sdk.modelcontextprotocol.io/get-started/testingWeb
  8. 8.py.sdk.modelcontextprotocol.io/client/transportsWeb
  9. 9.py.sdk.modelcontextprotocol.io/runWeb
  10. 10.py.sdk.modelcontextprotocol.io/servers/toolsWeb
  11. 11.py.sdk.modelcontextprotocol.io/servers/handling-errorsWeb

Spotted an error? See our corrections policy.

Enjoyed this? Get the weekly brief.

New guides, Project & Tool of the Week and the AI developments that matter. One email, no spam.

Read next