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.
A server that is a program rather than a URL runs the same phases, but two of them have no subject over a pipe and two are replaced by checks about the process instead. Servers that are programs has the details, and every check a stdio run does not make appears in its report as skipped, by id, with the reason.
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; passmcp never skips verification |
net.tls.version |
TLS 1.3, warning on 1.2 |
net.tls.cert |
not expired; warning inside 14 days |
Over stdio there is nothing to resolve, connect to or verify, so this phase
asks the question those checks were really asking — is the thing on the
other end there — with stdio.process and stdio.environment instead.
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.dpop |
DPoP (RFC 9449) as the metadata and the 401 describe it: asymmetric proof algorithms only; a resource that requires bound tokens names algorithms at its authorization server and a DPoP challenge. Read, not exercised; absence is info while MCP's profile is a draft |
discovery.enterprise_managed |
the Enterprise-Managed Authorization grant profile (ID-JAG): when advertised, grant_types_supported must include the JWT bearer grant it is presented with. Read, not exercised |
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.mrtr |
whether a request for client input can be answered: every inputRequests entry has a method and a correlation id. Emitted from the execution phase, where the observations exist |
protocol.extensions |
the extensions server/discover advertises, separated into the specification's own and an author's, with their identifiers checked for reverse-DNS shape |
protocol.deprecated_features |
whether a server on 2026-07-28 still answers initialize and ping, and whether supportedVersions says it means to |
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 |
protocol.tasks.unknown_id, protocol.tasks.capability |
for a server advertising the Tasks extension: an unknown task id gets -32602, and a client that did not declare the extension gets -32021 |
protocol.tasks.undeclared |
no task is returned to a call that did not declare the extension |
protocol.tasks.lifecycle |
a task created by calling a read-only tool is retrievable at once, carries the required fields, reaches a terminal state within 30 seconds and keeps it; passmcp cancels any task it does not see finish |
protocol.origin |
a request from a foreign Origin is refused, as the transport requires against DNS rebinding; failing on loopback or a private address, a warning on a public host |
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.idempotency |
information: which state-changing tools declare idempotentHint, and which leave it at the specification's default of "not safe to repeat"; a read-only tool declaring it is not idempotent is a warning |
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 |
tools that succeeded, repeated --samples times — all of them up to ten, otherwise the five slowest plus five picked by a seed from the target, so two runs repeat the same tools; 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 passmcp, and with --allow-load, whether the server rate-limited it |
Every timed call passes through passmcp's recorder, because a finding has to cite the request it came from. Its cost was measured on loopback, where it is not hidden by the network: indistinguishable from noise for a 200-byte answer, about 0.1 ms at 20 KB, and about 1.2 ms at 500 KB. Against a real server, whose round trip is tens of milliseconds, that is inside the spread of the samples. passmcp keeps the recorder on rather than publish latency figures it cannot cite.
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 |
A pipe has neither a session to lose nor a token to renew — the connection
is the session. What this phase asks over stdio instead is whether the
process survived the run (stdio.alive), whether it kept the transport
clean (stdio.stdout_clean), and what it logged on the way
(stdio.stderr). Those run even when an earlier phase blocked the rest,
because a server that stopped answering is exactly when they are the
findings that explain everything else.
It also reports what the server did after its handshake, on Linux, from
samples of its process group under /proc taken while every other phase
ran. Whatever was open when the handshake completed is bootstrap and is
not reported; after it, the only reason to act is a request passmcp sent.
| Finding | Reports |
|---|---|
stdio.post_init_connections |
a socket to a non-loopback address that did not go through passmcp's proxy; a warning |
stdio.post_init_writes |
a file open for writing outside the working directory, passmcp's scratch home and /dev, /proc, /sys; a warning |
stdio.post_init_processes |
processes started in the server's group; information |
These are samples, taken every 100 ms, and each finding says so: a connection opened and closed between two samples is not seen, so seeing nothing is information, never a pass. The roadmap asked for a sandbox that snaps shut at the handshake; that cannot be built from outside, because Landlock and seccomp are restrictions a process applies to itself. Off Linux the three are skipped by name.
With --soak N, the run asks the one question fifty requests cannot: does
the server's memory settle, or does every call leave something behind? The
fastest tool that succeeded in the execution phase is called N more times
in sequence, paced by --rps like every other call, and the resident
memory of the server's process group is read from /proc after each. The
first tenth of the samples is dropped as warm-up — a runtime that grows its
heap for a few dozen calls and then holds is behaving well — and a
least-squares line is fitted through the rest.
| Finding | Checks |
|---|---|
resilience.soak_memory |
the line through resident memory against call number is not a leak: it explains less than 60% of the variation, or adds up to less than sixteen mebibytes and less than a quarter of where the window started, or has flattened over the last quarter of the window. A line that fits, adds up and is still rising fails as major, as does a server that stops answering or exits part way through |
The measurement is the slope, not the peak: a collector's sawtooth has a
slope and no fit, a warm-up has a fit and no slope after the cut, and a
heap that is settling flattens before the end. The floor is high on
purpose: a runtime growing its heap towards its first collection rises in
a straight line for as long as the window is shorter than one collection
cycle, and a rise of a few mebibytes cannot be told from that ramp by its
shape. The numbers are in the finding either way. It
needs at least 100 calls, a program to run (the memory read is of a process
passmcp started, so --soak without --stdio is refused), and Linux to read
it on; elsewhere it is skipped by name. At the default pacing a thousand
calls take about eight minutes; --rps 0 removes the throttle for a server
you own.
With --fault-upstream, the run ends by asking what an agent sees when a
server's dependency is down. The proxy --watch-egress points the server
at (the flag implies it) holds every new connection open without answering,
as a hung upstream would, and up to three tools that succeeded earlier are
called again with the same arguments. Only a call during which the server
tried to connect counts.
| Finding | Checks |
|---|---|
resilience.upstream_down |
a call whose upstream never answers comes back — an error, isError, or an answer from a cache — within the call timeout. One that does not come back, or a server that exits, fails as major |
It is off by default, because it is the one part of a run that makes the
server's world worse on purpose, and it needs a program to run: an endpoint
passmcp did not start cannot be pointed at the proxy, so --fault-upstream
without --stdio is refused rather than silently skipped.