mcp-placard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_placard-0.1.0/.gitignore +13 -0
- mcp_placard-0.1.0/AGENTS.md +230 -0
- mcp_placard-0.1.0/LICENSE +21 -0
- mcp_placard-0.1.0/PKG-INFO +181 -0
- mcp_placard-0.1.0/README.md +130 -0
- mcp_placard-0.1.0/docs/TAXONOMY-amendment-1.md +176 -0
- mcp_placard-0.1.0/docs/TAXONOMY.md +669 -0
- mcp_placard-0.1.0/docs/THREAT_MODEL.md +269 -0
- mcp_placard-0.1.0/pyproject.toml +117 -0
- mcp_placard-0.1.0/scripts/check_no_tool_invocation.py +77 -0
- mcp_placard-0.1.0/src/mcp_placard/__init__.py +22 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/README.md +87 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/__init__.py +91 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/candidates.py +27 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/chain.py +49 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/combine.py +41 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/overrides.py +98 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/patterns.py +61 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/reversibility.py +40 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/schema_walk.py +181 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/signals/__init__.py +8 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/signals/annotations.py +77 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/signals/description.py +85 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/signals/schema_shape.py +257 -0
- mcp_placard-0.1.0/src/mcp_placard/classify/signals/verb.py +92 -0
- mcp_placard-0.1.0/src/mcp_placard/cli.py +266 -0
- mcp_placard-0.1.0/src/mcp_placard/diff/README.md +88 -0
- mcp_placard-0.1.0/src/mcp_placard/diff/__init__.py +32 -0
- mcp_placard-0.1.0/src/mcp_placard/diff/engine.py +229 -0
- mcp_placard-0.1.0/src/mcp_placard/diff/models.py +135 -0
- mcp_placard-0.1.0/src/mcp_placard/errors.py +99 -0
- mcp_placard-0.1.0/src/mcp_placard/inject/README.md +37 -0
- mcp_placard-0.1.0/src/mcp_placard/inject/__init__.py +17 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/README.md +135 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/__init__.py +108 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/build.py +159 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/canonical.py +75 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/hashing.py +84 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/io.py +107 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/models.py +352 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/raw.py +45 -0
- mcp_placard-0.1.0/src/mcp_placard/manifest/verify.py +82 -0
- mcp_placard-0.1.0/src/mcp_placard/report/README.md +43 -0
- mcp_placard-0.1.0/src/mcp_placard/report/__init__.py +17 -0
- mcp_placard-0.1.0/src/mcp_placard/transport/README.md +53 -0
- mcp_placard-0.1.0/src/mcp_placard/transport/__init__.py +74 -0
- mcp_placard-0.1.0/src/mcp_placard/transport/base.py +207 -0
- mcp_placard-0.1.0/src/mcp_placard/transport/http.py +49 -0
- mcp_placard-0.1.0/src/mcp_placard/transport/stdio.py +49 -0
- mcp_placard-0.1.0/src/mcp_placard/transport/target.py +79 -0
- mcp_placard-0.1.0/tests/__init__.py +5 -0
- mcp_placard-0.1.0/tests/conftest.py +145 -0
- mcp_placard-0.1.0/tests/fixtures/legacy_manifest_1_0.json +56 -0
- mcp_placard-0.1.0/tests/fixtures/mock_server_manifest.json +477 -0
- mcp_placard-0.1.0/tests/fixtures/schema_traversal/anyof_dangerous_branch.json +23 -0
- mcp_placard-0.1.0/tests/fixtures/schema_traversal/depth_exceeded.json +109 -0
- mcp_placard-0.1.0/tests/fixtures/schema_traversal/nested_allof_to.json +26 -0
- mcp_placard-0.1.0/tests/fixtures/schema_traversal/nested_if_match.json +20 -0
- mcp_placard-0.1.0/tests/fixtures/schema_traversal/ref_cycle.json +20 -0
- mcp_placard-0.1.0/tests/fixtures/schema_traversal/unresolvable_ref.json +12 -0
- mcp_placard-0.1.0/tests/mock_server/__init__.py +25 -0
- mcp_placard-0.1.0/tests/mock_server/__main__.py +7 -0
- mcp_placard-0.1.0/tests/mock_server/server.py +94 -0
- mcp_placard-0.1.0/tests/mock_server/surface.py +381 -0
- mcp_placard-0.1.0/tests/test_canonical.py +70 -0
- mcp_placard-0.1.0/tests/test_classify_chain.py +76 -0
- mcp_placard-0.1.0/tests/test_classify_fixture_matrix.py +367 -0
- mcp_placard-0.1.0/tests/test_classify_overrides.py +141 -0
- mcp_placard-0.1.0/tests/test_classify_reversibility.py +54 -0
- mcp_placard-0.1.0/tests/test_classify_schema_traversal.py +119 -0
- mcp_placard-0.1.0/tests/test_classify_signals.py +115 -0
- mcp_placard-0.1.0/tests/test_cli.py +206 -0
- mcp_placard-0.1.0/tests/test_diff_table.py +225 -0
- mcp_placard-0.1.0/tests/test_diff_tier_stub.py +111 -0
- mcp_placard-0.1.0/tests/test_enumeration.py +160 -0
- mcp_placard-0.1.0/tests/test_exit_code_contract.py +70 -0
- mcp_placard-0.1.0/tests/test_hashing.py +96 -0
- mcp_placard-0.1.0/tests/test_integration.py +294 -0
- mcp_placard-0.1.0/tests/test_manifest.py +263 -0
- mcp_placard-0.1.0/tests/test_manifest_version_upgrade.py +127 -0
- mcp_placard-0.1.0/tests/test_mock_server.py +117 -0
- mcp_placard-0.1.0/tests/test_no_tool_invocation.py +87 -0
- mcp_placard-0.1.0/tests/test_patterns.py +62 -0
- mcp_placard-0.1.0/tests/test_schema_walk.py +278 -0
- mcp_placard-0.1.0/tests/test_self_gating.py +61 -0
- mcp_placard-0.1.0/tests/test_transport.py +149 -0
- mcp_placard-0.1.0/tests/test_verify.py +99 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# AGENTS.md — Placard
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
Placard is a static analysis tool for Model Context Protocol servers. It connects to an
|
|
6
|
+
MCP server, enumerates the surface that server exposes to an agent, classifies the blast
|
|
7
|
+
radius of every tool, and emits a canonical, hashed manifest. It then diffs manifests across
|
|
8
|
+
versions and fails CI when the blast radius escalates.
|
|
9
|
+
|
|
10
|
+
The problem it solves: teams connect agents to MCP servers with no record of what capability
|
|
11
|
+
they just granted, and no mechanism to notice when that capability changes. A server can add a
|
|
12
|
+
destructive tool, or silently rewrite a tool description — which is a prompt injected directly
|
|
13
|
+
into the agent's context — and nothing in the ecosystem today flags either event.
|
|
14
|
+
|
|
15
|
+
Two principles govern every design decision in this repo:
|
|
16
|
+
|
|
17
|
+
1. **A tool description change is a prompt change.** Descriptions are model-facing input.
|
|
18
|
+
They are hashed separately from schemas and surfaced as reviewable diffs, not metadata noise.
|
|
19
|
+
2. **Server self-declaration is evidence, not truth.** MCP annotations (`readOnlyHint`,
|
|
20
|
+
`destructiveHint`, `idempotentHint`, `openWorldHint`) are supplied by the party being
|
|
21
|
+
audited. Placard records declared risk and inferred risk as separate fields and reports
|
|
22
|
+
disagreement as a finding.
|
|
23
|
+
|
|
24
|
+
**Hard non-goal: Placard never invokes a tool.** It performs enumeration and static
|
|
25
|
+
analysis only. No code path may call `tools/call`. A PR that adds one is rejected on sight.
|
|
26
|
+
|
|
27
|
+
Owner: Lanier Developments. License: MIT. Repo: `github.com/Lanier-Developments/mcp-placard`.
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
python -m venv .venv && source .venv/bin/activate
|
|
33
|
+
pip install -e ".[dev]"
|
|
34
|
+
pre-commit install
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.11+. No network access is needed for the test suite; all server interaction
|
|
38
|
+
in tests runs against fixtures and a local mock server.
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
41
|
+
|
|
42
|
+
| Command | Purpose |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `placard scan <target>` | Connect, enumerate, emit manifest JSON to stdout |
|
|
45
|
+
| `placard diff <old.json> <new.json>` | Compare two manifests; exit nonzero on escalation |
|
|
46
|
+
| `placard report <manifest.json>` | Human-readable Markdown or SARIF output |
|
|
47
|
+
| `placard verify <manifest.json>` | Recompute and check manifest hashes |
|
|
48
|
+
|
|
49
|
+
`<target>` accepts a stdio command string or an HTTP(S) URL. Transport is inferred; `--transport`
|
|
50
|
+
overrides.
|
|
51
|
+
|
|
52
|
+
Development:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pytest # full suite
|
|
56
|
+
pytest -m "not slow" # fast loop
|
|
57
|
+
ruff check . && ruff format --check .
|
|
58
|
+
mypy src/
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Directory Structure
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
src/mcp_placard/
|
|
65
|
+
cli.py Command entry points (typer)
|
|
66
|
+
transport/ stdio and http MCP client wrappers — connect and enumerate only
|
|
67
|
+
manifest/ Canonical serialization, hashing, schema (pydantic models)
|
|
68
|
+
classify/ Risk tier inference, declared-vs-inferred reconciliation
|
|
69
|
+
inject/ Injection surface heuristics over descriptions and resource text
|
|
70
|
+
diff/ Manifest comparison and escalation rules
|
|
71
|
+
report/ Markdown and SARIF renderers
|
|
72
|
+
tests/
|
|
73
|
+
fixtures/ Captured manifests and synthetic server surfaces
|
|
74
|
+
mock_server/ Local MCP server used by integration tests
|
|
75
|
+
docs/
|
|
76
|
+
THREAT_MODEL.md What Placard does and does not defend against
|
|
77
|
+
TAXONOMY.md Risk tier definitions with worked examples
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every directory under `src/` carries a README.md stating its responsibility and its boundary.
|
|
81
|
+
|
|
82
|
+
## Risk Taxonomy
|
|
83
|
+
|
|
84
|
+
Tiers are ordered. A tool receives the highest tier any signal supports.
|
|
85
|
+
|
|
86
|
+
| Tier | Name | Meaning |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| R0 | Inert | No side effects, no data return of consequence — metadata, schema, help |
|
|
89
|
+
| R1 | Read-scoped | Reads data inside the server's own declared domain |
|
|
90
|
+
| R2 | Read-sensitive | Reads user or organizational data — mail, files, calendar, directory |
|
|
91
|
+
| R3 | Write-scoped | Creates or mutates within the server's domain, reversibly |
|
|
92
|
+
| R4 | Egress | Moves data outward — send, post, publish, webhook, arbitrary-URL fetch |
|
|
93
|
+
| R5 | Irreversible | Delete, purge, revoke, rotate, transfer funds, write credentials |
|
|
94
|
+
|
|
95
|
+
Inference draws on four signal classes: input schema shape (fields named like `to`, `recipient`,
|
|
96
|
+
`url`, `amount`, `path`, `force`), declared annotations, tool name verb, description text. Tier
|
|
97
|
+
assignment is a **monotonic maximum over signals; ordering governs citation only** — it never
|
|
98
|
+
reduces a tier. `docs/TAXONOMY.md` holds the worked examples and the full rule set (Amendment 1,
|
|
99
|
+
Rules A-G); the classifier must cite which signals produced a tier so findings are auditable.
|
|
100
|
+
|
|
101
|
+
Never silently downgrade a tier because a server declares it safe. Downgrades require an explicit
|
|
102
|
+
allowlist entry in the consuming repo's config, and the manifest records that an override applied.
|
|
103
|
+
|
|
104
|
+
## Manifest Format
|
|
105
|
+
|
|
106
|
+
Manifests are canonical JSON: sorted keys, stable array ordering, no timestamps or
|
|
107
|
+
environment-dependent values in the hashed body. Hashing is SHA-256.
|
|
108
|
+
|
|
109
|
+
Three hash levels, all required:
|
|
110
|
+
|
|
111
|
+
- `surface_hash` — the whole server surface
|
|
112
|
+
- per-tool `schema_hash` — input schema only
|
|
113
|
+
- per-tool `description_hash` — description text only
|
|
114
|
+
|
|
115
|
+
Splitting schema from description is what makes "the server rewrote its prompt but kept the API
|
|
116
|
+
identical" a visible event. Do not collapse these into one hash for convenience.
|
|
117
|
+
|
|
118
|
+
## Exit Codes
|
|
119
|
+
|
|
120
|
+
Exit codes are **categories, not a severity ladder**: `2` is a different review path, not a
|
|
121
|
+
worse outcome than `1`. Codes are pinned per command below and are an interface Phase 4's
|
|
122
|
+
GitHub Action consumes — a code changing meaning for a command is a breaking change to that
|
|
123
|
+
interface, not an implementation detail. Pin every code for every command in tests; add a row
|
|
124
|
+
before changing one (`tests/test_exit_code_contract.py`, `tests/test_diff_table.py`).
|
|
125
|
+
|
|
126
|
+
### `scan`
|
|
127
|
+
|
|
128
|
+
| Code | Condition |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| 0 | Enumerated successfully; manifest written |
|
|
131
|
+
| 3 | Server unreachable, or enumeration failed after a successful handshake |
|
|
132
|
+
| 10 | Usage or configuration error (unresolvable transport, empty target) |
|
|
133
|
+
|
|
134
|
+
### `diff`
|
|
135
|
+
|
|
136
|
+
| Code | Condition |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| 0 | No change, or changes entirely below the configured ceiling |
|
|
139
|
+
| 1 | Escalation — new tool at or above `--ceiling` (default R4), a tier increase, capabilities changed, or (Phase 3) a new injection finding |
|
|
140
|
+
| 2 | Description change on any existing tool (prompt change, requires review) |
|
|
141
|
+
| 3 | Tool removed |
|
|
142
|
+
| 10 | Usage or configuration error (unreadable file, malformed JSON, unsupported `manifest_version`) |
|
|
143
|
+
|
|
144
|
+
When several conditions apply at once, the reported code is the highest-precedence one:
|
|
145
|
+
`3 > 1 > 2 > 0`. Every finding is still listed on stderr regardless of which code wins. Exit
|
|
146
|
+
code 2 is deliberately never silenceable by tier config — a prompt change is always reviewable.
|
|
147
|
+
|
|
148
|
+
A tool absent from a manifest's `classification` — an old `"1.0"` manifest, or one nothing has
|
|
149
|
+
classified — falls back to the Phase 1 conservative default (escalate) for both `tool_added` and
|
|
150
|
+
`tool_schema_changed`: AGENTS.md forbids treating "we cannot grade this" as "this is safe." A
|
|
151
|
+
schema change on a tool that *can* be graded escalates only if it moved the tier (already caught
|
|
152
|
+
separately as a tier increase) or if `--escalate-schema-changes` is set.
|
|
153
|
+
|
|
154
|
+
### `verify`
|
|
155
|
+
|
|
156
|
+
| Code | Condition |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| 0 | Every recorded hash matches its content |
|
|
159
|
+
| 1 | At least one recorded hash does not match — the manifest was edited after it was produced |
|
|
160
|
+
| 10 | Usage or configuration error (unreadable file, malformed JSON, unsupported `manifest_version`) |
|
|
161
|
+
|
|
162
|
+
`verify`'s code `1` and `diff`'s code `1` share a number, not a meaning — the first is an
|
|
163
|
+
integrity failure, the second is a risk escalation. Read a code only in the context of the
|
|
164
|
+
command that produced it.
|
|
165
|
+
|
|
166
|
+
### `report` (reserved, Phase 4)
|
|
167
|
+
|
|
168
|
+
Not implemented yet. Codes `20`-`29` are reserved for it so nothing in `scan`, `diff`, or
|
|
169
|
+
`verify` claims them before Phase 4 defines their meaning.
|
|
170
|
+
|
|
171
|
+
## Testing
|
|
172
|
+
|
|
173
|
+
Every risk tier needs fixture coverage in both directions: a tool that belongs in it, and a
|
|
174
|
+
near-miss that does not. Classifier changes without a new fixture pair are incomplete.
|
|
175
|
+
|
|
176
|
+
Diff rules are tested as a table of (old manifest, new manifest, expected exit code). Add a row
|
|
177
|
+
before changing the rule.
|
|
178
|
+
|
|
179
|
+
Injection heuristics are tested against a corpus in `tests/fixtures/injection/` holding both
|
|
180
|
+
malicious samples and benign descriptions that superficially resemble them. False-positive rate
|
|
181
|
+
on the benign set is a tracked metric, not an afterthought — a scanner that cries wolf gets
|
|
182
|
+
turned off.
|
|
183
|
+
|
|
184
|
+
Coverage floor is 85% on `src/`, enforced in CI. Do not lower it to make a PR pass.
|
|
185
|
+
|
|
186
|
+
## Code Style
|
|
187
|
+
|
|
188
|
+
Ruff for lint and format, default line length 100. Full type annotations on public functions;
|
|
189
|
+
`mypy src/` runs clean in CI. Pydantic models define every serialized structure — no ad-hoc dicts
|
|
190
|
+
crossing module boundaries.
|
|
191
|
+
|
|
192
|
+
Errors are typed exceptions from `mcp_placard.errors`, surfaced by `cli.py` as exit codes. Library
|
|
193
|
+
code does not print and does not call `sys.exit`.
|
|
194
|
+
|
|
195
|
+
## Security Posture
|
|
196
|
+
|
|
197
|
+
- No `tools/call`. No resource writes. No credential storage.
|
|
198
|
+
- Scanned content — tool descriptions, resource text, server names — is untrusted data and is
|
|
199
|
+
never interpolated into a shell command, a file path, or an LLM prompt inside this tool.
|
|
200
|
+
- Report renderers escape scanned content. An injection string must not become live markup in the
|
|
201
|
+
Markdown or SARIF output.
|
|
202
|
+
- Network egress during a scan goes only to the target server. No telemetry.
|
|
203
|
+
|
|
204
|
+
## Git and PR Conventions
|
|
205
|
+
|
|
206
|
+
Branches: `feature/<short-description>`, `fix/<short-description>`.
|
|
207
|
+
|
|
208
|
+
Conventional commits. One logical change per PR. PR description states what changed, which
|
|
209
|
+
fixtures were added, and any exit-code semantics affected.
|
|
210
|
+
|
|
211
|
+
CI must pass lint, types, tests, and coverage before merge. CI also runs Placard against its
|
|
212
|
+
own mock server and diffs the result against a checked-in manifest — the tool gates itself.
|
|
213
|
+
|
|
214
|
+
## Roadmap
|
|
215
|
+
|
|
216
|
+
- **Phase 1** — stdio and HTTP transport, enumeration, canonical manifest with three hash levels,
|
|
217
|
+
`scan` and `diff` with exit codes. No classification yet; every tool lands at `unclassified`.
|
|
218
|
+
- **Phase 2** — done. Schema walker (Rule G), signal extractors, the Rule F combiner, Rules A-E
|
|
219
|
+
with fixtures, reversibility (Rule D), declared-vs-inferred reconciliation, the override
|
|
220
|
+
allowlist, `CHAIN_EXFIL`, live diff narrowing, and the `manifest_version` bump to `"2.0"` with
|
|
221
|
+
`"1.0"` backward compatibility. `classification` / `classification_hash` sit outside
|
|
222
|
+
`surface_hash`, split out for the same reason `capabilities` was in Pre-work 1: a classifier
|
|
223
|
+
rule fix must never move `surface_hash` for a server that did not change.
|
|
224
|
+
- **Phase 3** — injection surface heuristics with a tracked false-positive rate.
|
|
225
|
+
- **Phase 4** — GitHub Action wrapper, SARIF output, ceiling configuration.
|
|
226
|
+
- **Phase 5** — manifest signing, and a published index of blast radii for widely used public MCP
|
|
227
|
+
servers.
|
|
228
|
+
|
|
229
|
+
Phases ship as separate branches and separate releases. Do not begin a phase before the prior
|
|
230
|
+
phase's fixtures are green.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lanier Developments
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-placard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static analysis and drift detection for the surface an MCP server exposes to an agent.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Lanier-Developments/mcp-placard
|
|
6
|
+
Project-URL: Source, https://github.com/Lanier-Developments/mcp-placard
|
|
7
|
+
Author: Lanier Developments
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Lanier Developments
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: agents,mcp,security,static-analysis,supply-chain
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Topic :: Security
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Requires-Dist: anyio>=4.4
|
|
40
|
+
Requires-Dist: mcp>=2.2.0
|
|
41
|
+
Requires-Dist: pydantic>=2.7
|
|
42
|
+
Requires-Dist: typer>=0.12
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: coverage>=7.5; extra == 'dev'
|
|
45
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
46
|
+
Requires-Dist: pre-commit>=3.7; extra == 'dev'
|
|
47
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
49
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
50
|
+
Description-Content-Type: text/markdown
|
|
51
|
+
|
|
52
|
+
# Placard
|
|
53
|
+
|
|
54
|
+
Static analysis and drift detection for the surface an MCP server exposes to an agent.
|
|
55
|
+
|
|
56
|
+
Teams wire agents to Model Context Protocol servers with no record of what capability they
|
|
57
|
+
just granted, and no mechanism to notice when that capability changes. A server can add a
|
|
58
|
+
destructive tool, or silently rewrite a tool description — which is a prompt injected directly
|
|
59
|
+
into the agent's context — and nothing in the ecosystem today flags either event.
|
|
60
|
+
|
|
61
|
+
Placard connects to an MCP server, enumerates its surface, emits a canonical hashed
|
|
62
|
+
manifest, and fails CI when the next scan disagrees with the last one.
|
|
63
|
+
|
|
64
|
+
**Placard never invokes a tool.** Enumeration and static analysis only. No code path may
|
|
65
|
+
call `tools/call`; `scripts/check_no_tool_invocation.py` enforces that mechanically.
|
|
66
|
+
|
|
67
|
+
## Status: Phase 1 and Phase 2 complete
|
|
68
|
+
|
|
69
|
+
`scan` classifies every tool on the R0-R5 ladder — schema shape, tool name, and description
|
|
70
|
+
signals combine as a monotonic maximum (never a weighted score), reconciled against what the
|
|
71
|
+
server declared about itself. The full ladder, its worked examples, and the rules the
|
|
72
|
+
classifier implements are in [`docs/TAXONOMY.md`](docs/TAXONOMY.md). `diff` grades tier
|
|
73
|
+
increases and gates new-tool escalation on a configurable ceiling; a per-tool `classification`
|
|
74
|
+
entry never enters `surface_hash` — a classifier fix must never move that hash for a server
|
|
75
|
+
that did not change. Injection heuristics (Phase 3), SARIF/GitHub Action packaging (Phase 4),
|
|
76
|
+
and manifest signing (Phase 5) are not yet built.
|
|
77
|
+
|
|
78
|
+
## Install
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
python -m venv .venv && source .venv/bin/activate
|
|
82
|
+
pip install -e ".[dev]"
|
|
83
|
+
pre-commit install
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Requires Python 3.11+. The test suite needs no network access.
|
|
87
|
+
|
|
88
|
+
## Use
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Enumerate a server, classify every tool, and print the manifest
|
|
92
|
+
placard scan "python -m tests.mock_server"
|
|
93
|
+
placard scan https://mcp.example.com/mcp --out manifest.json
|
|
94
|
+
|
|
95
|
+
# Downgrade a tier only through an explicit, attributable allowlist entry
|
|
96
|
+
placard scan "python -m tests.mock_server" --override overrides.json
|
|
97
|
+
|
|
98
|
+
# Compare two manifests; the exit code is the finding
|
|
99
|
+
placard diff baseline.json current.json
|
|
100
|
+
placard diff baseline.json current.json --ceiling R5 # only R5 additions escalate
|
|
101
|
+
|
|
102
|
+
# Confirm a manifest's hashes — including its classification — still describe its own content
|
|
103
|
+
placard verify manifest.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`<target>` is a stdio command line or an HTTP(S) URL. The transport is inferred from the target;
|
|
107
|
+
`--transport stdio|http` overrides. `--override` points at a JSON array of
|
|
108
|
+
`{"entry_id", "tool", "tier", "reason"}` objects — the only way a tier is ever downgraded.
|
|
109
|
+
`--ceiling` (default `R4`) sets the tier a new tool must reach before `diff` escalates on it;
|
|
110
|
+
`--escalate-schema-changes` reverts to escalating on every input-schema change, even one that
|
|
111
|
+
leaves the tier unchanged.
|
|
112
|
+
|
|
113
|
+
### Exit codes
|
|
114
|
+
|
|
115
|
+
A per-command contract, pinned in [`AGENTS.md`](AGENTS.md) and in
|
|
116
|
+
`tests/test_exit_code_contract.py`. Codes are **categories, not a severity ladder** — `2` is a
|
|
117
|
+
different review path from `1`, not a worse outcome than it, and the same number can mean
|
|
118
|
+
different things for different commands.
|
|
119
|
+
|
|
120
|
+
| Code | `scan` | `diff` | `verify` |
|
|
121
|
+
| --- | --- | --- | --- |
|
|
122
|
+
| 0 | enumerated successfully | no change / below ceiling | every hash matches |
|
|
123
|
+
| 1 | — | escalation — new tool at/above ceiling, tier increase, capabilities change | a hash does not match its content |
|
|
124
|
+
| 2 | — | description change on an existing tool (always reviewable, never silenceable) | — |
|
|
125
|
+
| 3 | server unreachable | tool removed | — |
|
|
126
|
+
| 10 | usage/configuration error | usage/configuration error | usage/configuration error |
|
|
127
|
+
|
|
128
|
+
`diff`'s codes precedence-order as `3 > 1 > 2 > 0` when several apply at once; every finding is
|
|
129
|
+
still listed on stderr regardless of which code wins. Codes `20`-`29` are reserved for `report`
|
|
130
|
+
(Phase 4, not yet implemented).
|
|
131
|
+
|
|
132
|
+
## The manifest
|
|
133
|
+
|
|
134
|
+
Canonical JSON: sorted keys, stable array ordering, nothing environment-dependent in a hashed
|
|
135
|
+
body. No timestamp, no scan target — two scans of an unchanged server are byte-identical. The
|
|
136
|
+
negotiated protocol version and SDK version *are* recorded, but only inside `environment`, which
|
|
137
|
+
no hash ever covers.
|
|
138
|
+
|
|
139
|
+
Independent SHA-256 hashes, all required, never collapsed:
|
|
140
|
+
|
|
141
|
+
- `surface_hash` — tools, resources, prompts, instructions
|
|
142
|
+
- per-tool `schema_hash` — the input schema only
|
|
143
|
+
- per-tool `description_hash` — the description text only
|
|
144
|
+
- `capabilities_hash` — the server's declared MCP capabilities block only
|
|
145
|
+
- `classification_hash` — Placard's own tier judgment (`classification`) only
|
|
146
|
+
|
|
147
|
+
Splitting schema from description is what makes *"the server rewrote its prompt but kept the API
|
|
148
|
+
identical"* a visible event rather than a silent one. `capabilities` and `classification` are
|
|
149
|
+
both split out of `surface_hash` for the same reason in the other direction: some capability
|
|
150
|
+
flags are SDK-derived and can drift on a client SDK upgrade, and a classifier rule fix can
|
|
151
|
+
change a tier, neither with any server-side change at all — each gets its own finding
|
|
152
|
+
(`server_capabilities_changed`, `tier_escalated`) instead of moving `surface_hash`.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pytest # full suite
|
|
158
|
+
pytest -m "not slow" # skip subprocess integration tests
|
|
159
|
+
ruff check . && ruff format --check .
|
|
160
|
+
mypy src/
|
|
161
|
+
python scripts/check_no_tool_invocation.py
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Coverage floor is 85% on `src/`, enforced in CI. CI also scans the bundled mock server and
|
|
165
|
+
diffs the result against `tests/fixtures/mock_server_manifest.json` — the tool gates itself.
|
|
166
|
+
|
|
167
|
+
Regenerate that fixture deliberately, never to make a red build green:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
placard scan "python -m tests.mock_server" --out tests/fixtures/mock_server_manifest.json
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Documentation
|
|
174
|
+
|
|
175
|
+
- [`AGENTS.md`](AGENTS.md) — the operating spec
|
|
176
|
+
- [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) — what Placard does and does not defend against
|
|
177
|
+
- [`docs/TAXONOMY.md`](docs/TAXONOMY.md) — the R0-R5 risk ladder with worked examples
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT — see [`LICENSE`](LICENSE). © Lanier Developments.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Placard
|
|
2
|
+
|
|
3
|
+
Static analysis and drift detection for the surface an MCP server exposes to an agent.
|
|
4
|
+
|
|
5
|
+
Teams wire agents to Model Context Protocol servers with no record of what capability they
|
|
6
|
+
just granted, and no mechanism to notice when that capability changes. A server can add a
|
|
7
|
+
destructive tool, or silently rewrite a tool description — which is a prompt injected directly
|
|
8
|
+
into the agent's context — and nothing in the ecosystem today flags either event.
|
|
9
|
+
|
|
10
|
+
Placard connects to an MCP server, enumerates its surface, emits a canonical hashed
|
|
11
|
+
manifest, and fails CI when the next scan disagrees with the last one.
|
|
12
|
+
|
|
13
|
+
**Placard never invokes a tool.** Enumeration and static analysis only. No code path may
|
|
14
|
+
call `tools/call`; `scripts/check_no_tool_invocation.py` enforces that mechanically.
|
|
15
|
+
|
|
16
|
+
## Status: Phase 1 and Phase 2 complete
|
|
17
|
+
|
|
18
|
+
`scan` classifies every tool on the R0-R5 ladder — schema shape, tool name, and description
|
|
19
|
+
signals combine as a monotonic maximum (never a weighted score), reconciled against what the
|
|
20
|
+
server declared about itself. The full ladder, its worked examples, and the rules the
|
|
21
|
+
classifier implements are in [`docs/TAXONOMY.md`](docs/TAXONOMY.md). `diff` grades tier
|
|
22
|
+
increases and gates new-tool escalation on a configurable ceiling; a per-tool `classification`
|
|
23
|
+
entry never enters `surface_hash` — a classifier fix must never move that hash for a server
|
|
24
|
+
that did not change. Injection heuristics (Phase 3), SARIF/GitHub Action packaging (Phase 4),
|
|
25
|
+
and manifest signing (Phase 5) are not yet built.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m venv .venv && source .venv/bin/activate
|
|
31
|
+
pip install -e ".[dev]"
|
|
32
|
+
pre-commit install
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Python 3.11+. The test suite needs no network access.
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Enumerate a server, classify every tool, and print the manifest
|
|
41
|
+
placard scan "python -m tests.mock_server"
|
|
42
|
+
placard scan https://mcp.example.com/mcp --out manifest.json
|
|
43
|
+
|
|
44
|
+
# Downgrade a tier only through an explicit, attributable allowlist entry
|
|
45
|
+
placard scan "python -m tests.mock_server" --override overrides.json
|
|
46
|
+
|
|
47
|
+
# Compare two manifests; the exit code is the finding
|
|
48
|
+
placard diff baseline.json current.json
|
|
49
|
+
placard diff baseline.json current.json --ceiling R5 # only R5 additions escalate
|
|
50
|
+
|
|
51
|
+
# Confirm a manifest's hashes — including its classification — still describe its own content
|
|
52
|
+
placard verify manifest.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`<target>` is a stdio command line or an HTTP(S) URL. The transport is inferred from the target;
|
|
56
|
+
`--transport stdio|http` overrides. `--override` points at a JSON array of
|
|
57
|
+
`{"entry_id", "tool", "tier", "reason"}` objects — the only way a tier is ever downgraded.
|
|
58
|
+
`--ceiling` (default `R4`) sets the tier a new tool must reach before `diff` escalates on it;
|
|
59
|
+
`--escalate-schema-changes` reverts to escalating on every input-schema change, even one that
|
|
60
|
+
leaves the tier unchanged.
|
|
61
|
+
|
|
62
|
+
### Exit codes
|
|
63
|
+
|
|
64
|
+
A per-command contract, pinned in [`AGENTS.md`](AGENTS.md) and in
|
|
65
|
+
`tests/test_exit_code_contract.py`. Codes are **categories, not a severity ladder** — `2` is a
|
|
66
|
+
different review path from `1`, not a worse outcome than it, and the same number can mean
|
|
67
|
+
different things for different commands.
|
|
68
|
+
|
|
69
|
+
| Code | `scan` | `diff` | `verify` |
|
|
70
|
+
| --- | --- | --- | --- |
|
|
71
|
+
| 0 | enumerated successfully | no change / below ceiling | every hash matches |
|
|
72
|
+
| 1 | — | escalation — new tool at/above ceiling, tier increase, capabilities change | a hash does not match its content |
|
|
73
|
+
| 2 | — | description change on an existing tool (always reviewable, never silenceable) | — |
|
|
74
|
+
| 3 | server unreachable | tool removed | — |
|
|
75
|
+
| 10 | usage/configuration error | usage/configuration error | usage/configuration error |
|
|
76
|
+
|
|
77
|
+
`diff`'s codes precedence-order as `3 > 1 > 2 > 0` when several apply at once; every finding is
|
|
78
|
+
still listed on stderr regardless of which code wins. Codes `20`-`29` are reserved for `report`
|
|
79
|
+
(Phase 4, not yet implemented).
|
|
80
|
+
|
|
81
|
+
## The manifest
|
|
82
|
+
|
|
83
|
+
Canonical JSON: sorted keys, stable array ordering, nothing environment-dependent in a hashed
|
|
84
|
+
body. No timestamp, no scan target — two scans of an unchanged server are byte-identical. The
|
|
85
|
+
negotiated protocol version and SDK version *are* recorded, but only inside `environment`, which
|
|
86
|
+
no hash ever covers.
|
|
87
|
+
|
|
88
|
+
Independent SHA-256 hashes, all required, never collapsed:
|
|
89
|
+
|
|
90
|
+
- `surface_hash` — tools, resources, prompts, instructions
|
|
91
|
+
- per-tool `schema_hash` — the input schema only
|
|
92
|
+
- per-tool `description_hash` — the description text only
|
|
93
|
+
- `capabilities_hash` — the server's declared MCP capabilities block only
|
|
94
|
+
- `classification_hash` — Placard's own tier judgment (`classification`) only
|
|
95
|
+
|
|
96
|
+
Splitting schema from description is what makes *"the server rewrote its prompt but kept the API
|
|
97
|
+
identical"* a visible event rather than a silent one. `capabilities` and `classification` are
|
|
98
|
+
both split out of `surface_hash` for the same reason in the other direction: some capability
|
|
99
|
+
flags are SDK-derived and can drift on a client SDK upgrade, and a classifier rule fix can
|
|
100
|
+
change a tier, neither with any server-side change at all — each gets its own finding
|
|
101
|
+
(`server_capabilities_changed`, `tier_escalated`) instead of moving `surface_hash`.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pytest # full suite
|
|
107
|
+
pytest -m "not slow" # skip subprocess integration tests
|
|
108
|
+
ruff check . && ruff format --check .
|
|
109
|
+
mypy src/
|
|
110
|
+
python scripts/check_no_tool_invocation.py
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Coverage floor is 85% on `src/`, enforced in CI. CI also scans the bundled mock server and
|
|
114
|
+
diffs the result against `tests/fixtures/mock_server_manifest.json` — the tool gates itself.
|
|
115
|
+
|
|
116
|
+
Regenerate that fixture deliberately, never to make a red build green:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
placard scan "python -m tests.mock_server" --out tests/fixtures/mock_server_manifest.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Documentation
|
|
123
|
+
|
|
124
|
+
- [`AGENTS.md`](AGENTS.md) — the operating spec
|
|
125
|
+
- [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) — what Placard does and does not defend against
|
|
126
|
+
- [`docs/TAXONOMY.md`](docs/TAXONOMY.md) — the R0-R5 risk ladder with worked examples
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT — see [`LICENSE`](LICENSE). © Lanier Developments.
|