How to test an MCP server: MCP Inspector, curl and pytest
By Arshad Ansari
You test an MCP server the same way you test any API: call it directly, without a model in the loop. If a tool misbehaves inside Claude or Cursor, you can't tell whether the server failed or the model asked badly. A plain client takes the model out of the question.
This post shows how to test an MCP server three ways. I ran all three against the same small FastMCP server, a read-only DuckDB query tool from the FastMCP post. The tools are the MCP Inspector CLI, plain curl and pytest. Then I cover the stdout bug that breaks stdio servers, and how I debug tool calls in production, where a test suite can't help.
The setup: FastMCP 4.0.10, MCP Python SDK 2.2.0, DuckDB 1.5.6, Python 3.13, Node 26. Every output below is copied from those runs on 2 October 2026.
The MCP Inspector CLI
The MCP Inspector is the official testing tool. One npm package holds three clients: a web UI (the default), a terminal UI (--tui) and a CLI (--cli). It needs Node 22.19 or newer. I used version 2.9.0, published on 30 September 2026.
The web UI is good for exploring. The CLI is better for testing, because each run sends one request, prints the result and exits. To list a stdio server's tools, pass the command that starts the server:
npx @modelcontextprotocol/inspector --cli python server.py --method tools/list
That printed both tools with their schemas and the readOnlyHint annotation. To call a tool, add --tool-name and the arguments:
npx @modelcontextprotocol/inspector --cli python server.py \
--method tools/call --tool-name query \
--tool-arg 'sql=SELECT kind, count(*) AS n FROM orders GROUP BY kind ORDER BY n DESC' \
--format json
{"result":{"content":[{"type":"text","text":"{\"columns\":[\"kind\",\"n\"],\"rows\":[[\"view\",\"16667\"],[\"cart\",\"16667\"],[\"buy\",\"16666\"]],\"truncated\":false}"}],"structuredContent":{"columns":["kind","n"],"rows":[["view","16667"],["cart","16667"],["buy","16666"]],"truncated":false},"isError":false}}
That took 1.1 seconds, including starting Python and the server.
The part that makes it a testing tool is the exit code. The CLI docs define six. I checked the two that matter most for a data server:
| Call | Exit code | Last line on stderr |
|---|---|---|
query with DELETE FROM orders | 5 | {"error":{"code":"tool_is_error","message":"Tool 'query' returned isError:true."}} |
drop_table, which doesn't exist | 5 | {"error":{"code":"tool_not_found","message":"Tool 'drop_table' not found on server."}} |
The refused DELETE still printed its payload ("attached in read-only mode"), but exited 5. So a shell && chain stops on a failed call. Exit 3 means the server wants authentication and 4 means it is unreachable. That is enough to put a few Inspector calls in CI and fail the build when a tool disappears or a refusal stops refusing.
Which protocol version did it test?
The first initialize reported "protocolVersion":"2025-11-25". That is not the latest spec. The Inspector's protocol eras page explains why: it defaults to the legacy era on purpose, so a debugging tool never probes a server you didn't ask it to probe. To test against 2026-07-28, set protocolEra in a config file:
{"mcpServers": {
"shop-modern": {"command": "python", "args": ["server.py"], "protocolEra": "modern"}
}}
npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server shop-modern --method initialize
That returned "protocolVersion":"2026-07-28". The same server answered both eras. If your users run different clients, test both.
How to test an MCP server with curl
curl only works for HTTP servers, but it shows you exactly what goes over the wire. I started the same server with mcp.run(transport="http", port=8765).
The 2026-07-28 revision made this much simpler. It removed the initialize handshake and the Mcp-Session-Id session. Every request now stands alone. It carries the protocol version and client capabilities in params._meta, and copies the method and tool name into headers. So one curl command is one complete tool call:
curl -s http://127.0.0.1:8765/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: query' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"query","arguments":{"sql":"SELECT count(*) FROM orders"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
It returned HTTP 200 and the rows, with "resultType":"complete" and the server's name in _meta. Four other requests showed how strict the server is:
| Request | Result |
|---|---|
server/discover (new in 2026-07-28) | 200, "supportedVersions":["2026-07-28"] and the capabilities |
tools/call without Mcp-Method and Mcp-Name | 400, error -32020: "mcp-method header does not match the request body's method" |
tools/list without the Accept header | 200. The spec says clients MUST send it; this server didn't insist |
Old-style tools/list with no _meta and no session | 400, "Bad Request: Missing session ID" |
The second row is the one that catches people. A hand-written client, or a gateway that rewrites requests, can send a perfect JSON body and still get a 400 because the headers don't match it. The Streamable HTTP spec requires them so proxies can route on the method without reading the body.
Test MCP server tools in pytest, in memory
The Inspector and curl are for poking. For tests that run on every commit, FastMCP's testing docs recommend wrapping the server object in a client. No subprocess, no port:
import pytest
from fastmcp import Client
from server import mcp
@pytest.fixture
async def client():
async with Client(mcp) as c: # in-memory transport
yield c
@pytest.mark.parametrize("sql, expected", [
("DELETE FROM orders", "read-only mode"),
("SELECT * FROM read_csv('/etc/passwd')", "disabled by configuration"),
("SET enable_external_access = true", "configuration has been locked"),
])
async def test_refusals(client, sql, expected):
r = await client.call_tool("query", {"sql": sql}, raise_on_error=False)
assert r.is_error and expected in r.content[0].text
My file had seven tests: the tool list and its read-only annotations, a correct count, the 200-row cap with truncated: true, the three refusals above, and the runaway query being stopped. Result: 7 passed in 6.72s. Five of those seconds were the timeout test, waiting for the 5-second limit to fire.
Test the refusals, not only the happy path. The happy path is what the model will try first. The refusals are what stop it when it tries something else, and they are the part that silently stops working when someone changes a setting.
The stdout rule, and what it looks like when you break it
The stdio transport spec has one rule that causes most "my server won't connect" reports: the server MUST NOT write anything to stdout that is not an MCP message. stdout is the wire. Logging goes to stderr, which the client may capture, show or ignore.
I broke the rule on purpose, twice.
A print() inside a tool. Nothing broke. The MCP Python SDK v2 diverts file descriptor 1 to stderr while it serves, and keeps a private copy for the protocol. The debug line appeared in stderr. But only with flush=True: without it, the line never appeared at all, because Python buffers stdout when it isn't a terminal.
A print() at import time, before mcp.run(). That one reached the wire. The FastMCP client logged a traceback:
pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='connected to shop.duckdb', input_type=str]
Then it carried on, and the tool call still worked. The Inspector skipped the line silently. Both clients were forgiving. I didn't test Claude Code, Cursor or other SDKs, which may not be. The fix is the same either way: log to stderr from the first line, and treat any print in a stdio server as a bug.
Logging changed in 2026-07-28 as well. The logging/setLevel method is gone, and the protocol's Logging feature is deprecated. The changelog's advice is to log to stderr for stdio servers, or use OpenTelemetry. FastMCP 4 emits OpenTelemetry spans for tool calls on its own; you add an exporter.
Debugging MCP tool calls in production
Tests prove the server works for the calls you thought of. In production the model makes calls you didn't think of, and you need a record of them. My own system, AEGIS, has a registry of 80 tools and serves them to agent runs over MCP. Its MCP server has 82 tests across six files. They test what the server does. They couldn't tell me what the agents were doing with it. Here is what I learned, from api/routes/mcp_server.py and its tests.
Record every call, with an outcome you can query. Every terminal path in the tools/call handler writes one row through record_tool_call, with a status: success, error, timeout, invalid_args, not_granted or gate_blocked. Each row also records the surface it came from: the in-process chat loop, or one of three MCP mounts. This was added late. Before it, the MCP surface wrote nothing to that table. The test file's docstring records the cost: infra tools returning exit code 127 went unnoticed for six weeks.
A "success" can be a failure. Many tools report failure by returning {"error": ...} instead of raising. The handler parses each result and records a success whose payload carries an error as error. Without that rule, the log said "success" while the tool did nothing.
Return bad arguments as a tool result, not a protocol error. When arguments fail schema validation, AEGIS answers with isError: true and a hint showing the expected schema. The model reads that and retries with fixed arguments. A JSON-RPC error would end the call for good.
Keep application errors at HTTP 200. The server sends its JSON-RPC errors inside a 200 response on purpose. A compliant client treats a non-2xx status as a transport failure and raises before reading the body, which hides the one message the caller needed.
Test the classification itself. One test reads the source of every tool on the read-only allow-list and fails if any of them contains an INSERT, UPDATE, DELETE or a workflow start. The comment calls it "a tripwire on the allow-list, not a static analyser". It stops a "read" tool from gaining a write without anyone noticing.
A checklist for testing an MCP server
- List the tools from a plain client and compare them with what you meant to serve. A tool you forgot to remove is one the model can call.
- Call every tool once with the Inspector CLI, and check the exit code, not only the output.
- Test the refusals in pytest: writes, file reads, oversized results, runaway queries.
- Test both protocol eras if your users run different clients.
- For HTTP servers, send one raw curl request, so you know which headers your server requires.
- Keep stdout clean in stdio servers, and log to stderr from the first line.
- Log every call in production with its arguments, outcome and duration, and treat an error payload as an error.
So: how do you test an MCP server?
Without the model. A model hides server bugs behind its own guesses. It retries, rephrases, or tells you the data looks fine. The Inspector CLI, curl and an in-memory client each answer one question exactly: what did the server return for this request? Put the refusals in pytest, put a few Inspector calls in CI, and log every call once it is live.
The security side, meaning who may call the server at all, is in MCP security and authentication.
Local-First Analytics doesn't cover MCP or testing MCP servers. It covers DuckDB and Parquet on your own hardware. The closest part is Chapter 7, data quality gates: schema checks and business rules in DuckDB that run like tests and fail the pipeline, the same habit as testing your server's refusals. Chapter 1 is free to read; the rest is on Amazon.
If you run an MCP server over data your team depends on and want its tests and logs checked before an agent finds the gap, that is work I do. If you already have the checklist above in CI, you probably don't need me.
More in this series: building an MCP server with FastMCP, DuckDB MCP server, Postgres MCP server and MCP security and authentication.
Common questions
- How do I test an MCP server?
- Test it without a model first. The MCP Inspector CLI (npx @modelcontextprotocol/inspector --cli) launches a stdio server or connects to an HTTP one, runs a single method such as tools/list or tools/call, prints JSON and exits with a code that says what failed. For an HTTP server, plain curl works too. For tests that run on every commit, use your framework's in-memory client: in FastMCP, Client(mcp) calls the server object directly, with no subprocess.
- How do I test an MCP server with MCP Inspector?
- Run npx @modelcontextprotocol/inspector for the web UI, or add --cli for a scriptable client: for example, npx @modelcontextprotocol/inspector --cli python server.py --method tools/call --tool-name query --tool-arg sql="SELECT 1" --format json. It needs Node 22.19 or newer. It exits 0 on success, 5 when a tool returns isError or does not exist, 3 when the server needs authentication and 4 when it cannot be reached, so it works in CI and shell scripts.
- How do I test an MCP server with curl?
- Send one HTTP POST per JSON-RPC message to the server's endpoint, with an Accept header listing application/json and text/event-stream. Under the 2026-07-28 protocol there is no initialize handshake or session: each request carries MCP-Protocol-Version, Mcp-Method and (for tools/call) Mcp-Name headers, and the protocol version and client capabilities in params._meta. A FastMCP 4.0.10 server rejected a call without the Mcp-Method header with HTTP 400 and error -32020.
- How do I debug an MCP server that uses stdio?
- Never write anything to stdout except MCP messages; the spec forbids it, because stdout is the wire. Log to stderr instead, which clients may capture or show. The MCP Python SDK v2 diverts stdout to stderr while it is serving, so a print() inside a tool did not break my server, but a print() that ran before mcp.run() went onto the wire and the client logged an "Invalid JSON" error. Then reproduce the failing call with the Inspector CLI, outside the model.
- How do I test an MCP server locally in Python?
- With FastMCP, import the server object into a pytest file and wrap it in a client: async with Client(mcp) as c. Calls go through the full MCP request path in memory, so you can assert on tool lists, results, error flags and limits. My seven tests for a read-only DuckDB server ran in 6.7 seconds, five of them spent proving the query timeout fires.
Get new posts by email
Data engineering notes like this one — what breaks and what it costs, in production.
What breaks and what it costs — pipelines, warehouse bills, and the failures that only show up in production. A few a month, never padded to hit a schedule. No sequence, no pitch deck. Reply 'stop' once and you're off — it reaches me, not a queue.
Want the whole playbook?
If this was useful, the long version is my book. Local-First Analytics — 313 pages, runnable code for every chapter — is the full build: DuckDB, Parquet and Arrow, from install to production. On Amazon, and chapter 1 is free to read here.
Get the bookNot ready to buy? Read chapter 1 free — the whole chapter, no email required.
Rather talk it through? Book a free 30-minute call. No slot that suits your time zone? Email info@hikmahtech.in.