Skip to main content
A refactor, a dependency bump or a new tool can break your MCP server without failing a single unit test. The server still starts. An agent that connects to it gets a tool whose result no longer matches the declared schema, or a handler that accepts arguments it should reject. Smoke mode catches that in CI: --smoke runs the normal audit, then calls your server’s tools over the same connection and checks the answers.
One run gives you the audit score and, under it, one line per smoke check. Each line reads verdict check_id [tool]: reason. This server has four tools: honest works, dishonest breaks its own output schema, sloppy accepts anything, and writer was never called because it is not annotated read-only.
--smoke invokes tools/call on the target. Run it against servers you operate. The ordinary audit never calls tools, so it is the right choice for anyone else’s server.

Smoke-test your own server

The steps below take a minimal server built with the MCP Python SDK from no coverage to a CI gate. Each step changes one thing and shows the run that proves it. The server has one tool:
server.py

Step 1: Run it and read the skips

Point mcpscore at the server and add --smoke. A server in its own project environment runs through uv run, the same way you start it yourself:
Tools called: 0 of 1. mcpscore calls only tools annotated readOnlyHint: true, and add has no annotations. The spec defaults a missing readOnlyHint to false, so add might write something, and smoke mode leaves it alone. Every skip says why, so the gap in coverage is visible. The failure is about the server, not about add. Step 3 explains it.

Step 2: Annotate the tool

Tell clients that add has no side effects. This is the one-line change:
server.py
Run the same command again:
add is now called twice. With valid arguments it returned a result that matches the output schema the SDK derived from -> int. With a string where an integer belongs it refused, which is the rejection the check wants. A tool that answers gets a returned line: the number and types of content blocks, whether structuredContent was present, whether the result was flagged isError, and the first 80 characters of text. The annotation also raised the score from 114/135 to 116/135, because tools_annotations_present now passes. The same annotation tells every client that add does not modify its environment, and your smoke run now covers it.

Step 3: Read a failure

smoke_unknown_tool calls a tool name that cannot exist on your server and expects a JSON-RPC error, as Tools §Error Handling requires for unknown tools. This server answers with a tool result flagged isError, which tells the client the tool exists and ran. The MCP Python SDK answers this way by default, in both FastMCP (1.x) and MCPServer (2.x), so a server built on it fails this check until the SDK changes or the server rejects unknown names itself. A failure in your own tool reads the same way. The first example on this page shows the two most common ones:
dishonest declares an output schema with a required result field and returns an object without it. sloppy returned a success result for arguments its input schema forbids. Both would reach an agent in production.

Step 4: Gate CI on it

Any smoke failure exits with code 4. Add the score gate the same way, and CI can tell a low score apart from broken tools:
Raise the threshold to 90 against the 84% server from Step 1 and both gates fail. The score gate wins:
Skips never gate. A server with no readOnlyHint: true tools and a correct unknown-tool rejection exits 0 and reports what it skipped and why. The full exit-code table is in the stability contract.

What each check catches

Each check reports pass, fail or skip. Skip means the check could not run. It never means the server failed it. For the two rejection checks, any JSON-RPC error code counts as a rejection except three: a request timeout, a closed connection, and -32603 internal error. Those three mean the server tried to run the call and failed. Each call has 30 seconds to answer.

Which tools get called

  • Only tools annotated readOnlyHint: true are called by default. An unannotated tool is skipped, and every skip is reported with its reason.
  • --call-all lifts that default. No other flag implies it, and it has no environment-variable form. On the unannotated server from Step 1, it calls add without the annotation:
  • The unknown-tool check uses a name derived from your server’s own catalog, so it cannot match a real tool. If the tool listing failed or is incomplete, the check skips instead of guessing a name.
  • Arguments come from each tool’s input schema, in this order: default, const, the first entry of examples, the first enum value, then a zero value for the type. No language model is involved, so the same server receives the same calls on every run.
A tool that returns an error result for valid synthesized arguments reports as skip in smoke_structured_content. It may be rejecting the sample value, or a service behind it may be down, and an outage elsewhere should not fail your build. A JSON-RPC error to valid arguments skips for the same reason. Only behavior your server owns can fail a smoke check.Calls run one at a time, in catalog order: every structured-content call first, then every invalid-arguments call, then the unknown-tool call. A malformed schema fragment is tolerated rather than aborting the run.

Results stay out of the score

The score is deterministic: the same server gets the same score. Tool calls depend on upstream services and on the environment, so smoke results live in their own smoke section of the JSON report and never change score or max_score:
When --smoke is requested but there is no session to call tools on, the section reports "executed": false with the reason instead of disappearing. That happens on a partial audit of an auth-gated server, on a modern-only probe audit, and when the tools catalog had to be recovered from the stateless lifecycle because the negotiated session could not list it.

Where it does not run

  • mcpscore.dev never runs smoke checks. Invoking tools on third-party URLs from our infrastructure is outside the website’s safety model, so smoke mode is CLI-only.
  • The GitHub Action accepts --smoke in args. A smoke failure fails the job after the report and PR comment are published. The comment does not list the smoke checks yet, and there is no dedicated smoke input.
  • Your test suite still has a job. Smoke checks prove the MCP contract: honest schemas, correct rejections, tools that answer. They do not test your business logic.

Recap

  • Run mcpscore --smoke against your own server and read the skip reasons.
  • Annotate read-only tools with readOnlyHint: true so smoke mode calls them.
  • Read each FAIL line as check, tool, and reason.
  • Gate CI with --fail-under and --smoke: exit 3 is the score, exit 4 is broken tools.

What’s next

GitHub Action

Run the same gate on every pull request

CI beyond GitHub

Exit codes and --json in any CI system

Stability Contract

Exit codes and the smoke report section

Authenticated Servers

Pass credentials so smoke can reach behind the gate