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.
Files changed (87) hide show
  1. mcp_placard-0.1.0/.gitignore +13 -0
  2. mcp_placard-0.1.0/AGENTS.md +230 -0
  3. mcp_placard-0.1.0/LICENSE +21 -0
  4. mcp_placard-0.1.0/PKG-INFO +181 -0
  5. mcp_placard-0.1.0/README.md +130 -0
  6. mcp_placard-0.1.0/docs/TAXONOMY-amendment-1.md +176 -0
  7. mcp_placard-0.1.0/docs/TAXONOMY.md +669 -0
  8. mcp_placard-0.1.0/docs/THREAT_MODEL.md +269 -0
  9. mcp_placard-0.1.0/pyproject.toml +117 -0
  10. mcp_placard-0.1.0/scripts/check_no_tool_invocation.py +77 -0
  11. mcp_placard-0.1.0/src/mcp_placard/__init__.py +22 -0
  12. mcp_placard-0.1.0/src/mcp_placard/classify/README.md +87 -0
  13. mcp_placard-0.1.0/src/mcp_placard/classify/__init__.py +91 -0
  14. mcp_placard-0.1.0/src/mcp_placard/classify/candidates.py +27 -0
  15. mcp_placard-0.1.0/src/mcp_placard/classify/chain.py +49 -0
  16. mcp_placard-0.1.0/src/mcp_placard/classify/combine.py +41 -0
  17. mcp_placard-0.1.0/src/mcp_placard/classify/overrides.py +98 -0
  18. mcp_placard-0.1.0/src/mcp_placard/classify/patterns.py +61 -0
  19. mcp_placard-0.1.0/src/mcp_placard/classify/reversibility.py +40 -0
  20. mcp_placard-0.1.0/src/mcp_placard/classify/schema_walk.py +181 -0
  21. mcp_placard-0.1.0/src/mcp_placard/classify/signals/__init__.py +8 -0
  22. mcp_placard-0.1.0/src/mcp_placard/classify/signals/annotations.py +77 -0
  23. mcp_placard-0.1.0/src/mcp_placard/classify/signals/description.py +85 -0
  24. mcp_placard-0.1.0/src/mcp_placard/classify/signals/schema_shape.py +257 -0
  25. mcp_placard-0.1.0/src/mcp_placard/classify/signals/verb.py +92 -0
  26. mcp_placard-0.1.0/src/mcp_placard/cli.py +266 -0
  27. mcp_placard-0.1.0/src/mcp_placard/diff/README.md +88 -0
  28. mcp_placard-0.1.0/src/mcp_placard/diff/__init__.py +32 -0
  29. mcp_placard-0.1.0/src/mcp_placard/diff/engine.py +229 -0
  30. mcp_placard-0.1.0/src/mcp_placard/diff/models.py +135 -0
  31. mcp_placard-0.1.0/src/mcp_placard/errors.py +99 -0
  32. mcp_placard-0.1.0/src/mcp_placard/inject/README.md +37 -0
  33. mcp_placard-0.1.0/src/mcp_placard/inject/__init__.py +17 -0
  34. mcp_placard-0.1.0/src/mcp_placard/manifest/README.md +135 -0
  35. mcp_placard-0.1.0/src/mcp_placard/manifest/__init__.py +108 -0
  36. mcp_placard-0.1.0/src/mcp_placard/manifest/build.py +159 -0
  37. mcp_placard-0.1.0/src/mcp_placard/manifest/canonical.py +75 -0
  38. mcp_placard-0.1.0/src/mcp_placard/manifest/hashing.py +84 -0
  39. mcp_placard-0.1.0/src/mcp_placard/manifest/io.py +107 -0
  40. mcp_placard-0.1.0/src/mcp_placard/manifest/models.py +352 -0
  41. mcp_placard-0.1.0/src/mcp_placard/manifest/raw.py +45 -0
  42. mcp_placard-0.1.0/src/mcp_placard/manifest/verify.py +82 -0
  43. mcp_placard-0.1.0/src/mcp_placard/report/README.md +43 -0
  44. mcp_placard-0.1.0/src/mcp_placard/report/__init__.py +17 -0
  45. mcp_placard-0.1.0/src/mcp_placard/transport/README.md +53 -0
  46. mcp_placard-0.1.0/src/mcp_placard/transport/__init__.py +74 -0
  47. mcp_placard-0.1.0/src/mcp_placard/transport/base.py +207 -0
  48. mcp_placard-0.1.0/src/mcp_placard/transport/http.py +49 -0
  49. mcp_placard-0.1.0/src/mcp_placard/transport/stdio.py +49 -0
  50. mcp_placard-0.1.0/src/mcp_placard/transport/target.py +79 -0
  51. mcp_placard-0.1.0/tests/__init__.py +5 -0
  52. mcp_placard-0.1.0/tests/conftest.py +145 -0
  53. mcp_placard-0.1.0/tests/fixtures/legacy_manifest_1_0.json +56 -0
  54. mcp_placard-0.1.0/tests/fixtures/mock_server_manifest.json +477 -0
  55. mcp_placard-0.1.0/tests/fixtures/schema_traversal/anyof_dangerous_branch.json +23 -0
  56. mcp_placard-0.1.0/tests/fixtures/schema_traversal/depth_exceeded.json +109 -0
  57. mcp_placard-0.1.0/tests/fixtures/schema_traversal/nested_allof_to.json +26 -0
  58. mcp_placard-0.1.0/tests/fixtures/schema_traversal/nested_if_match.json +20 -0
  59. mcp_placard-0.1.0/tests/fixtures/schema_traversal/ref_cycle.json +20 -0
  60. mcp_placard-0.1.0/tests/fixtures/schema_traversal/unresolvable_ref.json +12 -0
  61. mcp_placard-0.1.0/tests/mock_server/__init__.py +25 -0
  62. mcp_placard-0.1.0/tests/mock_server/__main__.py +7 -0
  63. mcp_placard-0.1.0/tests/mock_server/server.py +94 -0
  64. mcp_placard-0.1.0/tests/mock_server/surface.py +381 -0
  65. mcp_placard-0.1.0/tests/test_canonical.py +70 -0
  66. mcp_placard-0.1.0/tests/test_classify_chain.py +76 -0
  67. mcp_placard-0.1.0/tests/test_classify_fixture_matrix.py +367 -0
  68. mcp_placard-0.1.0/tests/test_classify_overrides.py +141 -0
  69. mcp_placard-0.1.0/tests/test_classify_reversibility.py +54 -0
  70. mcp_placard-0.1.0/tests/test_classify_schema_traversal.py +119 -0
  71. mcp_placard-0.1.0/tests/test_classify_signals.py +115 -0
  72. mcp_placard-0.1.0/tests/test_cli.py +206 -0
  73. mcp_placard-0.1.0/tests/test_diff_table.py +225 -0
  74. mcp_placard-0.1.0/tests/test_diff_tier_stub.py +111 -0
  75. mcp_placard-0.1.0/tests/test_enumeration.py +160 -0
  76. mcp_placard-0.1.0/tests/test_exit_code_contract.py +70 -0
  77. mcp_placard-0.1.0/tests/test_hashing.py +96 -0
  78. mcp_placard-0.1.0/tests/test_integration.py +294 -0
  79. mcp_placard-0.1.0/tests/test_manifest.py +263 -0
  80. mcp_placard-0.1.0/tests/test_manifest_version_upgrade.py +127 -0
  81. mcp_placard-0.1.0/tests/test_mock_server.py +117 -0
  82. mcp_placard-0.1.0/tests/test_no_tool_invocation.py +87 -0
  83. mcp_placard-0.1.0/tests/test_patterns.py +62 -0
  84. mcp_placard-0.1.0/tests/test_schema_walk.py +278 -0
  85. mcp_placard-0.1.0/tests/test_self_gating.py +61 -0
  86. mcp_placard-0.1.0/tests/test_transport.py +149 -0
  87. mcp_placard-0.1.0/tests/test_verify.py +99 -0
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ .DS_Store
@@ -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.