|
| 1 | +--- |
| 2 | +name: client-smoke |
| 3 | +description: "Drive a locally built server with a real client: the Inspector CLI (scripted), the Inspector web UI (by hand) and an LLM client. Use when smoke-testing a server change; when calling a tool, reading a resource or getting a prompt from the command line; when choosing a protocol era; or when the Inspector CLI will not connect to, or misreads, a server command." |
| 4 | +disable-model-invocation: false |
| 5 | +--- |
| 6 | + |
| 7 | +# Client smoke |
| 8 | + |
| 9 | +A server change is checked against real clients before its PR opens: the MCP |
| 10 | +Inspector and an LLM client. This skill is how to run each one against a server |
| 11 | +built from this checkout, and what the output means. What the PR must then |
| 12 | +record is in `/pr-flow` (client evidence); building and starting the server is |
| 13 | +`/local-dev`; automated tests are `/testing`. |
| 14 | + |
| 15 | +Three clients, in the order to reach for them: |
| 16 | + |
| 17 | +| Client | Driven by | Use it for | |
| 18 | +| --- | --- | --- | |
| 19 | +| Inspector **CLI** | A command. This is the **scripted path**, and the one an agent runs | One request, one JSON result: the evidence that goes in a PR | |
| 20 | +| Inspector **web** UI | A person, by hand. Nothing in this repo automates a browser | What only a UI shows: notifications arriving, elicitation and sampling prompts, a long-running tool's progress | |
| 21 | +| An **LLM client** | A prompt | Whether a model picks the tool and uses its result, which the PR checklist asks for | |
| 22 | + |
| 23 | +Build first (`npm run build -w src/<server>`). Every command below runs the |
| 24 | +last build of a TypeScript server, not your edit. |
| 25 | + |
| 26 | +## The Inspector CLI |
| 27 | + |
| 28 | +```sh |
| 29 | +npx -y @modelcontextprotocol/inspector --cli <server command> [-- ] <inspector options> |
| 30 | +``` |
| 31 | + |
| 32 | +It starts the server command over stdio (or connects to a URL), sends one |
| 33 | +request, prints the result and exits. Pin the Inspector version in anything you |
| 34 | +keep (`@modelcontextprotocol/inspector@<version>`); the commands here were run |
| 35 | +on 2.9.0. `--cli --help` is the reference for the options. |
| 36 | + |
| 37 | +```sh |
| 38 | +INSPECT="npx -y @modelcontextprotocol/inspector --cli" |
| 39 | + |
| 40 | +# List, then call, on a TypeScript server |
| 41 | +$INSPECT node src/everything/dist/index.js stdio --method tools/list --format json |
| 42 | +$INSPECT node src/everything/dist/index.js stdio \ |
| 43 | + --method tools/call --tool-name echo --tool-arg message=hello --format json |
| 44 | +# → {"result":{"content":[{"type":"text","text":"Echo: hello"}]}} |
| 45 | + |
| 46 | +# Arguments that are not strings: pass them as JSON |
| 47 | +$INSPECT node src/everything/dist/index.js stdio \ |
| 48 | + --method tools/call --tool-name get-sum --tool-args-json '{"a":2,"b":3}' --format json |
| 49 | + |
| 50 | +# Resources and prompts |
| 51 | +$INSPECT node src/everything/dist/index.js stdio \ |
| 52 | + --method resources/read --uri demo://resource/dynamic/text/1 --format json |
| 53 | +$INSPECT node src/everything/dist/index.js stdio \ |
| 54 | + --method prompts/get --prompt-name args-prompt --prompt-args city=Paris --format json |
| 55 | + |
| 56 | +# A server that takes a positional argument |
| 57 | +$INSPECT node src/filesystem/dist/index.js /abs/dir \ |
| 58 | + --method tools/call --tool-name list_allowed_directories --format json |
| 59 | + |
| 60 | +# A Python server: note the `--` (see below) |
| 61 | +$INSPECT uv --directory src/time run mcp-server-time -- \ |
| 62 | + --method tools/call --tool-name get_current_time --tool-arg timezone=UTC --format json |
| 63 | +$INSPECT uv --directory src/git run mcp-server-git --repository "$PWD" -- \ |
| 64 | + --method tools/list --format json |
| 65 | +``` |
| 66 | + |
| 67 | +`--format json` prints a result as one JSON object, `{"result": …}`, on |
| 68 | +**stdout**, and a failure as `{"error": …}` on **stderr**. The server's own |
| 69 | +stderr (its startup banner) passes through on stderr too. So capture stdout |
| 70 | +alone for the result, never `2>&1` into a JSON parser: a tool error produces |
| 71 | +both objects, one on each stream (see the table below). |
| 72 | + |
| 73 | +### How the command line is split |
| 74 | + |
| 75 | +The server command comes **first** and the Inspector's options **after** it. |
| 76 | +With no `--`, the server command ends at the first word that starts with `-`. |
| 77 | +That rule produces the three failures people actually hit: |
| 78 | + |
| 79 | +- **A server command with its own flags needs `--` before the Inspector's |
| 80 | + options.** `uv --directory …` and `mcp-server-git --repository …` both have |
| 81 | + one. Without the separator the command is cut at `--directory`, and what runs |
| 82 | + is a bare `uv`, which prints its help and exits. With it, everything before |
| 83 | + `--` is the server command and everything after is the Inspector's. |
| 84 | +- **Putting an Inspector option before the server command loses the command.** |
| 85 | + `--cli -e KEY=VALUE node …` leaves no server command at all, and the CLI |
| 86 | + falls back to your personal catalog (`~/.mcp-inspector/mcp.json`). With |
| 87 | + several servers in it that fails with `Multiple servers found in config |
| 88 | + file. Please specify one with --server`. Either way it is no longer talking |
| 89 | + to your build, so move the option after the command. |
| 90 | +- **`Method is required`** means the options ended up on the wrong side of a |
| 91 | + `--`. |
| 92 | + |
| 93 | +### Environment variables |
| 94 | + |
| 95 | +The spawned server gets only the SDK's default safelist from your shell |
| 96 | +(`PATH`, `HOME`, `USER` and the like), not the variables you set. Setting |
| 97 | +`MEMORY_FILE_PATH=… npx …` has no effect on the server; pass it with `-e`: |
| 98 | + |
| 99 | +```sh |
| 100 | +$INSPECT node src/memory/dist/index.js \ |
| 101 | + -e MEMORY_FILE_PATH=/abs/tmp/graph.jsonl \ |
| 102 | + --method tools/call --tool-name read_graph --format json |
| 103 | +``` |
| 104 | + |
| 105 | +### Exit codes and errors |
| 106 | + |
| 107 | +The exit code is the verdict, so check it rather than reading the text: |
| 108 | + |
| 109 | +| Outcome | stdout | stderr | Exit | |
| 110 | +| --- | --- | --- | --- | |
| 111 | +| The request succeeded | `{"result": …}` | | 0 | |
| 112 | +| The tool ran and returned `isError: true` | `{"result":{"content": …, "isError": true}}` | `{"error":{"code":"tool_is_error", …}}` | 5 | |
| 113 | +| No such tool | | `{"error":{"code":"tool_not_found", …}}` | 5 | |
| 114 | +| An HTTP server could not be reached (refused, DNS, timeout) | | `{"error":{"code":"unreachable", …}}` | 4 | |
| 115 | +| Bad arguments, a failed version negotiation, or a stdio server that exited | | `{"error":{"code":"error", …}}` | 1 | |
| 116 | + |
| 117 | +These are the codes observed on 2.9.0 for the cases this repo's servers |
| 118 | +produce; the CLI defines others (schema and skill checks, authentication) |
| 119 | +that its `--help` describes. |
| 120 | + |
| 121 | +A tool error is a **successful protocol exchange**: the result object is still |
| 122 | +printed on stdout, and it is what to quote when the change is about an error a |
| 123 | +tool returns. |
| 124 | + |
| 125 | +### `everything` over HTTP |
| 126 | + |
| 127 | +Start the transport in one terminal (or in the background), then give the CLI |
| 128 | +the URL. The transport is inferred from the path: `/mcp` is Streamable HTTP, |
| 129 | +`/sse` is HTTP+SSE. |
| 130 | + |
| 131 | +```sh |
| 132 | +( |
| 133 | + URL=http://localhost:3917/mcp |
| 134 | + # Refuse a port something already answers on: it would be tested instead of this build. |
| 135 | + curl -s -m 2 -o /dev/null "$URL" && { echo "port 3917 is already in use" >&2; exit 1; } |
| 136 | + PORT=3917 node src/everything/dist/index.js streamableHttp >/dev/null 2>&1 & |
| 137 | + SERVER_PID=$! |
| 138 | + trap 'kill "$SERVER_PID" 2>/dev/null' EXIT # stops it however the subshell ends |
| 139 | + READY= |
| 140 | + for _ in $(seq 50); do # at most ten seconds |
| 141 | + kill -0 "$SERVER_PID" 2>/dev/null || { echo "server exited before listening" >&2; exit 1; } |
| 142 | + curl -s -m 2 -o /dev/null "$URL" && { READY=1; break; } |
| 143 | + sleep 0.2 |
| 144 | + done |
| 145 | + [ -n "$READY" ] || { echo "server did not listen within ten seconds" >&2; exit 1; } |
| 146 | + $INSPECT "$URL" --method tools/call --tool-name echo --tool-arg message=over-http --format json |
| 147 | +) |
| 148 | +``` |
| 149 | + |
| 150 | +Each part of that is there for a failure it prevents. The first check refuses |
| 151 | +a port that already answers, because a stale listener there would be the |
| 152 | +server under test. Without the wait, the CLI can reach the port before the |
| 153 | +server is listening and exit 4. The wait is bounded and checks that this |
| 154 | +server is still alive, so one that fails to bind (the build is missing, the |
| 155 | +port was taken in between) ends the run instead of hanging it. The subshell's `EXIT` trap stops the server by pid even when the CLI call |
| 156 | +fails; a listener left on the port makes the next run talk to the old build. |
| 157 | +By hand in two terminals, the equivalent is waiting for the `listening on |
| 158 | +port` line and stopping the server with Ctrl-C. |
| 159 | + |
| 160 | +A change to a tool, resource or prompt of `everything` is checked over stdio |
| 161 | +and Streamable HTTP. Check HTTP+SSE as well when the change touches transport |
| 162 | +or session code; it is deprecated but must keep working. |
| 163 | + |
| 164 | +## Protocol eras |
| 165 | + |
| 166 | +The Inspector connects in one of three eras, chosen with `--protocol-era` (the |
| 167 | +CLI) or **Server Settings → Protocol Era** (the web UI): |
| 168 | + |
| 169 | +| Era | What the Inspector does | |
| 170 | +| --- | --- | |
| 171 | +| `legacy` (the default) | A plain `initialize`. Negotiates 2025-11-25 or older | |
| 172 | +| `modern` | Pins 2026-07-28 through `server/discover`, with no fallback | |
| 173 | +| `auto` | Probes `server/discover`, and falls back to `initialize` | |
| 174 | + |
| 175 | +⚠️ **Only the legacy era can be exercised against these servers today.** All |
| 176 | +seven are on a v1 SDK (`@modelcontextprotocol/sdk` 1.x, `mcp` 1.x), whose |
| 177 | +newest protocol version is 2025-11-25. None implements `server/discover`. So: |
| 178 | + |
| 179 | +- `--protocol-era modern` fails, by design, with `Version negotiation failed: |
| 180 | + the server did not offer pinned protocol version 2026-07-28 via |
| 181 | + server/discover (no fallback in pin mode)` and exit 1. That is the expected |
| 182 | + result for an unmigrated server, not a regression in your change. |
| 183 | +- `--protocol-era auto` succeeds, but through the fallback, so it is a legacy |
| 184 | + connection and proves nothing about 2026-07-28. |
| 185 | + |
| 186 | +The both-eras check that `/pr-flow` asks for (#4857) therefore has one half |
| 187 | +that can pass today. For a server that does not yet serve the modern era, |
| 188 | +record the legacy run, and say in the PR that the server does not yet speak |
| 189 | +2026-07-28 rather than leaving the modern era unmentioned. Moving a server to |
| 190 | +a v2 SDK (#4856 for TypeScript, #4851 for Python) does not change that by |
| 191 | +itself: those keep the legacy wire behavior. Modern-era support arrives with |
| 192 | +#4852 (TypeScript) and #4853 (Python). Once a server has it, run every command |
| 193 | +twice, with `--protocol-era legacy` and `--protocol-era modern`, and expect |
| 194 | +both to succeed. Do not use `auto` for evidence in either case: it |
| 195 | +does not say which era answered. |
| 196 | + |
| 197 | +## The Inspector web UI |
| 198 | + |
| 199 | +The web client is the hand-driven path. It needs a person at a browser; this |
| 200 | +repo configures no browser automation, so an agent prepares the command and |
| 201 | +the steps and asks for what was observed. |
| 202 | + |
| 203 | +```sh |
| 204 | +npx -y @modelcontextprotocol/inspector node src/everything/dist/index.js stdio |
| 205 | +npx -y @modelcontextprotocol/inspector -- uv --directory src/time run mcp-server-time |
| 206 | +npx -y @modelcontextprotocol/inspector --transport http --server-url http://localhost:3001/mcp |
| 207 | +``` |
| 208 | + |
| 209 | +⚠️ **The web launcher reads `--` the other way round from the CLI.** Here the |
| 210 | +Inspector's own options come **before** `--` and the server command **after** |
| 211 | +it; in `--cli` mode the server command is before `--` and the options after. |
| 212 | +A line copied from one mode to the other starts the wrong thing. Pass a |
| 213 | +server's environment with `-e KEY=VALUE` before the `--`. |
| 214 | + |
| 215 | +Use the web UI for what a single request cannot show: `toggle-simulated-logging` and |
| 216 | +`toggle-subscriber-updates` (notifications over time), |
| 217 | +`trigger-elicitation-request` and `trigger-sampling-request` (the client is |
| 218 | +asked something), `trigger-long-running-operation` (progress), and a |
| 219 | +filesystem server's response to a Roots change. |
| 220 | + |
| 221 | +Evidence from the web UI is what was clicked and what appeared, in words. This |
| 222 | +repo does not attach screenshots. |
| 223 | + |
| 224 | +## An LLM client |
| 225 | + |
| 226 | +The PR checklist asks whether the change was tested with an LLM client. Any |
| 227 | +client that can launch a stdio server works; each server's `README.md` has the |
| 228 | +config blocks for Claude Desktop and VS Code, and `/local-dev` shows how to |
| 229 | +point one at the local build. |
| 230 | + |
| 231 | +Claude Code can do it in one headless command, which makes it the scriptable |
| 232 | +option. Write a config that names the local build with an **absolute** path: |
| 233 | + |
| 234 | +```json |
| 235 | +{ |
| 236 | + "mcpServers": { |
| 237 | + "everything": { |
| 238 | + "command": "node", |
| 239 | + "args": ["/abs/path/to/servers/src/everything/dist/index.js", "stdio"] |
| 240 | + } |
| 241 | + } |
| 242 | +} |
| 243 | +``` |
| 244 | + |
| 245 | +```sh |
| 246 | +printf '%s' "Call the echo tool of the everything server with the message 'llm-smoke' and reply with exactly the text it returned." \ |
| 247 | + | claude -p --mcp-config /abs/path/to/config.json --strict-mcp-config \ |
| 248 | + --allowedTools "mcp__everything__echo" --max-turns 3 |
| 249 | +# → Echo: llm-smoke |
| 250 | +``` |
| 251 | + |
| 252 | +`--strict-mcp-config` makes the file the only source of servers, so the answer |
| 253 | +cannot have come from a published copy configured elsewhere. A tool is |
| 254 | +addressed as `mcp__<server name in the config>__<tool name>`, and only the |
| 255 | +tools listed in `--allowedTools` can be called without a prompt. Keep the |
| 256 | +config file outside the worktree. |
| 257 | + |
| 258 | +Claude Code, run this way, has no era switch and does not print the protocol |
| 259 | +version it negotiated. `/pr-flow` asks for the LLM client in both eras as well; |
| 260 | +where the client gives no way to choose, record the client and its version and |
| 261 | +say that the era could not be selected, rather than labelling the run with an |
| 262 | +era it was not shown to use. Against a v1-SDK server the connection is legacy |
| 263 | +whatever the client prefers, for the reason given under Protocol eras. |
| 264 | + |
| 265 | +## What to write down |
| 266 | + |
| 267 | +For each client: the command or the prompt, and the result trimmed to what |
| 268 | +shows the change. For a bug fix, run the same CLI command against `v2/main` |
| 269 | +and against the branch, and keep both outputs. Where the evidence goes in the |
| 270 | +PR, and what a change with no client-visible surface records instead, is |
| 271 | +`/pr-flow`. |
0 commit comments