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.
@@ -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,2 @@
1
+ recursive-include examples *
2
+ recursive-include tests *.py
@@ -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
+ }