Debugging a server the model keeps calling wrong
Your MCP server connects, the tools are listed, and the model still calls them wrong — or never at all. A procedure that finds the layer the fault is on, and a probe that reads the tool list the way the model does.
The server works. The model is still wrong.
2 types of scenarios that look like the same bug.
Tool invoked with wrong arguments — asking about last quarter's revenue, the tool gets invoked with "last quarter" which is a period parameter, the server has no idea what to do with it and returns an error, we try "Q2 2026", same problem, try "2026-Q2" — it works, so 3 rounds and 2 errors for just one look-up.
The tool hasn't been invoked at all — the server is in the list, connected, but the model responds from its memory as if the tool wasn't there.
Common mistake is that both of these scenarios lead people to inspect the server's code which is often not the place. Usually, it's just a matter that the server did what it was told to do, the questions are what the model has seen and what it has outputted; in such situations the best thing is to divide the way into layers and isolate the layer that broke, the quickest way to do this is to exclude the model.
Step 1 — read the tool list the way the model reads it
Let's look at the tool list from the model's perspective. Contrary to the harness where you can see server names and connection indicators, the model only sees the response of the tools/list request. For every tool — name, description, JSON Schema; plus an optional spec-allowed title or output schema so this is all that the model knows.
This being said, we can't improve such a briefing by prompting, as we prompt about what the model doesn't see.
To make sure you get the picture, you can ask the server directly — a subprocess sending JSON-RPC over stdin/stdout without the harness and the model, no token spent. We've added a probe script at the end of this lesson that performs the handshake and logs the list returned by the server, let's have a look:
python3 scripts/mcp-probe.py list -- python3 report_server.pyprotocol 2025-06-18 server report-server 0.1.0
get_report (13 chars of description)
period: string; required; ! free-form string, no description, no enum
1 tool(s). Every character above is prompt the model pays for.This is the output of the loose version of the build; as you can see it contains the protocol version, the server's name and version, the tool's description which is 13 characters long — "Get a report." and the period parameter that is a string with no description and no enum — the model has no clue that quarters are written like this: "2026-Q2" so it uses what the user has typed.
Now, the same output of the tightened version.
protocol 2025-06-18 server report-server 0.1.0
get_report (168 chars of description)
period: string; enum: 2025-Q4, 2026-Q1, 2026-Q2; required
1 tool(s). Every character above is prompt the model pays for.The description is 168 characters long and the period parameter is an enum containing these three values: "2025-Q4", "2026-Q1", "2026-Q2" — required; no room for guessing.
Every character here is a prompt the model is paying for, make sure to have a look at such output before you ship anything, often you'll notice the bug here.
Step 2 — capture what actually went over the wire
Now that we know what the model has seen, the question is what it has said, and we need the frames to answer this, not the summary. This stdio-based MCP server is a process with pipes on both ends so we can do an interception — in tee mode our probe script wraps the server command inside the MCP config logging every frame to a file named after an env var while forwarding them unmodified:
{
"mcpServers": {
"report": {
"command": "python3",
"args": ["scripts/mcp-probe.py", "tee", "--", "python3", "report_server.py"],
"env": { "MCP_PROBE_LOG": "/tmp/mcp-frames.jsonl" }
}
}
}So go ahead and reproduce the session in which you faced the problem, and then read the log:
sent: {"period": "last quarter"}
got : isError ValueError: invalid period
sent: {"period": "2026-Q2"}
got : ok revenue for 2026-Q2: 412000 EURHere's the actual traffic from Claude Code 2.1.193 against the loose build (we've stripped it to the tool calls) — first argument "last quarter" returned isError with a ValueError about invalid period; for the second argument "2026-Q2" it returned the revenue of 412k EUR.
From this you can see two things:
The model echoed what the user wrote — the only reasonable thing given the period parameter is a string.
The recovery works but in a non-deterministic manner, we ran it earlier the same day and it took three calls to do it with "Q2 2026" between them.
Some harnesses will show you the tool call and its arguments natively, without a proxy — the block below says whether yours is one of them. The tee is for when that view stops short. It's the one view that's independent of what the harness decided to display: tools/list response, initialize handshake, negotiated protocol version.
Step 3 — find the layer, in this order
Let's isolate the layer that broke, checking things in order — there are four layers, but only the last two involve your schema, and every check is cheap eliminating its layer for good:
Layer 1 — the request never left the harness. If you see an empty log or even no log at all, the server is not connected, awaiting approval, or the call has been denied by permissions; btw, the harness's own list command goes first here as a server waiting for approval can't be called, this is also the only evidence (that many ignore).
Layer 2 — connected but the tool definition hasn't reached the model. If you see the log contains initialize and tools/list and then nothing else, it means that the server works and the model has no idea the tool is there. But why? The most common reason is that the harness hides definitions behind a search step, and this one was never found by the model; or maybe the list is too long so the tool lost in the crowd; or perhaps the description doesn't say anything the request could match. But you know what? It's not about the code, it's about the naming and the description — make them good.
Layer 3 — invoked with wrong arguments. If the log shows tools/call frames returning isError, this is your problem, your schema was okay with it.
Layer 4 — invoked correctly but the result is unusable. If you see that the call succeeded but the answer is still incorrect, or the model seems to ignore it, have a look at the response; it might be too big for the harness to display so it's been truncated, or the request took some time and the harness timed out half-way through; or maybe the response has some shape that doesn't say what it is.
Where a harness publishes figures for any of that, they're in the block below.
What we measured
Let's set up a measurement. Keeping everything the same — prompt, harness, server logic — except for the tool list text and the error text, we'll run Claude Code 2.1.193 headless twice against each of these three builds:
| Tool list says | Error says | Calls | First argument sent |
|---|---|---|---|
"Get a report.", period any string | ValueError: invalid period | 2–3 | "last quarter" |
| same | names the three valid values | 2 | "last quarter" |
| enum + an example + "don't pass a relative phrase" | (never reached) | 1 | "2026-Q2" |
So as you can see, with the enum in place we've removed the room for guessing — the first call was always correct in both runs; without it the model took the user's phrasing and used it in the first call every time, hence the different retry counts. What's more, the second version's error text could only improve the second call as the model hasn't seen it before the first call.
But ofc this is two rounds of a single prompt so it's an anecdote, not a benchmark; we can't have any citable numbers here, but what we can say is that this is the direction — with the schema constraints in place the model doesn't need to guess, and the error text can shorten the recovery.
Fix it in the schema, not in the prompt
With the bug located, the best place for the fix is your server's schema, not the prompt. Once the tool list is visible for the model, most of the fixes are small and are about the server, improving the experience for every client, not only for you.
The enum — this is the main thing we've changed here, and it changed everything. If the set of values is static, you should define it with an enum; if it's open-ended, say what's the format and provide an example in the parameter's own description.
additionalProperties: false — this closes the object, so a parameter nobody defined is invalid by the schema instead of something you quietly drop on the floor. Per the spec, for tools taking no arguments you should define
{ "type": "object", "additionalProperties": false }which "explicitly accepts only empty objects".The description — "Get a report." tells you nothing, it doesn't say when you should call this tool; say what it returns and when it applies (and when it doesn't).
And finally, the name — names must be between 1–128 characters long, contain letters, digits, underscore, hyphen or dot, and must be unique within the server. The spec says that they "SHOULD be considered case-sensitive" so if you have two tools with the only difference being their case, it's two separate tools for the protocol and a coin-toss for the model. The probe reports such pairs.
Write the error message for the reader it has
Let's improve the error message. When the model calls your tool with invalid arguments, it's you who writes the next thing it reads.
The spec distinguishes two types of refusals, and the difference matters:
Tool Execution Errors — these are actionable pieces of feedback for the model so it can self-correct and retry with different parameters.
Protocol Errors — these are about request structure and aren't as actionable for the model.
Clients SHOULD return tool execution errors to the model to enable its self-correction.
So we should put the input validation in the first category, which means returning isError: true instead of an actual JSON-RPC error. Here's an example of a tool-execution-level date-format objection from the spec — it carries the fact that the model was missing, the current date:
{
"content": [
{ "type": "text",
"text": "Invalid departure date: must be in the future. Current date is 08/08/2025." }
],
"isError": true
}And here are the two messages our server returned for the very same invalid call — same loose schema in both cases, only the error text differs.
ValueError: invalid period
period 'last quarter' is not a quarter. Use one of: 2025-Q4, 2026-Q1, 2026-Q2. Call get_report again with one of those.The first one is a plain exception fragment addressed to nobody, the second tells what was wrong with the input, lists the valid values, and suggests retrying with one of them. And it worked — the model's next call was correct.
What's important is to write it as if the reader couldn't see your code, because they can't.
When the tool list itself is the problem
If you're in the situation where the tool doesn't seem to be called at all, don't modify the schema further, but assess the entire list instead — if it's a server with twenty tools that are almost the same it means that the problem is not in the description but in the discovery. Either limit yourself to the few actually useful tools and remove the rest, or distribute them across multiple servers.
Also, after any changes keep in mind that the model might still be operating on an outdated list; if you see that your server supports listChanged capability, make sure to emit notifications/tools/list_changed so the client can refetch it. Otherwise, assume that a live session remembers what it saw during startup and just restart it before deciding that the fix didn't work.
Claude Code
Let's start with two basic CLI commands that you can run to check if the call can happen at all — listing servers and showing details of a single server:
claude mcp list
claude mcp get reportThese two display if the tool is callable from the server level, which means that the server is present in your project's configuration and generally accessible by the listing command (if you have access to it).
The listing command performs a connectivity check on every server it's allowed to reach, so most rows end in a real status — the docs name ✔ Connected, ! Needs authentication and ✘ Failed to connect, and they're explicit that a failure status "means Claude Code couldn't connect to that server, not that the list command failed".
The row people misread is the one for a server it never connected to at all:
report: python3 report_server.py - ⏸ Pending approval (run `claude` to approve)That's a project .mcp.json server nobody has approved yet — it's listed and spelled correctly, but not connected, so the model has no such tool and the "it ignores my server" hunt ends right here.
If you run:
claude mcp get <name>You will get the same status (connected / needs authentication / failed to connect) with the additional information about where the entry comes from (for example, project's .mcp.json). Both commands don't display tools; if you want to see them, either run /mcp in an active session or use the probe.
Last but not least, a few things that make life easier when working with MCP here. They matter most once you have a large number of servers and tools.
The thing that surprises people in 2026.
By default, MCP's tools are being deferred instead of loaded into the context upfront. That means that Claude is finding the tools which might be relevant based on the task using a search tool. This way, connected servers' tools can sometimes go uncalled simply because they haven't been found by Claude.
To address this, make sure the server's instructions say what kind of work its tools handle, and set alwaysLoad to true on those servers whose tools you want to skip the search process entirely.
If you want to disable the deferral completely (so that it behaves like in previous versions), set the ENABLE_TOOL_SEARCH environment variable to false. It's helpful for testing, for example.
Debugging a session in isolation.
You can also run the mcp command from a specific session, for this purpose there are two flags:
--mcp-config <file> — loads servers from a specified file
--strict-mcp-config — ignores every other MCP configuration that might be defined in your project and allows you to reproduce an issue with only one server presentFor instance:
claude --mcp-config ./one-server.json --strict-mcp-config \
--allowedTools "mcp__report__get_report" \
-p "How much revenue did we make last quarter? Use the report tool."That's the setup we used for the experiment in this lesson — a session built on one configuration file, marked strict, with exactly one tool allowed.
For this experiment, keep in mind that when you define allowedTools, you need to provide the fully qualified name of the tool, like mcp__my-server__a-tool or mcp__plugin__my-server__a-tool if it's part of a plugin; the same goes for permissions and hook matchers. These flags are available only for the session command (if you run the list command with --mcp-config, you'll get an unknown option error).
If you want to explore what's happening during the call without using tee or similar tool to pipe the output to the standard output, you can use:
--debug [category] — enables debug mode and optionally filters the logs by category; if no category is provided, the logs for all categories are shown
--debug-file <path> — stores the debug output in a file so you can analyse it laterWhen the call worked and the answer didn't.
Lastly, if the call was successful but the answer is not what you expected, it might be connected with the output size limits. By default, the tool outputs up to 10k tokens (you'll see a warning when this limit is exceeded) and are being cut off at 25k tokens; you can change this with the MAX_MCP_OUTPUT_TOKENS environment variable.
Also, keep in mind that there are different timeouts:
MCP_TIMEOUT — for the server to start; if you want to set a custom value, export it as an environment variable before running the mcp command
timeout — per call, defined in the server's entry or with the MCP_TOOL_TIMEOUT environment variable (the latter is used only when not setting it from the server's entry)There's also an idle timer. If a call to the server is initiated and the server goes quiet, the call is being aborted. It's set to 5 minutes for HTTP-based servers and 30 minutes for stdio ones by default. It only reaches stdio servers from Claude Code v2.1.203, so make sure to check what version you're on before blaming it on this.
Last thing to keep in mind is that if your input schema has anyOf, oneOf or allOf at its root level (which might be the case if you use JSON Schema), it gets flattened before being sent to the API, and the branch requirements move into the tool's description — your server still receives whatever the model chose, so keep validating the combination server-side. Claude Code versions earlier than 2.1.195 skipped such a tool entirely.
Codex CLI
If you want to inspect the MCP setup from the Codex CLI, it provides two commands:
codex mcp list
codex mcp get report --jsonflat listing of all registered servers — useful but be mindful that the status column is not a liveness indicator (for instance, if you have two servers registered, one of them pointing at a real path, and another pointing at a path that does not exist, in the list both of them will appear with the same status, both marked as "enabled"):
Name Command Args Env Cwd Status Auth
broken /nonexistent/path/to/server --flag - - enabled Unsupported
report python3 /tmp/report_server.py - - enabled UnsupportedThe "enabled" flag means that Codex is configured to run the server, but it doesn't mean that it was launched, connected or returned any tool.
per-server detail in JSON — personally we find this one more useful, as it contains a few fields that alter some behaviour without signalling that directly:
{
"name": "report",
"enabled": true,
"transport": { "type": "stdio", "command": "python3", "args": ["/tmp/report_server.py"] },
"enabled_tools": null,
"disabled_tools": null,
"startup_timeout_sec": null,
"tool_timeout_sec": null
}In the example above you can see, that for the server neither enabled_tools nor disabled_tools are set; these are the places to check if you think that the model doesn't see a tool that is exposed by the server, or if you want to display only a subset of tools of the server that has twenty of them. The other important fields in the detail JSONs are timeouts: startup_timeout_sec and tool_timeout_sec, which are the two thresholds a slow server can hit.
What the model actually sent.
If you run the model under the codex exec command with --json flag, it emits one JSONL event per step, including MCP calls as a separate item type, keeping the arguments untouched; for example:
{"type":"item.started","item":{"type":"mcp_tool_call","server":"report","tool":"get_report","arguments":{"period":"last_quarter"},"status":"in_progress"}}Redirecting the output to a file and running grep 'mcp_tool_call' on it is a good move.
The gotcha we hit.
We've been using version 0.146.0 of Codex, and the latest run has finished with an event like this:
{"type":"item.completed","item":{"type":"mcp_tool_call","server":"report","tool":"get_report","arguments":{"period":"last_quarter"},"error":{"message":"user cancelled MCP tool call"},"status":"failed"}}The reason is that we've run a non-interactive codex exec run with a default approval policy (so no human available to approve) under the hood, and the cancellation took place there. If you look at the tee log of this run, you will not find any frames from tools/call, so it was just that the request wasn't even sent to the server. So if you face a situation when a server is silent during the debug session with codex exec, have a look for the above message before you start looking for a bug on the server's side.
Testing without editing your config.
If you want to experiment with servers without affecting the config stored on the disk, you can use -c flag to override any config value for a single run:
codex exec --json --skip-git-repo-check \
-c 'mcp_servers.probe.command="python3"' \
-c 'mcp_servers.probe.args=["scripts/mcp-probe.py","tee","--","python3","report_server.py"]' \
"Use the report tool."or CODEX_HOME environment variable to set up a separate profile for this run (with a fresh config directory) and then add the server there using codex mcp add.
GitHub Copilot CLI
GitHub Copilot CLI — the most informative configuration display among the 6 tools presented. It's for listing subcommand and get subcommand for a single server.
copilot mcp list
copilot mcp get reportThe output is split by where the servers come from, so if there's any scope misconfiguration it's easy to spot. Like in this example showing user-level servers (per user) and workspace-level servers (per repository), each with transport type:
User servers:
executor (http)
Workspace servers:
report (local)If you see that a project-aimed server is under "user" group or isn't listed at all — that means it has a scope misconfigured, not that there's sth wrong with the server. Origins of the config files:
User level:
~/.copilot/mcp-config.jsonWorkspace level:
.mcp.jsonor.github/mcp.jsonWhatever any installed plugin contributes
The get subcommand is a good tool if the model happens to call the wrong tool. The per-server details output — enabled, type, launch command, env vars, tool filter and source path:
report
Status: Enabled
Type: local
Command: python3 scripts/mcp-probe.py tee -- python3 report_server.py
Environment:
MCP_PROBE_LOG: ***
Tools: * (all)
Source: Workspace (/private/tmp/scratch/.mcp.json)If it comes to tool filter it's especially important if you set it to something other than "all" for a server, because then it might be limited to a subset of tools so you can see that here. Also, if you're wondering why a tool is missing — the answer lies here too. The source shows the fully resolved path (from the root of your file system), telling you what config file is actually used (the most time-consuming part when there are multiple config files). Just keep in mind that for env vars you see asterisks instead of values, so it's safe to post on social media but doesn't help if you wonder about a value. Open the file directly in such a scenario.
Inspecting what the model has sent. Copilot CLI creates session logs (you can change their destination and verbosity level using flags).
copilot --log-level debug --log-dir ./copilot-logsThe verbosity flag supports these values: "none", "error", "warning", "info", "debug", "default" and "all". The log directory defaults to ~/.copilot/logs/. If you do a lot of debugging, point it somewhere next to your project instead, so you get a single session's log rather than a heap.
One more thing, mentioned earlier but worth repeating. If you happen to have a misconfigured MCP config in a workspace, Copilot will point it out, and tell you which file it is. Then it will skip the entire "workspace" part of its output, but return exit code 0. So remember to always trust the message, not the exit status. If you see that there's no output for servers in a workspace — this is the last proof.
Cursor
It's the second of two CLIs that are capable of listing all the tools installed on a server and the only one showing the name of their arguments.
agent mcp list
agent mcp list-tools reportIt has two commands, one to list servers (which outputs a single line per server with its status) and another to list tools of a given server.
report: not loaded (needs approval)The latter doesn't work until you approve the server as it uses the same mechanism as the main CLI, so it prints an error message that the server isn't approved.
Failed to list tools: Failed to load MCP 'report': MCP server "report" has not been approved"Not loaded" means the CLI won't load it, so nothing from that server reaches the session either — which ends the "it ignores my server" hunt right there, before you go anywhere near the schema.
The enable command adds a server to the approved list on your machine so it gets considered; the disable command takes it out of consideration entirely, so it isn't even prompted for. Once a server is approved, list-tools is the fastest way to see what the model sees, straight from the terminal — no probe, no session.
The trap. There's a possibility that you have a file called .cursor/mcp.json (in the project root) or ~/.cursor/mcp.json with a key on the top level that's not mcpServers. This will also result in the "No servers configured" message, which might be misleading as it's not actually about a missing config file
No MCP servers configured (expected in .cursor/mcp.json or ~/.cursor/mcp.json)Whenever you see this message, don't treat it as if the file was missing, but rather that there's nothing useful in it. Check that it has the mcpServers key on the top level before you go rewriting the entry.
And for argument-level evidence — what the model sent, not what the tool accepts — the tee wrapper from the shared recipe is your surface here, since the CLI won't replay a call for you.
Antigravity CLI
1.1.10 has no mcp subcommand at all. Available subcommands are:
agent
agents
changelog
help
install
models
plugin
plugins
updateSo if you try to run agy mcp --help you will get the general help for the CLI and not an error, which is confusing, as it seems like the command exists, but you just used a wrong flag. MCP is supported all the same, just via config files only:
Global Configuration:
~/.gemini/config/mcp_config.json(applies to all sessions). Plugin Configuration:plugins/<plugin_name>/mcp_config.json(active when the plugin is enabled).
The general config is stored at ~/.gemini/config/mcp_config.json and is used for every session, while the per-plugin one (above) in plugins/<plugin_name>/mcp_config.json is active only when the plugin is enabled. The format of the servers object is standard: the familiar mcpServers map, with command/args/env for stdio and serverUrl for SSE.
The only way to verify sth without the internet is by running agy plugin validate for a project-local plugin in its directory:
.agents
└── plugins
└── report
├── plugin.json
└── mcp_config.jsonThen you can run:
agy plugin validate .agents/plugins/reportand see the output of the validator that has a line per every component (skills, agents, commands, mcpServers, hooks) with whether it was skipped or processed. If you see:
[ok] .agents/plugins/report
- skills : skipped (not found)
- agents : skipped (not found)
- commands : skipped (not found)
✔ mcpServers : 1 processed
- hooks : skipped (not found)Then you're good to go.
Otherwise, there are two common ways the configuration can be messed up:
there's a stray comma at the end of the config file.
In such a case the validator will show this:
Error: invalid mcp_config.json: invalid character '}' looking for beginning of object key stringAnd the output will contain both the names of the problematic file (mcp_config.json) and the invalid character ('}'). So if you see this, it's easy to understand what went wrong.
you renamed the top-level key from
mcpServerstoservers.
In this case the validator is happy and shows this:
[ok] .agents/plugins/report
...
- mcpServers : skipped (not found)So as you can see, the message on top of the output is not what matters here. You need to check the mcpServers line instead — if it says skipped, it means that there's no server configured.
If you want to cross-check it, you can list all the imported plugins in your project with:
agy plugin listBut you will see that for a project-local plugin it says No imported plugins.
The only thing that remains is using generic tools that are part of the CLI to be able to at least view the logs. You can for example run:
agy --log-file some-file.logAnd redirect all the logs into a file, or use the tee utility to capture the frames of the protocol.
And finally, if you want to list the servers that are live in your project and see what tools they provide, you can go to this UI path:
Additional Options (...) > MCP Servers
This is all we tested for 1.1.10 as we haven't been connected with a server using it, so YMMV
Kimi
The two commands under this are basically it, the second one being the most powerful single-server diagnostic in this entire set:
kimi-cli mcp list
kimi-cli mcp test <server>Whenever you run list, it shows the path to the config file it loads before showing anything else, so:
MCP config file: /Users/you/.kimi/mcp.json
No MCP servers configured.As said, this is very valuable information, as it does half of the work for you:
"No MCP servers configured" and the path pointing to the file you were editing — you should look into the contents of that file
"No MCP servers configured" and an unfamiliar path — you were editing a different file
The per-server test subcommand is described in its own help as connecting to one server and listing its available tools. This is what you would run if you see a server listed but the model never engages with it. Do it to see if it's not the server that's at fault. It's like the native version of the first step from the recipe. The only caveat here is we haven't tested it — in order to add a new server we'd need to edit the config file on a machine that wasn't ours.
The location of the config:
~/.kimi/mcp.jsonIt's separate from the general config, so you can alter it with some flags: --config-file points at a different config file, --config takes TOML or JSON inline, and there's --debug for verbose output, with --verbose alongside it.
Just make sure which binary you're using before doing any of the above. It's called kimi but there are two different programs:
The older Python kimi-cli, which reads the config from ~/.kimi/mcp.json
The rewritten Kimi Code CLI (the new one), which is distributed as
kimi
If the directory exists on your machine, it means nothing in terms of which binary you have installed.
The proper way to find out which binary you have is to ask it, using --version and an info subcommand if it has one.
On the machine we were using for the test, the new binary wasn't even cooperating — it was throwing Node's single-executable assertion on every command, including --version. All of the above is based on the older Python kimi-cli 1.48.0, so the command names are from that version. If your binary is telling you otherwise, go with what your binary says.
Leave the probe behind, take the tee out
Lastly, make sure to keep these practices in place after the bug is resolved.
Practice 1 — commit mcp-probe.py under scripts/; it's the standard library so harness-independent, and its list mode is a ten-second audit of a tool list you should perform before shipping any build (including third-party servers prior to installation).
Practice 2 — remove the tee wrapper from the config afterward. It's just a debugging aid that creates an additional process and logs every argument and result to a plaintext file; it's okay to use it with a fixture but definitely not with a server handling real credentials or customer data, so remove the wrapper and delete the log.
scripts/mcp-probe.py#!/usr/bin/env python3
"""mcp-probe — talk to a stdio MCP server without a model in the loop.
Three modes:
probe list -- <server command...> handshake, then audit tools/list
probe call <tool> '<json>' -- <server cmd...> one tools/call, raw result
probe tee -- <server command...> transparent proxy, logs every frame
`tee` is meant to be wired into your harness's MCP config in place of the server
command, so the real session runs through it and every JSON-RPC frame lands in
MCP_PROBE_LOG. Take it back out when you're done: it writes arguments and results
to disk in plain text.
Standard library only. Python 3.8+.
"""
import json
import os
import subprocess
import sys
import threading
PROTOCOL = os.environ.get("MCP_PROTOCOL_VERSION", "2025-06-18")
LOG = os.environ.get("MCP_PROBE_LOG", "mcp-frames.jsonl")
def split_args(argv):
if "--" not in argv:
sys.exit("usage: mcp-probe.py <list|call|tee> [args] -- <server command>")
i = argv.index("--")
return argv[:i], argv[i + 1:]
class Server:
def __init__(self, cmd):
self.p = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1,
)
self.n = 0
def send(self, method, params=None, notify=False):
msg = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
if not notify:
self.n += 1
msg["id"] = self.n
self.p.stdin.write(json.dumps(msg) + "\n")
self.p.stdin.flush()
if notify:
return None
while True:
line = self.p.stdout.readline()
if not line:
err = self.p.stderr.read()
sys.exit(f"server closed the pipe before answering {method}\n{err}")
try:
reply = json.loads(line)
except json.JSONDecodeError:
# A server that prints to stdout breaks the transport. Worth seeing.
print(f" not JSON on stdout: {line.rstrip()[:120]}", file=sys.stderr)
continue
if reply.get("id") == msg["id"]:
return reply
def handshake(self):
r = self.send("initialize", {
"protocolVersion": PROTOCOL,
"capabilities": {},
"clientInfo": {"name": "mcp-probe", "version": "1"},
})
self.send("notifications/initialized", {}, notify=True)
return r
def close(self):
try:
self.p.stdin.close()
self.p.wait(timeout=5)
except Exception:
self.p.kill()
def audit(tools):
"""Print the tool list the way a model receives it, and flag what it can't resolve."""
seen = {}
for t in tools:
name = t.get("name", "<unnamed>")
desc = t.get("description") or ""
schema = t.get("inputSchema") or {}
props = schema.get("properties") or {}
required = schema.get("required") or []
print(f"\n {name} ({len(desc)} chars of description)")
if not desc:
print(" ! no description — the model is guessing from the name alone")
if not props:
print(" ! no properties in inputSchema — any object is accepted")
for pname, p in props.items():
bits = [p.get("type", "no type")]
if "enum" in p:
bits.append("enum: " + ", ".join(map(str, p["enum"])))
if pname in required:
bits.append("required")
if not p.get("description") and "enum" not in p:
bits.append("! free-form string, no description, no enum")
print(f" {pname}: {'; '.join(bits)}")
for pname in required:
if pname not in props:
print(f" ! {pname} is required but not described in properties")
low = name.lower()
if low in seen:
print(f" ! name collides with {seen[low]} once case is ignored")
seen[low] = name
print(f"\n{len(tools)} tool(s). Every character above is prompt the model pays for.")
def main():
mine, cmd = split_args(sys.argv[1:])
if not mine:
sys.exit("usage: mcp-probe.py <list|call|tee> [args] -- <server command>")
mode = mine[0]
if mode == "tee":
child = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, bufsize=1)
log = open(LOG, "a", buffering=1)
def pump(src, dst, direction):
for line in src:
log.write(json.dumps({"dir": direction, "frame": line.rstrip()}) + "\n")
dst.write(line)
dst.flush()
try:
dst.close()
except Exception:
pass
threading.Thread(target=pump, args=(sys.stdin, child.stdin, "client->server"),
daemon=True).start()
pump(child.stdout, sys.stdout, "server->client")
child.wait()
return
s = Server(cmd)
init = s.handshake()
info = init.get("result", {})
print(f"protocol {info.get('protocolVersion')} server "
f"{info.get('serverInfo', {}).get('name')} {info.get('serverInfo', {}).get('version')}")
if mode == "list":
reply = s.send("tools/list")
if "error" in reply:
sys.exit(f"tools/list failed: {reply['error']}")
audit(reply.get("result", {}).get("tools", []))
elif mode == "call":
if len(mine) < 3:
sys.exit("usage: mcp-probe.py call <tool> '<json args>' -- <server command>")
reply = s.send("tools/call", {"name": mine[1], "arguments": json.loads(mine[2])})
print(json.dumps(reply, indent=2))
result = reply.get("result", {})
if reply.get("error"):
print("\nprotocol error — the server refused the call itself")
elif result.get("isError"):
print("\nisError: true — this text goes back to the model as its next observation")
else:
sys.exit(f"unknown mode {mode!r}")
s.close()
main()