The nine phases¶
Phases run in order. Each returns findings with a status (pass, warn,
fail, skip, info), a severity on failures (critical, major,
minor), what was observed, advice, and evidence: the range of recorded
requests that produced it (req#12-14). A phase that finds the server
unreachable, or credentials missing for a protected server, blocks every
later phase; they are recorded as skipped with the reason.
Run a subset with --phases net,discovery,auth or leave one out with
--skip-phases performance.
net: Network and TLS¶
No MCP traffic yet.
| Finding | Checks |
|---|---|
net.scheme |
https; plain http to a non-loopback host is critical |
net.dns |
the hostname resolves |
net.tcp |
a TCP connection opens |
net.tls |
the handshake completes and the certificate verifies; scout never skips verification |
net.tls.version |
TLS 1.3, warning on 1.2 |
net.tls.cert |
not expired; warning inside 14 days |
discovery: Authorization discovery¶
First contact is made with a bare transport carrying no credentials of any kind, even when you supplied some, so the server's own enforcement is what is observed.
| Finding | Checks |
|---|---|
discovery.first_contact |
200 means open; 401 means protected; 403 or anything else is a deviation |
discovery.creds_unused |
warns when credentials were supplied to an open server |
discovery.challenge |
a WWW-Authenticate: Bearer challenge with resource_metadata |
discovery.prm |
RFC 9728 protected-resource metadata: the hint, then the path-aware and root well-known locations |
discovery.prm.resource |
the PRM's resource matches the endpoint |
discovery.as |
RFC 8414 or OpenID discovery for each listed authorization server |
discovery.as.https, discovery.as.pkce, discovery.as.grants |
TLS on the authorization server, S256 advertised, grant types |
discovery.registration |
Client ID Metadata Documents or dynamic registration offered |
discovery.override |
discovery bypassed because --token-url was given |
auth: Credentials and token¶
| Finding | Checks |
|---|---|
auth.mode, auth.source.* |
which mode is in use and where each credential came from |
auth.registration |
how the client identity was obtained: cimd, static or dcr |
auth.token |
the token was obtained (or loaded from the store); a failed exchange names the OAuth error |
auth.token.type, auth.token.expiry, auth.token.scope |
token_type is Bearer, expires_in is present and not tiny, granted scope covers what was requested |
auth.rejects_garbage |
a made-up bearer token is answered with 401 and a challenge; a 2xx here is critical |
handshake: MCP initialize handshake¶
| Finding | Checks |
|---|---|
handshake.initialize |
initialize succeeds with the credentials |
handshake.protocol_version |
the negotiated version |
handshake.server_info |
name and version populated |
handshake.capabilities |
tools, resources, prompts, logging declared |
handshake.instructions |
server instructions present |
handshake.session |
an Mcp-Session-Id was issued (stateless servers are noted, not penalised) |
protocol: Protocol conformance¶
Deliberately unusual requests, checked against what JSON-RPC 2.0 and the MCP specification require.
| Finding | Checks |
|---|---|
protocol.ping |
ping answers |
protocol.unknown_method |
an unknown method returns JSON-RPC -32601, not an HTTP error |
protocol.id_echo |
the response id matches the request id and jsonrpc is "2.0" |
protocol.malformed_json |
a truncated body is refused with 400 or -32700 |
protocol.invalid_params |
tools/call without a name is refused |
protocol.unknown_tool |
calling a tool that does not exist is reported, not answered with success |
protocol.accept_header, protocol.get_stream |
informational: strictness about Accept, and whether GET opens a server event stream |
protocol.bogus_session |
a session id the server never issued is rejected |
protocol.version_header |
a bad MCP-Protocol-Version is rejected |
catalog: Tool, resource and prompt catalog¶
Lists everything; invokes nothing.
| Finding | Checks |
|---|---|
catalog.tools.list, catalog.resources.list, catalog.prompts.list |
each list succeeds when its capability is declared, and nothing lists without one |
catalog.tools.unique |
tool names are unique |
catalog.tools.descriptions |
every tool has a description of at least 20 characters |
catalog.tools.input_schema |
inputSchema describes an object |
catalog.tools.annotations |
tools declare readOnlyHint/destructiveHint; unannotated tools are treated as destructive |
catalog.tools.output_schema |
tools declare outputSchema |
catalog.resources.uris, catalog.resources.mime, catalog.resources.templates |
absolute URIs, mime types, template listing |
catalog.prompts.descriptions |
prompts and their arguments are described |
catalog.empty |
critical when there are no tools, resources or prompts at all |
catalog.text.hidden |
invisible characters and bidirectional overrides in any catalog text |
catalog.text.comments |
HTML comments, which a rendered catalog hides and a model reads |
catalog.text.instructions |
text addressed to the model rather than describing the tool |
catalog.text.secret_paths |
descriptions naming SSH keys, AWS credentials, dotenv files or credential environment variables |
catalog.names.confusable |
a name mixing scripts, which is how one tool is made to render like another |
What the last five are about¶
A tool description is not documentation. It is input to the model, read before the model decides what to call, with the same standing as the user's own words. Everything above this point asks whether the catalog is well formed; these five ask whether it is honest.
They read every string that reaches the model, not just the ones a catalog
viewer renders: tool and resource descriptions and titles, prompt argument
descriptions, and every description and title inside an inputSchema or
outputSchema. The schema is where published poisoning has most often been
found, for the obvious reason — it is the part nobody looks at.
Severity is calibrated deliberately, because a scanner people learn to ignore is worse than none:
| Severity | What earns it |
|---|---|
critical |
text with no honest reading — telling the reader to disregard earlier instructions, to conceal something from the user, to act as a different agent; a bidirectional override; a mixed-script name |
major |
text a legitimate author very rarely writes and should be told about anyway — a reference to the system prompt, a pseudo-tag like <IMPORTANT>, a named credential path, an HTML comment |
minor |
worth a reader's attention, not worth a failed build — a dotenv path, a description directing the model's behaviour |
Each finding names the field it came from and quotes it, with invisible
characters rendered as their code points, because "an instruction was found"
is not something a maintainer can act on and …<U+202E>nothing… is.
execution: Safe execution and content validation¶
| Finding | Checks |
|---|---|
execution.policy |
the policy in force |
execution.tools |
each permitted tool invoked with generated arguments (or yours, via --arg tool.field=value); protocol errors and timeouts fail, isError results are reported honestly |
execution.content |
structuredContent validates against outputSchema; a declared schema with no structured content is a violation |
execution.validation |
each tool with required arguments is called once more with one omitted, and must reject the call |
execution.resources |
up to --max-resources resources read; failures and empty reads reported |
execution.prompts |
up to --max-prompts prompts rendered with placeholder arguments |
performance: Latency and concurrency¶
| Finding | Checks |
|---|---|
performance.ping |
--samples pings: p50, p95, max |
performance.tools |
every tool that succeeded, repeated --samples times; p95 above 2 s warns |
performance.warmup |
a first call far slower than the median |
performance.concurrency |
--concurrency workers × --samples calls on the fastest tool; errors fail, 429 without Retry-After warns |
performance.throttle, performance.rate_limit |
whether the burst was throttled by scout, and with --allow-load, whether the server rate-limited it |
resilience: Session and token recovery¶
| Finding | Checks |
|---|---|
resilience.session_reinit |
with the session id replaced by garbage, the client sees a 404, re-initializes, and the next call succeeds |
resilience.token_refresh |
with the cached token invalidated, the next call obtains a fresh one and succeeds |