Skip to content
Chapter 2 of 7 · Debugging MCPDebugging with the MCP Inspector

16 min read

Debugging with the MCP Inspector

On this page

An inspector is a client that performs the MCP handshake for you and then hands you the part after it. You choose the tool, you type the arguments, and it shows you the JSON-RPC frames that result — the small JSON objects, each carrying a method name and an id, that the two sides exchange over the wire. The reference implementation is @modelcontextprotocol/inspector, which runs either as a browser UI or, with --cli, as a one-shot command suitable for a script.

Its obvious use is to make a suspected defect happen on demand, with inputs that do not change between attempts. Its less obvious use is the one that decides investigations, and it depends on a property of what the inspector has taken away rather than on anything it adds: because a direct run excludes exactly two participants, a result that differs between the two settings tells you which of the two changed the behaviour.

A client you are allowed to be dishonest with

A server of the kind built in topic 6.10, on writing an MCP server, will not answer a bare tools/call. It expects an initialize request carrying a protocol version and the client's capabilities, a response in which it declares its own, an initialized notification acknowledging the handshake, and a session that survives across calls so that cursors, subscriptions and any per-connection state still exist when the second request arrives. An inspector performs all of that, then lets you write everything after it yourself.

What it removes is two layers, and they are the two that generate the variables catalogued in chapter 1, where a failed agent run was read as evidence:

Flowchart: Model → Client application; Client application → Your server (stdio or HTTP); Browser UI or CLI → Inspector proxy; Inspector proxy → Your server (stdio or HTTP).

The model is gone, so the arguments are yours and identical on every run. The client application is gone, so nothing validates your arguments before they leave, nothing applies a timeout you did not choose, nothing rewrites the response before you see it, and nothing collapses a forty-kilobyte payload into a summary card. What sits in their place is a thin proxy whose entire job is to move frames between a browser and a child process or an HTTP endpoint, and whose behaviour you can read in a few hundred lines if a question about it ever becomes load-bearing.

The proxy is also why the inspector can show you both directions at once. In the UI, the history pane lists every JSON-RPC message with its direction, its method and its id, which means the exchange you just performed is visible as frames rather than as rendered output. The distinction between the frames and somebody's rendering of them is the hazard a screenshot creates, and here it stops being a hazard because both are on screen together.

A single claim, settled, fits in one terminal window:

bash
npx @modelcontextprotocol/inspector --cli node ./build/server.js \
  --method tools/call \
  --tool-name search_orders \
  --tool-arg 'customer=Acme Corp' \
  --tool-arg 'since=last month'
json
{
  "content": [{ "type": "text", "text": "{\"orders\":[],\"count\":0}" }],
  "isError": false
}

That is claim one confirmed. The handler parses last month to null, the null becomes an unbounded lower bound that the query then intersects with a default upper bound of "now minus nothing", and the result is an empty set delivered with every appearance of success. No model was involved, and the same command run again produces the same bytes.

Two more claims took the same form and the three together took ninety-four seconds to settle. Case-sensitive customer matching came back confirmed, with customer=acme returning twenty rows and customer=Acme Corp returning zero against a database that contains both spellings in different rows. The third claim was wrong: setting limit=500 returned twenty items with truncated: true, but the cap turned out to be a page size rather than a ceiling, and a second call carrying the cursor from the first returned the next twenty. The tool paginates correctly and says so; what it does not do is make the cursor legible enough that a model ever follows it. Being able to be wrong about one of three is the property that matters here, and it is a property of controlling the inputs rather than of the tool being fast to type.

A procedure that can only ever come back confirmed is not measuring the server; it is reporting the shape of the hypothesis, and these hypotheses were written by somebody who had already decided what was wrong. So the claim that failed is what certifies the two that held. It is also worth seeing why that claim was plausible and still false. The transcript's evidence for a ceiling — twenty items returned against five hundred requested, with a flag saying the set had been cut — is exactly what a page size looks like from the outside, and no amount of re-reading separates them, because both stories predict the same response. One further call separates them. A ceiling has nothing behind it and a page does, so holding the cursor and spending it decides which of the two you are looking at.

What is worth exercising before a client ever connects

A tool that returns the right rows for well-formed arguments is the easy half of a server's surface. The half that produces support tickets is everything around it, and the inspector reaches all of it without a model deciding whether the case is interesting.

Start with tools/list, and read the schemas rather than the names. This is where a loose since is visible as a bare {"type": "string"} with no format, and where you find out whether the description a model reads actually mentions the pagination cursor the tool returns. Then run each tool's failure paths deliberately: a required argument omitted, an enum value that is not in the enum, an id that does not exist, a value at the boundary of a range. What you are checking is that failures come back as a result with isError: true and a message a model could act on, rather than as a JSON-RPC -32603, the protocol's internal-error code, which most clients surface to the user as a red box and to the model as nothing useful.

Resources and prompts deserve the same pass, particularly resources/read on a URI that has been deleted, and pagination on any list that can exceed a page. Cursors are a common source of a specific and embarrassing bug: a server that generates a cursor from an offset into a result set it recomputes on every call will silently skip or duplicate rows when the underlying data changes between pages, and the only way to see it is to hold a cursor for a few seconds before spending it, which no agent will ever do on purpose.

The CLI mode matters because it turns each of those into a line that a CI job can run. A shell script that starts the server, performs the handshake, calls six tools with fixed arguments and diffs the results against recorded fixtures is a conformance test for your own surface, and it runs in the low hundreds of milliseconds per call rather than the twenty-to-sixty seconds an agent round trip costs.

bash
set -euo pipefail
for case in fixtures/*.json; do
  npx @modelcontextprotocol/inspector --cli node ./build/server.js \
    --method tools/call \
    --tool-name "$(jq -r .tool "$case")" \
    $(jq -r '.args | to_entries[] | "--tool-arg \(.key)=\(.value)"' "$case") \
    | jq -S . > "results/$(basename "$case")"
  diff -u "expected/$(basename "$case")" "results/$(basename "$case")"
done

"How to Test MCP Servers: Tools, Debugging, and CI" works through the fuller version of this loop, including how to keep the fixtures from rotting as the surface changes.

The other thing worth exercising early is anything your server pushes without being asked. Notifications — a list that changed, a resource that was updated, a progress report on a long call — are the one part of the surface where nothing on your side ever gets a response, so a bug in them produces silence rather than an error. The inspector's history pane shows inbound notifications alongside request and response frames, which makes "did the server actually emit it" a question you can answer by looking, and separates it cleanly from "did anything downstream act on it".

Against a deployed server the invocation changes shape but nothing else does. Point the inspector at the URL rather than a command, pass whatever the production client passes, and let it perform the same handshake:

bash
npx @modelcontextprotocol/inspector --cli \
  https://orders.internal.example.com/mcp \
  --transport http \
  --header "Authorization: Bearer $STAGING_TOKEN" \
  --method tools/list

Two things go wrong here more often than the tool calls do. The session identifier the server hands back on initialize has to come back on every subsequent request, and a load balancer that routes those requests to a different instance than the one holding the session will produce a handshake that succeeds followed by everything failing — which is the same signature as several unrelated defects and is worth ruling out first. The second is the inspector's own proxy: the UI talks to it over HTTP with a token printed on startup, so a stale browser tab from an earlier session connects to nothing and reports a connection failure that has no relationship at all to your server.

The clean run is the one that carries information

Consider the shape: forty-two calls under the inspector, every one of them returning what it should, and the same server failing within a minute of a real client connecting to it. Nothing about that pattern is mysterious once you notice that the inspector has removed exactly two participants. If the server answers correctly when the model and the client are absent, and answers incorrectly when they are present, then the behaviour that changed lives in the model or in the client — and if the failure reproduces with a client driving it but no model in the loop, in the client alone.

The elimination deserves spelling out, because its force comes from what the two runs have in common rather than from anything the inspector does well. Both runs reach the same server build, over the same transport, carrying the same arguments under the same method. Those parts are constant across the pair, and a constant cannot cause a difference. The pair differs in two things and only two: a model that writes the arguments, and a client application that wraps the exchange on both sides. One outcome is correct and the other is not, so a difference caused it, so the cause is one of those two. Driving the failing client with fixed arguments and no model in the loop removes the first from the pair, and a failure that survives that leaves the client as the only candidate standing.

What the clean run licenses is narrower than it feels, and the gap is where investigations go wrong. It is a statement about where the fault is not, which converts an open search into a bounded one. It is not a statement that the server is correct. Forty-two green calls establish that the failure is not reachable from forty-two sets of arguments you chose, and the arguments you choose are the ones you already believe in — the malformed enum you thought to try, not the one you did not. Passing under the inspector is a condition every healthy server meets and a great many broken ones meet as well: necessary, and a long way from sufficient. The reading is tempting because the inspector speaks the real protocol and is the closest thing to a ground-truth client on the desk, which makes it feel like an authority rather than like one more implementation carrying tolerances of its own.

That narrows the search to a small and specific list. The client may be applying a timeout your call exceeds; a report-building tool that takes 45 seconds against a client with a 30-second per-request deadline fails every time under the client and never under the inspector, because the inspector's default deadline is long and configurable. The client may be flattening your response: a server that returns both a content array and a structuredContent object will look correct in the inspector, which shows you both, while a client that reads only the first text block feeds the model a JSON string it then has to parse by guessing. The client may be validating your output against the tool's declared outputSchema with a strict validator and rejecting a response that carries one extra key. It may cap the size of a tool result at 100 KB and truncate at a byte boundary in the middle of a UTF-8 sequence. Every one of these is invisible from your side of the wire and every one produces a ticket whose text says the server is broken.

There is a condition on that inference and it is easy to violate. A server is reached over one of two transports, and they are not interchangeable for this purpose: stdio, where the client launches your server as a child process and exchanges frames through that process's standard input and output, and Streamable HTTP, where the server is an HTTP endpoint the client posts to and which answers either with a single JSON body or with a stream of server-sent events. The inspector run only localises the fault if it differs from the client run in the two layers you meant to remove and nothing else. Run the inspector over stdio against a local build while the failing client speaks Streamable HTTP to a deployed instance behind a load balancer, and a clean result tells you nothing at all, because you have also changed the transport, the process, the network path, the auth layer and possibly the version of the code. Matching the transport is the part people skip. When the production client uses HTTP, drive the inspector at the same URL with the same headers, and keep an eye on whether your proxy terminates an idle connection at 60 seconds while your longest tool takes 75.

Counting the differences is what makes the collapse visible. The pair was meant to differ in a model and a client; in the setup just described it also differs in every layer the paragraph above names, and each of those is on its own capable of producing the result you observed. An experiment that varies a handful of things at once supports a conclusion naming any one of them exactly as well as it supports a conclusion naming any other, which is to say it supports none of them. The discipline is the one that made the two-call transcript in chapter 1 useless as a comparison: hold everything constant but the thing under test and the run tells you about that thing; move several and the run tells you only that something moved.

A cap on the size of a result fails in a shape the other client behaviours do not, and the difference is a property of JSON rather than of the client. An array carries no declaration of how long it is. It ends when a closing bracket arrives, and a payload cut at a byte offset simply never delivers one. Two consequences follow from that single property. Where something downstream parses strictly, it raises — the good case, because somebody gets an error with a stack trace attached. Where the fragment is read as text instead, a list that stopped early is indistinguishable from a list that was short, since nothing inside the payload states how many records were meant to be in it. The model then answers about the records it holds as though they were all of them, and where it needs one that never arrived, nothing in what it received discourages it from filling the gap. A cut that produced an error would have been the smaller problem.

Two implementations of one protocol are two chances to disagree

The inspector cannot see your own read loop, and this is not a limitation of the tool so much as a consequence of what it is. If your client — the one you wrote, embedded in your product — reads the transport by accumulating bytes and splitting on newlines, and your server one day emits a message containing an escaped newline that your splitter mishandles, the inspector will parse the same message correctly and report no problem. Two independent implementations of the same protocol are two chances to interpret it differently, and the inspector's chance is the one that never runs in production.

The general form of that recurs, and it is why the blind spot is not the inspector's alone. A parser bug is a property of the parser and not of the bytes, so one frame can be well formed on the wire, read correctly by one reader and read wrongly by another. Any instrument that works by substituting its own reader is therefore incapable of testing yours, whatever else it establishes. The inspector is the instance in front of you; a curl-based smoke test and a hand-rolled harness have the same hole for the same reason, and a green result from any of them is evidence about the code that produced the green rather than about the code your users run.

It also cannot see anything the model does. A tool whose description reads as though a date field accepts natural language, a truncation flag no model has ever acted on, two tools with names close enough that the model picks the wrong one a third of the time: these are faults in the server's interface, they cause real failures, and a hand-typed protocol call will never surface any of them because you already know what you meant. Running the agent is the only place that class of defect appears, and a direct call does not replace it.

Finally, the inspector is itself one implementation of a client, built on one SDK, with its own tolerances. It may accept a response that another client's stricter parser rejects, and it may send a request in a shape that differs in some permitted way from what a different client sends. A server that passes the inspector has been shown to work with the inspector, which is evidence about conformance rather than proof of it — a distinction that becomes the whole subject of chapter 6, where a client vendor asserts that your server is the non-conforming one.

The value of a direct run is that it is the only version of the exchange whose inputs you control, and the value of a clean direct run is larger than it looks, because it converts an open investigation into a bounded one. Forty-two green calls do not mean the server is correct; they mean that whatever is wrong is not reachable from the inputs you chose, and that everything the inspector removed is now the place to look.

None of this helps with the case where the inspector is clean, the client is clean on everything you can check, and the agent simply stops. No error appears, no timeout fires, and your application log has nothing at all for the call in question. That failure has no visible artefact on either side, which means the next thing you need is a record of the frames themselves, from both directions, carrying the two fields that let you line them up.

Debugging MCP — read the current version at https://daviddominguez.dev/learn/en/debugging-mcp/debugging-with-the-mcp-inspector

Back to top