mcp-tool-pin-check 0.2.2__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_tool_pin_check-0.2.2/LICENSE +21 -0
- mcp_tool_pin_check-0.2.2/MANIFEST.in +2 -0
- mcp_tool_pin_check-0.2.2/PKG-INFO +296 -0
- mcp_tool_pin_check-0.2.2/README.md +263 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.changed.json +77 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.identity-name.json +67 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.identity-protocol.json +67 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.identity-version.json +67 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.identity.json +67 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.json +47 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.output-enum-widened.json +81 -0
- mcp_tool_pin_check-0.2.2/examples/tools-list.output.json +81 -0
- mcp_tool_pin_check-0.2.2/mcp_tool_pin_check.egg-info/PKG-INFO +296 -0
- mcp_tool_pin_check-0.2.2/mcp_tool_pin_check.egg-info/SOURCES.txt +19 -0
- mcp_tool_pin_check-0.2.2/mcp_tool_pin_check.egg-info/dependency_links.txt +1 -0
- mcp_tool_pin_check-0.2.2/mcp_tool_pin_check.egg-info/entry_points.txt +2 -0
- mcp_tool_pin_check-0.2.2/mcp_tool_pin_check.egg-info/top_level.txt +1 -0
- mcp_tool_pin_check-0.2.2/mcp_tool_pin_check.py +775 -0
- mcp_tool_pin_check-0.2.2/pyproject.toml +49 -0
- mcp_tool_pin_check-0.2.2/setup.cfg +4 -0
- mcp_tool_pin_check-0.2.2/tests/test_pin_check.py +778 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Digital Fortress
|
|
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,296 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-tool-pin-check
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Read-only checker that pins MCP tool definitions and flags drift, tool poisoning and rug pulls between tools/list exports.
|
|
5
|
+
Author: Digital Fortress
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/digitalfortressaudit/mcp-tool-pin-check
|
|
8
|
+
Project-URL: Source, https://github.com/digitalfortressaudit/mcp-tool-pin-check
|
|
9
|
+
Project-URL: Issues, https://github.com/digitalfortressaudit/mcp-tool-pin-check/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/digitalfortressaudit/mcp-tool-pin-check/releases
|
|
11
|
+
Project-URL: Full pack, https://fortressaudit.gumroad.com/l/wphmlq?utm_source=pypi&utm_medium=package&utm_campaign=mcp-tool-pin-check
|
|
12
|
+
Keywords: mcp,model-context-protocol,mcp-security,tool-poisoning,rug-pull,ai-agent-security,llm-security,supply-chain-security,lockfile,devsecops
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Information Technology
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
28
|
+
Classifier: Topic :: Software Development :: Testing
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# mcp-tool-pin-check: MCP Tool Definition Pinning Checker
|
|
35
|
+
|
|
36
|
+
Pin an MCP server's tool definitions, then diff a later listing for silent changes. One read-only Python script. No third-party packages.
|
|
37
|
+
|
|
38
|
+
Use it on servers you own or are explicitly allowed to assess.
|
|
39
|
+
|
|
40
|
+
## Why it matters
|
|
41
|
+
|
|
42
|
+
[OWASP's description of MCP tool poisoning](https://owasp.org/www-community/attacks/MCP_Tool_Poisoning) calls out a trust gap: tool descriptions are reviewed once, when an agent first connects, and later server behavior is not checked the same way. A listing can still change after that review. This checker stores a normalized copy and a SHA-256 hash of each tool so you can diff a later `tools/list` export before you keep treating the server as unchanged.
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
Requires Python 3.9 or newer. The standard library is enough.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install mcp-tool-pin-check
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This program does not connect to a server. From an MCP client you already use against a server you are authorized to assess, save the `tools/list` response to a file. The file can be any of these:
|
|
53
|
+
|
|
54
|
+
- a JSON-RPC response with `result.tools`
|
|
55
|
+
- an object with a `tools` array
|
|
56
|
+
- a bare list of tools
|
|
57
|
+
|
|
58
|
+
Pin that file, then check a later export against the lockfile:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python3 mcp_tool_pin_check.py pin \
|
|
62
|
+
--listing tools-list.json \
|
|
63
|
+
--lock mcp-tools.lock.json \
|
|
64
|
+
--source my-notes-server
|
|
65
|
+
|
|
66
|
+
python3 mcp_tool_pin_check.py check \
|
|
67
|
+
--listing tools-list.json \
|
|
68
|
+
--lock mcp-tools.lock.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
After install, the same subcommands are `pin` and `check`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
mcp-tool-pin-check pin \
|
|
75
|
+
--listing tools-list.json \
|
|
76
|
+
--lock mcp-tools.lock.json \
|
|
77
|
+
--source my-notes-server
|
|
78
|
+
|
|
79
|
+
mcp-tool-pin-check check \
|
|
80
|
+
--listing tools-list.json \
|
|
81
|
+
--lock mcp-tools.lock.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`--source` is a label stored in the lockfile. If you omit it, the listing path is stored. `--pinned-at` sets the timestamp; if you omit it, the checker uses the current UTC time. `--json` prints the same result as JSON.
|
|
85
|
+
|
|
86
|
+
The examples are a small notes server. `examples/tools-list.changed.json` adds `create_note`, rewords `get_note`, and changes `list_notes` by adding an optional `limit` property and dropping `query` from `required`. `examples/tools-list.output.json` adds an `outputSchema` to each of those tools, and `examples/tools-list.output-enum-widened.json` adds `deleted` to the `get_note` output `status` enum. `examples/tools-list.identity.json` is the same tools plus an MCP initialize result. `examples/tools-list.identity-version.json`, `examples/tools-list.identity-protocol.json`, and `examples/tools-list.identity-name.json` change only `serverInfo.version`, `protocolVersion`, or `serverInfo.name`. The sample below sets `--pinned-at` so the timestamp is reproducible:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python3 mcp_tool_pin_check.py pin \
|
|
90
|
+
--listing examples/tools-list.json \
|
|
91
|
+
--lock /tmp/notes.lock.json \
|
|
92
|
+
--source local-notes \
|
|
93
|
+
--pinned-at 2026-10-08T00:00:00Z
|
|
94
|
+
|
|
95
|
+
python3 mcp_tool_pin_check.py check \
|
|
96
|
+
--listing examples/tools-list.changed.json \
|
|
97
|
+
--lock /tmp/notes.lock.json
|
|
98
|
+
|
|
99
|
+
mcp-tool-pin-check pin \
|
|
100
|
+
--listing examples/tools-list.json \
|
|
101
|
+
--lock /tmp/notes.lock.json \
|
|
102
|
+
--source local-notes \
|
|
103
|
+
--pinned-at 2026-10-08T00:00:00Z
|
|
104
|
+
|
|
105
|
+
mcp-tool-pin-check check \
|
|
106
|
+
--listing examples/tools-list.changed.json \
|
|
107
|
+
--lock /tmp/notes.lock.json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`pin` prints:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
Pinned 2 tools to /tmp/notes.lock.json
|
|
114
|
+
source: local-notes
|
|
115
|
+
pinned_at: 2026-10-08T00:00:00Z
|
|
116
|
+
get_note 1550f3326897f328ef901e418c632b3aad519ab9126415e51d63a82cb930e6b6
|
|
117
|
+
list_notes 10fb955592a3ea05a96f15c689e5a8f52450c83a9d72b57bd98a42a20d35b758
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`check` prints:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
MCP tool pin check
|
|
124
|
+
lock source: local-notes
|
|
125
|
+
pinned_at: 2026-10-08T00:00:00Z
|
|
126
|
+
result: drift
|
|
127
|
+
|
|
128
|
+
server identity:
|
|
129
|
+
note: identity was not pinned
|
|
130
|
+
|
|
131
|
+
added (1):
|
|
132
|
+
+ create_note
|
|
133
|
+
removed (0):
|
|
134
|
+
(none)
|
|
135
|
+
changed (2):
|
|
136
|
+
~ get_note
|
|
137
|
+
description:
|
|
138
|
+
--- description (pinned)
|
|
139
|
+
+++ description (current)
|
|
140
|
+
@@ -1 +1 @@
|
|
141
|
+
-Return one note by its identifier.
|
|
142
|
+
+Fetch a single saved note using its identifier.
|
|
143
|
+
|
|
144
|
+
~ list_notes
|
|
145
|
+
schema:
|
|
146
|
+
[widened] property added: properties.limit (optional)
|
|
147
|
+
[widened] required removed: query
|
|
148
|
+
schema widened: yes
|
|
149
|
+
unchanged (0): (none)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
That check exits `1`.
|
|
153
|
+
|
|
154
|
+
## Exit codes
|
|
155
|
+
|
|
156
|
+
| Code | Meaning |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| `0` | The normalized tool listing matches the lockfile, and the pinned server name matches when identity was pinned. A server version change, a `protocolVersion` change, or identity added or removed does not by itself change this code. |
|
|
159
|
+
| `1` | Drift: a tool was added, removed, or its definition changed, including an output-schema change, or the pinned server name changed. |
|
|
160
|
+
| `2` | The listing or lockfile could not be read (missing file, invalid JSON, duplicate names, a lockfile hash that does not match its stored definition, or server identity that is present but not usable). |
|
|
161
|
+
|
|
162
|
+
## GitHub Actions
|
|
163
|
+
|
|
164
|
+
Commit the lockfile. Produce a fresh `tools/list` file in an earlier step you control, then fail the job when the listing has drifted:
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
- name: Check pinned MCP tool definitions
|
|
168
|
+
run: python3 mcp_tool_pin_check.py check --listing tools-list.json --lock mcp-tools.lock.json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
This repository's own workflow runs the unit tests only. It does not contact an MCP server.
|
|
172
|
+
|
|
173
|
+
## What it does
|
|
174
|
+
|
|
175
|
+
Each tool is reduced to `name`, `description`, `inputSchema`, `outputSchema`, and `annotations` when annotations are present. Tools are sorted by name. `pin` writes a JSON lockfile (`lockfile_version` 1) with `pinned_at`, a source label, and one SHA-256 per normalized tool, plus the normalized definition. The hash is SHA-256 of the canonical JSON, with keys sorted, so `outputSchema` is covered by the same hash as `inputSchema`. A tool with no output schema stores `outputSchema: null`. `check` reports added tools, removed tools, a short unified diff when a description changed, input-schema changes, and output-schema changes on separate lines.
|
|
176
|
+
|
|
177
|
+
A schema line marked `[widened]` means the input schema accepts more than the pin:
|
|
178
|
+
|
|
179
|
+
- a new property
|
|
180
|
+
- a `required` name removed
|
|
181
|
+
- `enum` removed or given new values
|
|
182
|
+
- `maxLength` removed or increased
|
|
183
|
+
- `pattern` removed
|
|
184
|
+
- `additionalProperties` moving from `false` or a subschema to `true` or absent
|
|
185
|
+
|
|
186
|
+
Other input-schema edits are still drift. They are printed without `[widened]`. A tighter `maxLength`, for example, is a change and not a widening.
|
|
187
|
+
|
|
188
|
+
Output-schema findings are printed under `output schema:`, not in the input `schema:` block and not as `schema widened`. A new enum value anywhere in `outputSchema` is a line of its own:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
output widened: properties.status.enum (added "deleted"); a new output enum value can break downstream exhaustive switches
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
That note is there because a caller with an exhaustive switch on the pinned enum can break when the server adds a value. Other output edits are `output changed` lines. Those include added or removed properties, type changes, and removed enum values. Output drift exits `1`.
|
|
195
|
+
|
|
196
|
+
A v0.1 lockfile has no `outputSchema` field. `check` still exits `0` when the current listing also has no output schema, so upgrading the checker does not fail that pin. Re-pinning the same listing writes a new hash, because the stored definition now includes `outputSchema: null`. If the listing has an `outputSchema` the old lock never pinned, `check` prints `output changed: outputSchema added (not present in the pin)` and exits `1`. That counts as drift: an output contract the pin never recorded is a definition change. Re-pin after you have reviewed the new schema.
|
|
197
|
+
|
|
198
|
+
## Server identity
|
|
199
|
+
|
|
200
|
+
`pin` can record which server produced the listing. Put the MCP `initialize` result in the same JSON file as the tools. The lock stores `serverInfo.name`, `serverInfo.version`, and `protocolVersion` when that field is present. Identity is not part of the tool hash. A listing with no identity writes the same tools-only lock as v0.2.0.
|
|
201
|
+
|
|
202
|
+
Either of these shapes works:
|
|
203
|
+
|
|
204
|
+
- `serverInfo` and optional `protocolVersion` on the same object as `tools`, or inside `result` next to `tools`
|
|
205
|
+
- an `initialize` object next to the tools. That object may be the initialize result itself, or a JSON-RPC response whose `result` is the initialize result
|
|
206
|
+
|
|
207
|
+
`examples/tools-list.identity.json` uses the `initialize` envelope. A later check against `examples/tools-list.identity-version.json` exits `0` and prints `server version changed`.
|
|
208
|
+
|
|
209
|
+
`check` prints a `server identity` section:
|
|
210
|
+
|
|
211
|
+
- `server version changed` is informational. A version bump on the same server name does not change the exit code.
|
|
212
|
+
- `protocol version changed` is informational, including when `protocolVersion` was added or dropped. It does not change the exit code.
|
|
213
|
+
- `server name changed` is drift and exits `1`, even when every tool still matches. A different `serverInfo.name` behind the same pin is a substitution signal: the listing may be from a different server than the one you reviewed.
|
|
214
|
+
- `identity added` and `identity removed` are reported on their own lines. They do not by themselves change the exit code. A tools-only export after an identity pin prints `identity removed` and still exits `0` when the tools and, if present, the server name are otherwise unchanged.
|
|
215
|
+
|
|
216
|
+
A v0.1 or v0.2.0 lock has no server identity. `check` still exits `0` when the tools match, and it prints `note: identity was not pinned`. If that later listing also includes an initialize result, the note is kept and `identity added` is printed. That added line is informational. Re-pin once you want the name and version stored.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
python3 mcp_tool_pin_check.py pin \
|
|
220
|
+
--listing examples/tools-list.identity.json \
|
|
221
|
+
--lock /tmp/notes.lock.json \
|
|
222
|
+
--source local-notes \
|
|
223
|
+
--pinned-at 2026-10-08T00:00:00Z
|
|
224
|
+
|
|
225
|
+
python3 mcp_tool_pin_check.py check \
|
|
226
|
+
--listing examples/tools-list.identity-version.json \
|
|
227
|
+
--lock /tmp/notes.lock.json
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## What it does not do
|
|
231
|
+
|
|
232
|
+
- It does not open a network connection, call `initialize` or `tools/list`, start a server, or modify an MCP server. You save those responses yourself.
|
|
233
|
+
- It does not execute text from descriptions or schemas. It only reads the files you name and writes the lockfile path you pass to `pin`.
|
|
234
|
+
- It does not decide that a description is safe or unsafe. It shows whether the stored definition changed.
|
|
235
|
+
- It does not pin tool fields other than name, description, input schema, output schema, and annotations. Server identity is only `serverInfo.name`, `serverInfo.version`, and `protocolVersion`.
|
|
236
|
+
- A clean check is not a full review of the server. It means the normalized tool definitions match and, when identity was pinned, the server name matches. A version or `protocolVersion` difference can still be printed as information.
|
|
237
|
+
|
|
238
|
+
## Authorized use
|
|
239
|
+
|
|
240
|
+
This repository is [MIT licensed](https://github.com/digitalfortressaudit/mcp-tool-pin-check/blob/main/LICENSE). It is for **defensive checks only**.
|
|
241
|
+
|
|
242
|
+
- Pin and check tool listings from MCP servers you own or are authorized to assess.
|
|
243
|
+
- Save those listings from clients you are allowed to use.
|
|
244
|
+
- Do not point it at servers you are not allowed to review.
|
|
245
|
+
|
|
246
|
+
## Tests
|
|
247
|
+
|
|
248
|
+
From this directory:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
python3 -m unittest tests.test_pin_check -v
|
|
252
|
+
python3 -m unittest discover -s tests -v
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Changelog
|
|
256
|
+
|
|
257
|
+
### v0.2.2
|
|
258
|
+
|
|
259
|
+
- Package the checker for installation with pip. `pin` and `check` behavior is unchanged from v0.2.1.
|
|
260
|
+
|
|
261
|
+
### v0.2.1
|
|
262
|
+
|
|
263
|
+
- `pin` records server identity from an MCP `initialize` result included in the listing JSON: `serverInfo.name`, `serverInfo.version`, and `protocolVersion` when it is present. Identity is stored on the lock and is not part of the tool hash. Listings without identity still produce a tools-only lock.
|
|
264
|
+
- `check` reports identity in its own section. `server version changed` and `protocol version changed` are informational and do not change the exit code. `server name changed` is drift and exits 1, because a different server name behind the same pin is a substitution signal. `identity added` and `identity removed` are reported and do not by themselves change the exit code.
|
|
265
|
+
- A v0.1 or v0.2.0 lock with no identity still checks cleanly (exit 0) when the tools match, and `check` prints `note: identity was not pinned`.
|
|
266
|
+
|
|
267
|
+
### v0.2.0
|
|
268
|
+
|
|
269
|
+
- `pin` stores and hashes each tool's `outputSchema` with the same canonical-JSON SHA-256 as the rest of the definition. A missing output schema is recorded as `null`.
|
|
270
|
+
- `check` reports output-schema changes on their own lines. A new enum value anywhere in `outputSchema` is flagged `output widened`, with a note that it can break downstream exhaustive switches. Other output edits are reported as `output changed`.
|
|
271
|
+
- v0.1 lockfiles, which have no `outputSchema` data, still check cleanly (exit 0) against a listing whose tools have no output schema. An `outputSchema` the old lock never pinned is an output-added finding and counts as drift (exit 1).
|
|
272
|
+
|
|
273
|
+
### v0.1.0
|
|
274
|
+
|
|
275
|
+
- Initial public release. Pin `name`, `description`, `inputSchema`, and `annotations`, and diff a later listing for added tools, removed tools, description edits, and input-schema changes.
|
|
276
|
+
|
|
277
|
+
## For more from Digital Fortress
|
|
278
|
+
|
|
279
|
+
For more from Digital Fortress:
|
|
280
|
+
|
|
281
|
+
- **[$79 MCP & Multi-Agent Authorization Security Pack](https://fortressaudit.gumroad.com/l/wphmlq)** — the pack for MCP tool trust.
|
|
282
|
+
- **[$99 AI Security & Infrastructure Prompt Pack](https://fortressaudit.gumroad.com/l/myyeen)**
|
|
283
|
+
- **[$149 Multi-Agent Orchestration Blueprint](https://fortressaudit.gumroad.com/l/cvxfw)**
|
|
284
|
+
- **[$99 Agent Runtime Containment Pack](https://fortressaudit.gumroad.com/l/ngouvf)**: agent egress, identity, Kubernetes sandboxing, and tool-call authorization.
|
|
285
|
+
|
|
286
|
+
A free sibling starter, with copy-paste audit prompts and one read-only repository scan, is the [AI Security Starter Checklist](https://github.com/digitalfortressaudit/ai-security-starter-checklist).
|
|
287
|
+
|
|
288
|
+
## Sources
|
|
289
|
+
|
|
290
|
+
- [OWASP: MCP Tool Poisoning](https://owasp.org/www-community/attacks/MCP_Tool_Poisoning)
|
|
291
|
+
- [Model Context Protocol Threat Modeling and Analyzing Vulnerabilities to Prompt Injection with Tool Poisoning](https://arxiv.org/html/2603.22489v1)
|
|
292
|
+
- [A Formal Security Framework for MCP-Based AI Agents](https://arxiv.org/abs/2604.05969v1)
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
[MIT](https://github.com/digitalfortressaudit/mcp-tool-pin-check/blob/main/LICENSE) © 2026 Digital Fortress · github.com/digitalfortressaudit. Authorized defensive use only, as described above.
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# mcp-tool-pin-check: MCP Tool Definition Pinning Checker
|
|
2
|
+
|
|
3
|
+
Pin an MCP server's tool definitions, then diff a later listing for silent changes. One read-only Python script. No third-party packages.
|
|
4
|
+
|
|
5
|
+
Use it on servers you own or are explicitly allowed to assess.
|
|
6
|
+
|
|
7
|
+
## Why it matters
|
|
8
|
+
|
|
9
|
+
[OWASP's description of MCP tool poisoning](https://owasp.org/www-community/attacks/MCP_Tool_Poisoning) calls out a trust gap: tool descriptions are reviewed once, when an agent first connects, and later server behavior is not checked the same way. A listing can still change after that review. This checker stores a normalized copy and a SHA-256 hash of each tool so you can diff a later `tools/list` export before you keep treating the server as unchanged.
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
Requires Python 3.9 or newer. The standard library is enough.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install mcp-tool-pin-check
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This program does not connect to a server. From an MCP client you already use against a server you are authorized to assess, save the `tools/list` response to a file. The file can be any of these:
|
|
20
|
+
|
|
21
|
+
- a JSON-RPC response with `result.tools`
|
|
22
|
+
- an object with a `tools` array
|
|
23
|
+
- a bare list of tools
|
|
24
|
+
|
|
25
|
+
Pin that file, then check a later export against the lockfile:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 mcp_tool_pin_check.py pin \
|
|
29
|
+
--listing tools-list.json \
|
|
30
|
+
--lock mcp-tools.lock.json \
|
|
31
|
+
--source my-notes-server
|
|
32
|
+
|
|
33
|
+
python3 mcp_tool_pin_check.py check \
|
|
34
|
+
--listing tools-list.json \
|
|
35
|
+
--lock mcp-tools.lock.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
After install, the same subcommands are `pin` and `check`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
mcp-tool-pin-check pin \
|
|
42
|
+
--listing tools-list.json \
|
|
43
|
+
--lock mcp-tools.lock.json \
|
|
44
|
+
--source my-notes-server
|
|
45
|
+
|
|
46
|
+
mcp-tool-pin-check check \
|
|
47
|
+
--listing tools-list.json \
|
|
48
|
+
--lock mcp-tools.lock.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`--source` is a label stored in the lockfile. If you omit it, the listing path is stored. `--pinned-at` sets the timestamp; if you omit it, the checker uses the current UTC time. `--json` prints the same result as JSON.
|
|
52
|
+
|
|
53
|
+
The examples are a small notes server. `examples/tools-list.changed.json` adds `create_note`, rewords `get_note`, and changes `list_notes` by adding an optional `limit` property and dropping `query` from `required`. `examples/tools-list.output.json` adds an `outputSchema` to each of those tools, and `examples/tools-list.output-enum-widened.json` adds `deleted` to the `get_note` output `status` enum. `examples/tools-list.identity.json` is the same tools plus an MCP initialize result. `examples/tools-list.identity-version.json`, `examples/tools-list.identity-protocol.json`, and `examples/tools-list.identity-name.json` change only `serverInfo.version`, `protocolVersion`, or `serverInfo.name`. The sample below sets `--pinned-at` so the timestamp is reproducible:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 mcp_tool_pin_check.py pin \
|
|
57
|
+
--listing examples/tools-list.json \
|
|
58
|
+
--lock /tmp/notes.lock.json \
|
|
59
|
+
--source local-notes \
|
|
60
|
+
--pinned-at 2026-10-08T00:00:00Z
|
|
61
|
+
|
|
62
|
+
python3 mcp_tool_pin_check.py check \
|
|
63
|
+
--listing examples/tools-list.changed.json \
|
|
64
|
+
--lock /tmp/notes.lock.json
|
|
65
|
+
|
|
66
|
+
mcp-tool-pin-check pin \
|
|
67
|
+
--listing examples/tools-list.json \
|
|
68
|
+
--lock /tmp/notes.lock.json \
|
|
69
|
+
--source local-notes \
|
|
70
|
+
--pinned-at 2026-10-08T00:00:00Z
|
|
71
|
+
|
|
72
|
+
mcp-tool-pin-check check \
|
|
73
|
+
--listing examples/tools-list.changed.json \
|
|
74
|
+
--lock /tmp/notes.lock.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`pin` prints:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
Pinned 2 tools to /tmp/notes.lock.json
|
|
81
|
+
source: local-notes
|
|
82
|
+
pinned_at: 2026-10-08T00:00:00Z
|
|
83
|
+
get_note 1550f3326897f328ef901e418c632b3aad519ab9126415e51d63a82cb930e6b6
|
|
84
|
+
list_notes 10fb955592a3ea05a96f15c689e5a8f52450c83a9d72b57bd98a42a20d35b758
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`check` prints:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
MCP tool pin check
|
|
91
|
+
lock source: local-notes
|
|
92
|
+
pinned_at: 2026-10-08T00:00:00Z
|
|
93
|
+
result: drift
|
|
94
|
+
|
|
95
|
+
server identity:
|
|
96
|
+
note: identity was not pinned
|
|
97
|
+
|
|
98
|
+
added (1):
|
|
99
|
+
+ create_note
|
|
100
|
+
removed (0):
|
|
101
|
+
(none)
|
|
102
|
+
changed (2):
|
|
103
|
+
~ get_note
|
|
104
|
+
description:
|
|
105
|
+
--- description (pinned)
|
|
106
|
+
+++ description (current)
|
|
107
|
+
@@ -1 +1 @@
|
|
108
|
+
-Return one note by its identifier.
|
|
109
|
+
+Fetch a single saved note using its identifier.
|
|
110
|
+
|
|
111
|
+
~ list_notes
|
|
112
|
+
schema:
|
|
113
|
+
[widened] property added: properties.limit (optional)
|
|
114
|
+
[widened] required removed: query
|
|
115
|
+
schema widened: yes
|
|
116
|
+
unchanged (0): (none)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
That check exits `1`.
|
|
120
|
+
|
|
121
|
+
## Exit codes
|
|
122
|
+
|
|
123
|
+
| Code | Meaning |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `0` | The normalized tool listing matches the lockfile, and the pinned server name matches when identity was pinned. A server version change, a `protocolVersion` change, or identity added or removed does not by itself change this code. |
|
|
126
|
+
| `1` | Drift: a tool was added, removed, or its definition changed, including an output-schema change, or the pinned server name changed. |
|
|
127
|
+
| `2` | The listing or lockfile could not be read (missing file, invalid JSON, duplicate names, a lockfile hash that does not match its stored definition, or server identity that is present but not usable). |
|
|
128
|
+
|
|
129
|
+
## GitHub Actions
|
|
130
|
+
|
|
131
|
+
Commit the lockfile. Produce a fresh `tools/list` file in an earlier step you control, then fail the job when the listing has drifted:
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
- name: Check pinned MCP tool definitions
|
|
135
|
+
run: python3 mcp_tool_pin_check.py check --listing tools-list.json --lock mcp-tools.lock.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This repository's own workflow runs the unit tests only. It does not contact an MCP server.
|
|
139
|
+
|
|
140
|
+
## What it does
|
|
141
|
+
|
|
142
|
+
Each tool is reduced to `name`, `description`, `inputSchema`, `outputSchema`, and `annotations` when annotations are present. Tools are sorted by name. `pin` writes a JSON lockfile (`lockfile_version` 1) with `pinned_at`, a source label, and one SHA-256 per normalized tool, plus the normalized definition. The hash is SHA-256 of the canonical JSON, with keys sorted, so `outputSchema` is covered by the same hash as `inputSchema`. A tool with no output schema stores `outputSchema: null`. `check` reports added tools, removed tools, a short unified diff when a description changed, input-schema changes, and output-schema changes on separate lines.
|
|
143
|
+
|
|
144
|
+
A schema line marked `[widened]` means the input schema accepts more than the pin:
|
|
145
|
+
|
|
146
|
+
- a new property
|
|
147
|
+
- a `required` name removed
|
|
148
|
+
- `enum` removed or given new values
|
|
149
|
+
- `maxLength` removed or increased
|
|
150
|
+
- `pattern` removed
|
|
151
|
+
- `additionalProperties` moving from `false` or a subschema to `true` or absent
|
|
152
|
+
|
|
153
|
+
Other input-schema edits are still drift. They are printed without `[widened]`. A tighter `maxLength`, for example, is a change and not a widening.
|
|
154
|
+
|
|
155
|
+
Output-schema findings are printed under `output schema:`, not in the input `schema:` block and not as `schema widened`. A new enum value anywhere in `outputSchema` is a line of its own:
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
output widened: properties.status.enum (added "deleted"); a new output enum value can break downstream exhaustive switches
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
That note is there because a caller with an exhaustive switch on the pinned enum can break when the server adds a value. Other output edits are `output changed` lines. Those include added or removed properties, type changes, and removed enum values. Output drift exits `1`.
|
|
162
|
+
|
|
163
|
+
A v0.1 lockfile has no `outputSchema` field. `check` still exits `0` when the current listing also has no output schema, so upgrading the checker does not fail that pin. Re-pinning the same listing writes a new hash, because the stored definition now includes `outputSchema: null`. If the listing has an `outputSchema` the old lock never pinned, `check` prints `output changed: outputSchema added (not present in the pin)` and exits `1`. That counts as drift: an output contract the pin never recorded is a definition change. Re-pin after you have reviewed the new schema.
|
|
164
|
+
|
|
165
|
+
## Server identity
|
|
166
|
+
|
|
167
|
+
`pin` can record which server produced the listing. Put the MCP `initialize` result in the same JSON file as the tools. The lock stores `serverInfo.name`, `serverInfo.version`, and `protocolVersion` when that field is present. Identity is not part of the tool hash. A listing with no identity writes the same tools-only lock as v0.2.0.
|
|
168
|
+
|
|
169
|
+
Either of these shapes works:
|
|
170
|
+
|
|
171
|
+
- `serverInfo` and optional `protocolVersion` on the same object as `tools`, or inside `result` next to `tools`
|
|
172
|
+
- an `initialize` object next to the tools. That object may be the initialize result itself, or a JSON-RPC response whose `result` is the initialize result
|
|
173
|
+
|
|
174
|
+
`examples/tools-list.identity.json` uses the `initialize` envelope. A later check against `examples/tools-list.identity-version.json` exits `0` and prints `server version changed`.
|
|
175
|
+
|
|
176
|
+
`check` prints a `server identity` section:
|
|
177
|
+
|
|
178
|
+
- `server version changed` is informational. A version bump on the same server name does not change the exit code.
|
|
179
|
+
- `protocol version changed` is informational, including when `protocolVersion` was added or dropped. It does not change the exit code.
|
|
180
|
+
- `server name changed` is drift and exits `1`, even when every tool still matches. A different `serverInfo.name` behind the same pin is a substitution signal: the listing may be from a different server than the one you reviewed.
|
|
181
|
+
- `identity added` and `identity removed` are reported on their own lines. They do not by themselves change the exit code. A tools-only export after an identity pin prints `identity removed` and still exits `0` when the tools and, if present, the server name are otherwise unchanged.
|
|
182
|
+
|
|
183
|
+
A v0.1 or v0.2.0 lock has no server identity. `check` still exits `0` when the tools match, and it prints `note: identity was not pinned`. If that later listing also includes an initialize result, the note is kept and `identity added` is printed. That added line is informational. Re-pin once you want the name and version stored.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python3 mcp_tool_pin_check.py pin \
|
|
187
|
+
--listing examples/tools-list.identity.json \
|
|
188
|
+
--lock /tmp/notes.lock.json \
|
|
189
|
+
--source local-notes \
|
|
190
|
+
--pinned-at 2026-10-08T00:00:00Z
|
|
191
|
+
|
|
192
|
+
python3 mcp_tool_pin_check.py check \
|
|
193
|
+
--listing examples/tools-list.identity-version.json \
|
|
194
|
+
--lock /tmp/notes.lock.json
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## What it does not do
|
|
198
|
+
|
|
199
|
+
- It does not open a network connection, call `initialize` or `tools/list`, start a server, or modify an MCP server. You save those responses yourself.
|
|
200
|
+
- It does not execute text from descriptions or schemas. It only reads the files you name and writes the lockfile path you pass to `pin`.
|
|
201
|
+
- It does not decide that a description is safe or unsafe. It shows whether the stored definition changed.
|
|
202
|
+
- It does not pin tool fields other than name, description, input schema, output schema, and annotations. Server identity is only `serverInfo.name`, `serverInfo.version`, and `protocolVersion`.
|
|
203
|
+
- A clean check is not a full review of the server. It means the normalized tool definitions match and, when identity was pinned, the server name matches. A version or `protocolVersion` difference can still be printed as information.
|
|
204
|
+
|
|
205
|
+
## Authorized use
|
|
206
|
+
|
|
207
|
+
This repository is [MIT licensed](https://github.com/digitalfortressaudit/mcp-tool-pin-check/blob/main/LICENSE). It is for **defensive checks only**.
|
|
208
|
+
|
|
209
|
+
- Pin and check tool listings from MCP servers you own or are authorized to assess.
|
|
210
|
+
- Save those listings from clients you are allowed to use.
|
|
211
|
+
- Do not point it at servers you are not allowed to review.
|
|
212
|
+
|
|
213
|
+
## Tests
|
|
214
|
+
|
|
215
|
+
From this directory:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
python3 -m unittest tests.test_pin_check -v
|
|
219
|
+
python3 -m unittest discover -s tests -v
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Changelog
|
|
223
|
+
|
|
224
|
+
### v0.2.2
|
|
225
|
+
|
|
226
|
+
- Package the checker for installation with pip. `pin` and `check` behavior is unchanged from v0.2.1.
|
|
227
|
+
|
|
228
|
+
### v0.2.1
|
|
229
|
+
|
|
230
|
+
- `pin` records server identity from an MCP `initialize` result included in the listing JSON: `serverInfo.name`, `serverInfo.version`, and `protocolVersion` when it is present. Identity is stored on the lock and is not part of the tool hash. Listings without identity still produce a tools-only lock.
|
|
231
|
+
- `check` reports identity in its own section. `server version changed` and `protocol version changed` are informational and do not change the exit code. `server name changed` is drift and exits 1, because a different server name behind the same pin is a substitution signal. `identity added` and `identity removed` are reported and do not by themselves change the exit code.
|
|
232
|
+
- A v0.1 or v0.2.0 lock with no identity still checks cleanly (exit 0) when the tools match, and `check` prints `note: identity was not pinned`.
|
|
233
|
+
|
|
234
|
+
### v0.2.0
|
|
235
|
+
|
|
236
|
+
- `pin` stores and hashes each tool's `outputSchema` with the same canonical-JSON SHA-256 as the rest of the definition. A missing output schema is recorded as `null`.
|
|
237
|
+
- `check` reports output-schema changes on their own lines. A new enum value anywhere in `outputSchema` is flagged `output widened`, with a note that it can break downstream exhaustive switches. Other output edits are reported as `output changed`.
|
|
238
|
+
- v0.1 lockfiles, which have no `outputSchema` data, still check cleanly (exit 0) against a listing whose tools have no output schema. An `outputSchema` the old lock never pinned is an output-added finding and counts as drift (exit 1).
|
|
239
|
+
|
|
240
|
+
### v0.1.0
|
|
241
|
+
|
|
242
|
+
- Initial public release. Pin `name`, `description`, `inputSchema`, and `annotations`, and diff a later listing for added tools, removed tools, description edits, and input-schema changes.
|
|
243
|
+
|
|
244
|
+
## For more from Digital Fortress
|
|
245
|
+
|
|
246
|
+
For more from Digital Fortress:
|
|
247
|
+
|
|
248
|
+
- **[$79 MCP & Multi-Agent Authorization Security Pack](https://fortressaudit.gumroad.com/l/wphmlq)** — the pack for MCP tool trust.
|
|
249
|
+
- **[$99 AI Security & Infrastructure Prompt Pack](https://fortressaudit.gumroad.com/l/myyeen)**
|
|
250
|
+
- **[$149 Multi-Agent Orchestration Blueprint](https://fortressaudit.gumroad.com/l/cvxfw)**
|
|
251
|
+
- **[$99 Agent Runtime Containment Pack](https://fortressaudit.gumroad.com/l/ngouvf)**: agent egress, identity, Kubernetes sandboxing, and tool-call authorization.
|
|
252
|
+
|
|
253
|
+
A free sibling starter, with copy-paste audit prompts and one read-only repository scan, is the [AI Security Starter Checklist](https://github.com/digitalfortressaudit/ai-security-starter-checklist).
|
|
254
|
+
|
|
255
|
+
## Sources
|
|
256
|
+
|
|
257
|
+
- [OWASP: MCP Tool Poisoning](https://owasp.org/www-community/attacks/MCP_Tool_Poisoning)
|
|
258
|
+
- [Model Context Protocol Threat Modeling and Analyzing Vulnerabilities to Prompt Injection with Tool Poisoning](https://arxiv.org/html/2603.22489v1)
|
|
259
|
+
- [A Formal Security Framework for MCP-Based AI Agents](https://arxiv.org/abs/2604.05969v1)
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
[MIT](https://github.com/digitalfortressaudit/mcp-tool-pin-check/blob/main/LICENSE) © 2026 Digital Fortress · github.com/digitalfortressaudit. Authorized defensive use only, as described above.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jsonrpc": "2.0",
|
|
3
|
+
"id": 2,
|
|
4
|
+
"result": {
|
|
5
|
+
"tools": [
|
|
6
|
+
{
|
|
7
|
+
"name": "get_note",
|
|
8
|
+
"description": "Fetch a single saved note using its identifier.",
|
|
9
|
+
"inputSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"additionalProperties": false,
|
|
12
|
+
"required": ["id"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"id": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Note identifier."
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"annotations": {
|
|
21
|
+
"readOnlyHint": true,
|
|
22
|
+
"destructiveHint": false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "list_notes",
|
|
27
|
+
"description": "List notes whose titles contain a query string.",
|
|
28
|
+
"inputSchema": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"query": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Text to match against note titles.",
|
|
35
|
+
"maxLength": 200
|
|
36
|
+
},
|
|
37
|
+
"limit": {
|
|
38
|
+
"type": "integer",
|
|
39
|
+
"description": "Maximum number of notes to return.",
|
|
40
|
+
"minimum": 1,
|
|
41
|
+
"maximum": 50
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"annotations": {
|
|
46
|
+
"readOnlyHint": true,
|
|
47
|
+
"destructiveHint": false
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "create_note",
|
|
52
|
+
"description": "Save a new note with a title and a body.",
|
|
53
|
+
"inputSchema": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"required": ["title", "body"],
|
|
57
|
+
"properties": {
|
|
58
|
+
"title": {
|
|
59
|
+
"type": "string",
|
|
60
|
+
"description": "Short title for the note.",
|
|
61
|
+
"maxLength": 120
|
|
62
|
+
},
|
|
63
|
+
"body": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "Note text.",
|
|
66
|
+
"maxLength": 4000
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"annotations": {
|
|
71
|
+
"readOnlyHint": false,
|
|
72
|
+
"destructiveHint": false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
}
|