Placard
Static analysis and drift detection for the surface an MCP server exposes to an agent.
pip install mcp-placard
placard scan "npx -y @modelcontextprotocol/server-filesystem ." --out baseline.json
# ... a week later
placard scan "npx -y @modelcontextprotocol/server-filesystem ." --out current.json
placard diff baseline.json current.json # exit 1 escalation, 2 prompt change, 3 removal
Teams wire agents to Model Context Protocol servers with no record of what capability they just granted, and no mechanism to notice when that capability changes. A server can add a destructive tool, or silently rewrite a tool description — which is a prompt injected directly into the agent's context — and nothing in the ecosystem today flags either event.
Placard connects to an MCP server, enumerates its surface, emits a canonical hashed manifest, and fails CI when the next scan disagrees with the last one.
Placard never invokes a tool. Enumeration and static analysis only. No code path may
call tools/call; scripts/check_no_tool_invocation.py enforces that mechanically.
Status: Phases 1 through 4 complete
scan classifies every tool on the R0-R5 ladder — schema shape, tool name, description, and
declared-annotation signals combine as a monotonic maximum (never a weighted score), reconciled
against what the server declared about itself. Alongside the tier every tool carries a kinds
set (read_sensitive, egress, write, destructive, code_exec) derived from the same
evidence, and CHAIN_EXFIL is a predicate over kinds. The full ladder, its worked examples, and
the rules the classifier implements are in docs/TAXONOMY.md. diff grades
tier increases and gates new-tool escalation on a configurable ceiling; a per-tool
classification entry never enters surface_hash — a classifier fix must never move that hash
for a server that did not change. scan also runs seven deterministic injection heuristics
over every model-facing string — tool and schema-property descriptions, server instructions,
prompts, resources — flagging text that reaches outside its own scope, with a false-positive
rate ratcheted at zero on 388 real strings (docs/INJECTION.md; detection numbers below). diff
re-analyses any manifest produced under an older ruleset before comparing, so a Placard upgrade
never produces findings on a server that did not change. Phase 4 shipped SARIF output and the
GitHub Action below (v0.4.0). v0.5.0 added rulesets 3.4 and 3.5 (false-positive fixes from the
held-out v2 review), the held-out v2 score, and merge-gate CI. Manifest signing (Phase 5) is not
yet built.
What do popular servers expose? docs/INDEX.md is a published index of the
blast radius of nine widely used public MCP servers at pinned versions, rendered from committed
manifests and reproducible with scripts/build_index.py.
In CI, Placard is a GitHub Action. placard.toml names the servers a repository depends on;
committed baseline manifests are the approval record; the action scans each server in an isolated
environment, diffs it against its baseline, writes a step summary and a SARIF log for code
scanning, exposes one output per finding category so a prompt change and an escalation can be
routed to different reviewers, and fails on the categories you choose. It installs every
dependency hash-pinned and Placard itself from its own checkout at the SHA you pinned.
docs/ACTION.md.
- uses: Lanier-Developments/mcp-placard@<commit-sha>
with:
fail-on: escalation,prompt,injection,incomplete
Against real servers
Phase 2.1 corrected the classifier against 11 public MCP servers (109 tools) after the first
real-server batch found three compounding rule ambiguities. The before/after, same servers,
same day-old surfaces (every surface_hash unchanged — the classifier moved, the servers did
not):
| Server | Tools | Phase 2 | Phase 2.1 | CHAIN_EXFIL |
|---|---|---|---|---|
| filesystem | 14 | R0:1 R1:1 R2:1 R5:11 | R0:1 R1:9 R5:4 | spurious → none |
| memory | 9 | R0:1 R1:2 R3:4 R4:2 | R0:1 R1:2 R3:6 | spurious → none |
| github | 26 | R1:15 R3:7 R5:4 | R1:14 R3:10 R5:2 | spurious → none |
| playwright | 26 | R0:3 R1:21 R4:2 | R0:1 R1:7 R3:14 R4:2 R5:2 | none → code_exec |
| git | 12 | R1:11 R2:1 | R1:7 R3:5 | — |
| everything, fetch, time, sequential-thinking, deepwiki, context7 | 22 | unchanged | unchanged | — |
Injection detection, measured
The heuristics are scored three ways, reported separately so a strong number cannot hide a weak one. Synthetic samples (authored alongside the detectors) measure coverage; lifted samples (reconstructed from public write-ups) measure realism; a held-out set authored independently and never opened during development is the only number that measures generalisation. Each set is scored once, as-is. Held-out v2 (ruleset 3.3) is the current headline; v1 (ruleset 3.0) is shown beside it:
| Class | Held-out v1 (ruleset 3.0) | Held-out v2 (ruleset 3.3) |
|---|---|---|
| exfil_sink | 5/5 | 7/7 |
| hidden_content | 5/5 | 7/7 |
| markup_smuggling | 3/5 | 7/7 |
| cross_scope | 3/5 | 5/7 |
| sensitive_target | 5/5 | 5/7 |
| override | 0/5 | 1/7 |
| concealment | 3/5 | 1/7 |
| overall | 24/35 (68%) | 33/49 (67%) |
The flat overall number is the average of opposite movements. v1 showed detectors that had learned
the synthetic corpus's phrasing rather than the class, so ruleset 3.1 replaced those phrase matches
with structural rules (a hierarchy referent plus an invalidator in one sentence; a wider
concealment audience; tool ownership rather than the word "server"; any paired custom tag), and v1
was retired into the regression corpus at 35/35. On v2, the structural rewrite generalised for
markup_smuggling (60% to 100%) and cross_scope (60% to 71%). It did not for override (0% to
14%) or concealment (60% to 14%), which is worse on new vocabulary than the phrase rules it
replaced. Those two classes are the open work, and until they are fixed do not rely on Placard to
catch an override or a concealment instruction on first sight: on unseen vocabulary it found
1 of 7 in each class. Treat the baseline diff as the defence for those two. A description that
starts saying something new is flagged by description_hash whether or not any heuristic fires.
See Known limits.
v2 also added the first coverage of prompt and resource surfaces (14/21, 66%) and a benign set:
6 of its 15 samples fired, three of them artefacts of how a sample is built and three real
false positives, which rulesets 3.4 and 3.5 fixed (token-matched path and credential evidence,
exfil_sink narrowed to a URL governed by a transmission verb, path exemption scoped to its
family). The 388-string real-server benign corpus remains at zero false
positives. Full analysis:
docs/dispatches/2026-09-23_from-jr_to-chief_heldout-v2-score.md.
The tool caught its first real drift. Between two of these runs the Playwright @latest
package shipped a release. Nobody was watching it; the diff named every change and returned
the documented code:
$ placard diff playwright-before.json playwright-after.json
[tool_added] tool 'browser_emulate_media' added (tier R3; schema 3b4bcf539a7e)
[tool_removed] tool 'browser_webmcp_call' removed (was tier R3)
[tool_removed] tool 'browser_webmcp_list' removed (was tier R0)
[server_capabilities_changed] server capabilities changed (ba8e230d1afc -> 46f63fd549bb)
exit=3
The last line is the one worth noticing. The capabilities block used to sit inside the hashed
body; it was split out precisely so that a change to it would surface as a named finding
instead of as surface_hash moving for no stated reason. That design decision paid for itself
here, in public, on a target nobody prompted.
What moved, and why: eight filesystem reads left R5 (a path on read_file is a source, not
a destination); move_file reached R5 on destination; GitHub's create_or_update_file became
R3 verified on its sha; every git and GitHub mutation verb (add, commit, checkout,
merge, fork, reset) reached R3; the memory server's graph edges (from/to) stopped
reading as email; Playwright's browser_evaluate and browser_run_code_unsafe went from R1 to R5
with every kind, and the server's only CHAIN_EXFIL is now the one it should have — code
execution carries both halves alone. The full delta and the flag-backs are in
docs/dispatches/2026-09-18_from-jr_to-chief_phase2.1-report.md.
Install
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pre-commit install
Requires Python 3.11+. The test suite needs no network access.
Use
# Enumerate a server, classify every tool, and print the manifest
placard scan "python -m tests.mock_server"
placard scan https://mcp.example.com/mcp --out manifest.json
# Downgrade a tier only through an explicit, attributable allowlist entry
placard scan "python -m tests.mock_server" --override overrides.json
# Compare two manifests; the exit code is the finding
placard diff baseline.json current.json
placard diff baseline.json current.json --ceiling R5 # only R5 additions escalate
# Confirm a manifest's hashes — including its classification — still describe its own content
placard verify manifest.json
<target> is a stdio command line or an HTTP(S) URL. The transport is inferred from the target;
--transport stdio|http overrides. --override points at a JSON array of
{"entry_id", "tool", "tier", "reason"} objects — the only way a tier is ever downgraded.
--ceiling (default R4) sets the tier a new tool must reach before diff escalates on it;
--escalate-schema-changes reverts to escalating on every input-schema change, even one that
leaves the tier unchanged.
Exit codes
A per-command contract, pinned in AGENTS.md and in
tests/test_exit_code_contract.py. diff's status is a bitmask of finding categories, OR'd
together, so a consumer can ask about one category regardless of what else happened in the run.
| Command | Codes |
|---|---|
scan |
0 enumerated · 3 server unreachable · 64 usage error |
diff |
bits: 1 escalation · 2 prompt change · 4 tool removed · 8 new injection finding · 64 usage error (exclusive) |
verify |
0 every hash matches · 1 a hash does not match · 64 usage error |
baseline |
0 written · 3 a server could not be scanned · 64 usage error |
check |
diff's bits OR'd across servers · 16 a server could not be scanned · 64 usage error |
report |
0 rendered · 101 manifest fails verify · 102 baseline fails verify · 64 usage error |
placard diff baseline.json current.json
rc=$?
(( rc & 2 )) && echo "a description changed — route to prompt review"
(( rc & 1 )) && echo "blast radius escalated — route to security review"
Codes 100-109 belong to report. Read a code only in the context of the command
that produced it: verify's 1 and diff's 1 share a number, not a meaning.
The manifest
Canonical JSON: sorted keys, stable array ordering, nothing environment-dependent in a hashed
body. No timestamp, no scan target — two scans of an unchanged server are byte-identical. The
negotiated protocol version and SDK version are recorded, but only inside environment, which
no hash ever covers.
Manifest format 2.2; 2.1, 2.0, and 1.0 documents still load, verify, and diff. A stored
baseline's classification_hash reproduces under this build because absent fields serialize as
absent and a missing ruleset_version selects the older hash shape; diff then re-analyses the
older side under the current ruleset before comparing.
Independent SHA-256 hashes, all required, never collapsed:
surface_hash— tools, resources, prompts, instructions- per-tool
schema_hash— the input schema only - per-tool
description_hash— the description text only capabilities_hash— the server's declared MCP capabilities block onlyclassification_hash— Placard's own analysis:classification,injection_findings, and theruleset_versionthat produced them
Splitting schema from description is what makes "the server rewrote its prompt but kept the API
identical" a visible event rather than a silent one. capabilities and classification are
both split out of surface_hash for the same reason in the other direction: some capability
flags are SDK-derived and can drift on a client SDK upgrade, and a classifier rule fix can
change a tier, neither with any server-side change at all — each gets its own finding
(server_capabilities_changed, tier_escalated) instead of moving surface_hash.
Development
pytest # full suite
pytest -m "not slow" # skip subprocess integration tests
ruff check . && ruff format --check .
mypy src/
python scripts/check_no_tool_invocation.py
Coverage floor is 85% on src/, enforced in CI. CI also scans the bundled mock server and
diffs the result against tests/fixtures/mock_server_manifest.json — the tool gates itself.
Regenerate that fixture deliberately, never to make a red build green:
placard scan "python -m tests.mock_server" --out tests/fixtures/mock_server_manifest.json
Documentation
AGENTS.md— the operating specdocs/THREAT_MODEL.md— what Placard does and does not defend againstdocs/TAXONOMY.md— the R0-R5 risk ladder, the kind axis, Rules A-H, worked examplesdocs/INJECTION.md— the seven injection classes, the corpus, the ratchetdocs/ACTION.md—placard.toml,baseline,check, the GitHub Action, and what scanning executes
License
MIT — see LICENSE. © Lanier Developments.
Metadata
Release files for mcp-placard 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_placard-0.5.1.tar.gz | 371.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_placard-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 515.0 kB
Release files / mcp_placard-0.5.1.tar.gz
| Download URL | mcp_placard-0.5.1.tar.gz |
|---|---|
| Size | 371.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d12947c61cafaae44ab319cbe080200b57ec1041a71f79dd4f7dfaba07602c20
|
|
BLAKE2b-256 checksum How to use checksums |
3b3189157cdfa3e398c7b4deb335c1fdb105275478c067e617b09213c7d7c075
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / mcp_placard-0.5.1-py3-none-any.whl
| Download URL | mcp_placard-0.5.1-py3-none-any.whl |
|---|---|
| Size | 143.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f9f5c80063778d79c199892410ea36dcc1e4ae0717d7526e285ab37237c60367
|
|
BLAKE2b-256 checksum How to use checksums |
172a3bb4efb12901652edfd919a4777ea8da0d85b7d5aa8dee7f52e47961bfdf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log