--smoke runs the normal audit, then calls your server’s tools over the
same connection and checks the answers.
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-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 thatadd has no side effects. This is the one-line change:
server.py
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 code4. 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:
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 reportspass, 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: trueare called by default. An unannotated tool is skipped, and every skip is reported with its reason. -
--call-alllifts that default. No other flag implies it, and it has no environment-variable form. On the unannotated server from Step 1, it callsaddwithout 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 ofexamples, the firstenumvalue, then a zero value for the type. No language model is involved, so the same server receives the same calls on every run.
Technical details: errors from upstream skip rather than fail
Technical details: errors from upstream skip rather than fail
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 ownsmoke section of the JSON report and
never change score or max_score:
--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
--smokeinargs. 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 dedicatedsmokeinput. - 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 --smokeagainst your own server and read the skip reasons. - Annotate read-only tools with
readOnlyHint: trueso smoke mode calls them. - Read each
FAILline as check, tool, and reason. - Gate CI with
--fail-underand--smoke: exit3is the score, exit4is 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 systemStability Contract
Exit codes and the
smoke report sectionAuthenticated Servers
Pass credentials so smoke can reach behind the gate