mcp-confirm 0.2.1__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_confirm-0.2.1/LICENSE +21 -0
- mcp_confirm-0.2.1/PKG-INFO +230 -0
- mcp_confirm-0.2.1/README.md +181 -0
- mcp_confirm-0.2.1/pyproject.toml +65 -0
- mcp_confirm-0.2.1/setup.cfg +4 -0
- mcp_confirm-0.2.1/src/mcp_confirm/__init__.py +18 -0
- mcp_confirm-0.2.1/src/mcp_confirm/__main__.py +5 -0
- mcp_confirm-0.2.1/src/mcp_confirm/prompt.py +68 -0
- mcp_confirm-0.2.1/src/mcp_confirm/server.py +275 -0
- mcp_confirm-0.2.1/src/mcp_confirm/singleuse.py +129 -0
- mcp_confirm-0.2.1/src/mcp_confirm.egg-info/PKG-INFO +230 -0
- mcp_confirm-0.2.1/src/mcp_confirm.egg-info/SOURCES.txt +17 -0
- mcp_confirm-0.2.1/src/mcp_confirm.egg-info/dependency_links.txt +1 -0
- mcp_confirm-0.2.1/src/mcp_confirm.egg-info/entry_points.txt +2 -0
- mcp_confirm-0.2.1/src/mcp_confirm.egg-info/requires.txt +7 -0
- mcp_confirm-0.2.1/src/mcp_confirm.egg-info/top_level.txt +1 -0
- mcp_confirm-0.2.1/tests/test_prompt.py +78 -0
- mcp_confirm-0.2.1/tests/test_server.py +292 -0
- mcp_confirm-0.2.1/tests/test_singleuse.py +84 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leslie Kadenge
|
|
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,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-confirm
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: An MCP server whose confirmation cannot be replayed against a different call
|
|
5
|
+
Author: Leslie Kadenge
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Leslie Kadenge
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/les-k/mcp-confirm
|
|
29
|
+
Project-URL: Issues, https://github.com/les-k/mcp-confirm/issues
|
|
30
|
+
Keywords: mcp,elicitation,mrtr,security,confirmation
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Topic :: Security
|
|
39
|
+
Requires-Python: >=3.11
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Requires-Dist: mcp>=2.0
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
46
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
47
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
<!-- mcp-name: io.github.les-k/mcp-confirm -->
|
|
51
|
+
# mcp-confirm
|
|
52
|
+
|
|
53
|
+
[](https://github.com/les-k/mcp-confirm/actions/workflows/ci.yml)
|
|
54
|
+
[](https://www.python.org/)
|
|
55
|
+
[](LICENSE)
|
|
56
|
+
|
|
57
|
+
> **In plain terms:** when an AI asks "are you sure?", the MCP SDK already stops
|
|
58
|
+
> that approval being reused for a *different* action. It does **not** stop the
|
|
59
|
+
> same approval being used *twice*. This closes that gap, and two others the SDK
|
|
60
|
+
> leaves open.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Read this first: most of this problem is already solved
|
|
65
|
+
|
|
66
|
+
The 2026-07-28 MCP specification added Multi Round-Trip Requests, so a tool can
|
|
67
|
+
pause mid-call and ask the user to confirm. The approval travels back through
|
|
68
|
+
the client as an opaque `requestState`, which the spec says servers **MUST**
|
|
69
|
+
treat as attacker-controlled.
|
|
70
|
+
|
|
71
|
+
**The Python SDK does this for you, by default, on every `MCPServer`.**
|
|
72
|
+
`RequestStateBoundary` is appended to the middleware chain unconditionally —
|
|
73
|
+
with an ephemeral key if you supply none. It seals the state under
|
|
74
|
+
**AES-256-GCM** and binds it to the method, the target, a **digest of the call's
|
|
75
|
+
arguments**, the audience, and the authenticated principal, with a TTL.
|
|
76
|
+
|
|
77
|
+
So the attack everyone reaches for first — *approve deleting `cache.txt`, then
|
|
78
|
+
replay that approval against `thesis.txt`* — **is already refused by the SDK.**
|
|
79
|
+
You do not need a library for it, and you should not write one.
|
|
80
|
+
|
|
81
|
+
This repository originally wrote one anyway: 250 lines of HMAC, TTL and argument
|
|
82
|
+
binding, shipped as the headline feature, redundant the day it was published.
|
|
83
|
+
That is recorded at the bottom rather than quietly deleted.
|
|
84
|
+
|
|
85
|
+
## What the SDK does *not* do
|
|
86
|
+
|
|
87
|
+
Three gaps, each with a test that fires.
|
|
88
|
+
|
|
89
|
+
### 1. It binds and expires the state. It never spends it.
|
|
90
|
+
|
|
91
|
+
Inside the TTL, the **same** approval verifies as many times as it is presented.
|
|
92
|
+
Every check the boundary makes passes, every time. The specification is explicit
|
|
93
|
+
that this is deliberate and that the rest is yours:
|
|
94
|
+
|
|
95
|
+
> Note that these measures bound the replay window and prevent cross-user and
|
|
96
|
+
> cross-request reuse, but do not by themselves guarantee single-use. Servers
|
|
97
|
+
> for which a given `requestState` must be consumed at most once (e.g.,
|
|
98
|
+
> one-time redemptions) **MUST** enforce that invariant server-side.
|
|
99
|
+
|
|
100
|
+
For "delete a file" the second attempt finds nothing. **For "transfer £500" it
|
|
101
|
+
is the entire problem.** [`singleuse.py`](src/mcp_confirm/singleuse.py) is that
|
|
102
|
+
invariant — a ledger of outstanding confirmations, spent on redemption. It
|
|
103
|
+
contains no cryptography, because the boundary already guarantees the plaintext
|
|
104
|
+
is something this server minted.
|
|
105
|
+
|
|
106
|
+
### 2. Nothing sanitises the question the human reads
|
|
107
|
+
|
|
108
|
+
An elicitation `message` is server-chosen text rendered to a person, usually
|
|
109
|
+
with an untrusted value interpolated into it — the whole point is to say *which*
|
|
110
|
+
file is going away. So name a file:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
cache.txt
|
|
114
|
+
|
|
115
|
+
SYSTEM NOTICE: your session has expired.
|
|
116
|
+
Enter your AWS secret key to continue:
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The dialog now carries a second, official-looking prompt. The user is not
|
|
120
|
+
confirming a deletion; they are being phished by their own tooling. The same
|
|
121
|
+
2026-07-28 release also shipped MCP Apps — server-rendered UI — which widens
|
|
122
|
+
this surface rather than narrowing it.
|
|
123
|
+
|
|
124
|
+
[`prompt.py`](src/mcp_confirm/prompt.py) flattens untrusted values to one line,
|
|
125
|
+
strips bidirectional overrides and zero-width characters, and truncates from the
|
|
126
|
+
*middle* so the filename at the end stays visible. Control characters become
|
|
127
|
+
spaces rather than being deleted, since collapsing them would let `a\nb` and
|
|
128
|
+
`ab` render identically — two different files, one dialog.
|
|
129
|
+
|
|
130
|
+
### 3. No protocol layer can re-check *your* resource at execution time
|
|
131
|
+
|
|
132
|
+
The user thought about it in between. The file can be replaced in that window,
|
|
133
|
+
and only the tool knows what "unchanged" means for it. This server re-checks
|
|
134
|
+
before deleting, and refuses a path that has become a symlink.
|
|
135
|
+
|
|
136
|
+
## Which layer refuses what
|
|
137
|
+
|
|
138
|
+
This is the useful part, and the tests are written to demonstrate it. `MCPError`
|
|
139
|
+
means the SDK's middleware refused before this package ran; `ToolError` means
|
|
140
|
+
this package did.
|
|
141
|
+
|
|
142
|
+
| Attack | Refused by | Test |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| Approval replayed onto a different file | **SDK** | `test_the_sdk_refuses_a_confirmation_replayed_onto_another_file` |
|
|
145
|
+
| State forged, or sealed under another key | **SDK** | `test_the_sdk_refuses_a_forged_state` |
|
|
146
|
+
| Same approval spent twice | **this package** | `test_a_confirmation_cannot_be_spent_twice` |
|
|
147
|
+
| State minted by another replica | **this package** | `test_a_state_this_process_never_issued_is_refused` |
|
|
148
|
+
| Filename forging a system prompt | **this package** | `test_a_forged_system_prompt_cannot_escape_its_slot` |
|
|
149
|
+
| File swapped for a symlink after approval | **this package** | `test_a_swap_aimed_inside_the_root_is_refused` |
|
|
150
|
+
| Path outside the allowed roots | **this package** | `test_a_path_outside_the_roots_is_refused_before_asking` |
|
|
151
|
+
|
|
152
|
+
## Tests
|
|
153
|
+
|
|
154
|
+
**35 tests** — 17 through the server, 11 on the ledger, 7 on prompt sanitisation.
|
|
155
|
+
|
|
156
|
+
**Every server test runs through the real `RequestStateBoundary`**, the same
|
|
157
|
+
class `MCPServer` installs on itself, with a pinned key — sealing round one and
|
|
158
|
+
unsealing round two exactly as the wire would.
|
|
159
|
+
|
|
160
|
+
That harness exists because of a specific mistake. The first version tested by
|
|
161
|
+
calling `MCPServer.call_tool()` directly, which goes straight to the tool
|
|
162
|
+
manager and **bypasses the middleware chain entirely**. The SDK's boundary never
|
|
163
|
+
ran, so a redundant hand-rolled guard looked load-bearing. The test design is
|
|
164
|
+
what hid it, for an entire build cycle.
|
|
165
|
+
|
|
166
|
+
Coverage is 81%, with `prompt.py` at 100% and `singleuse.py` at 94%. The gap is
|
|
167
|
+
`main()`'s argparse and transport wiring, exercised through `build_server`
|
|
168
|
+
instead.
|
|
169
|
+
|
|
170
|
+
CI runs on Ubuntu only, deliberately: the swap-after-confirmation tests need
|
|
171
|
+
symlinks, and the build **fails if they report as skipped there**.
|
|
172
|
+
|
|
173
|
+
## Install
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pip install mcp-confirm
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Run
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
mcp-confirm --root /path/you/allow
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
With no `--root`, the server refuses every request rather than defaulting to
|
|
186
|
+
anything. Roots are fixed at startup and never chosen by the agent. No signing
|
|
187
|
+
key is needed — `MCPServer` brings its own.
|
|
188
|
+
|
|
189
|
+
## Known limitations
|
|
190
|
+
|
|
191
|
+
- **The ledger is in-memory**, so it is correct for one process and wrong behind
|
|
192
|
+
a load balancer. The SDK supports sharing keys across replicas
|
|
193
|
+
(`RequestStateSecurity(keys=[...])`); under that configuration replica B
|
|
194
|
+
rejects a confirmation issued by replica A. It fails closed — the safe
|
|
195
|
+
direction — but reads to a user as a confirmation that inexplicably stopped
|
|
196
|
+
working. A multi-process deployment needs Redis or a database row with an
|
|
197
|
+
atomic compare-and-delete. There is a test for this behaviour.
|
|
198
|
+
- **The demonstration tool is deliberately small.** It deletes one file. The
|
|
199
|
+
interesting code is `singleuse.py` and `prompt.py`.
|
|
200
|
+
- **No CVE backs this.** MRTR is weeks old, so this is built from the
|
|
201
|
+
specification's own MUST/SHOULD list and from reading the SDK, not from a
|
|
202
|
+
published incident.
|
|
203
|
+
|
|
204
|
+
## What was wrong with version 0.1.0
|
|
205
|
+
|
|
206
|
+
Kept here because a repository that records only its successes is not evidence
|
|
207
|
+
of anything.
|
|
208
|
+
|
|
209
|
+
**0.1.0 reimplemented what the SDK already did.** `state.py` was 250 lines of
|
|
210
|
+
HMAC signing, TTL, and principal/method/argument binding — all duplicating
|
|
211
|
+
`RequestStateBoundary`, none of it as good: HMAC where the SDK uses
|
|
212
|
+
authenticated encryption, no audience binding, no key rotation.
|
|
213
|
+
|
|
214
|
+
**It was caught by reading the SDK's source, not by a test.** The tests passed
|
|
215
|
+
precisely because they bypassed the middleware that would have exposed it.
|
|
216
|
+
|
|
217
|
+
**CI separately caught a real bug in 0.1.0**: the symlink check ran *after*
|
|
218
|
+
`Path.resolve()`, so it inspected the link's destination rather than the link
|
|
219
|
+
itself. A swap aimed at another file inside an allowed root would have been
|
|
220
|
+
deleted. Fixed, with a test for the variant the original never exercised.
|
|
221
|
+
|
|
222
|
+
0.2.0 deletes `state.py` entirely and keeps only what the SDK leaves uncovered.
|
|
223
|
+
|
|
224
|
+
## Licence
|
|
225
|
+
|
|
226
|
+
MIT.
|
|
227
|
+
|
|
228
|
+
## Author
|
|
229
|
+
|
|
230
|
+
Built by [Leslie Kadenge](https://les-k.github.io). I do independent security reviews of MCP servers; my public survey of thirteen production servers is at [les-k.github.io/field-notes.html](https://les-k.github.io/field-notes.html).
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.les-k/mcp-confirm -->
|
|
2
|
+
# mcp-confirm
|
|
3
|
+
|
|
4
|
+
[](https://github.com/les-k/mcp-confirm/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
> **In plain terms:** when an AI asks "are you sure?", the MCP SDK already stops
|
|
9
|
+
> that approval being reused for a *different* action. It does **not** stop the
|
|
10
|
+
> same approval being used *twice*. This closes that gap, and two others the SDK
|
|
11
|
+
> leaves open.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Read this first: most of this problem is already solved
|
|
16
|
+
|
|
17
|
+
The 2026-07-28 MCP specification added Multi Round-Trip Requests, so a tool can
|
|
18
|
+
pause mid-call and ask the user to confirm. The approval travels back through
|
|
19
|
+
the client as an opaque `requestState`, which the spec says servers **MUST**
|
|
20
|
+
treat as attacker-controlled.
|
|
21
|
+
|
|
22
|
+
**The Python SDK does this for you, by default, on every `MCPServer`.**
|
|
23
|
+
`RequestStateBoundary` is appended to the middleware chain unconditionally —
|
|
24
|
+
with an ephemeral key if you supply none. It seals the state under
|
|
25
|
+
**AES-256-GCM** and binds it to the method, the target, a **digest of the call's
|
|
26
|
+
arguments**, the audience, and the authenticated principal, with a TTL.
|
|
27
|
+
|
|
28
|
+
So the attack everyone reaches for first — *approve deleting `cache.txt`, then
|
|
29
|
+
replay that approval against `thesis.txt`* — **is already refused by the SDK.**
|
|
30
|
+
You do not need a library for it, and you should not write one.
|
|
31
|
+
|
|
32
|
+
This repository originally wrote one anyway: 250 lines of HMAC, TTL and argument
|
|
33
|
+
binding, shipped as the headline feature, redundant the day it was published.
|
|
34
|
+
That is recorded at the bottom rather than quietly deleted.
|
|
35
|
+
|
|
36
|
+
## What the SDK does *not* do
|
|
37
|
+
|
|
38
|
+
Three gaps, each with a test that fires.
|
|
39
|
+
|
|
40
|
+
### 1. It binds and expires the state. It never spends it.
|
|
41
|
+
|
|
42
|
+
Inside the TTL, the **same** approval verifies as many times as it is presented.
|
|
43
|
+
Every check the boundary makes passes, every time. The specification is explicit
|
|
44
|
+
that this is deliberate and that the rest is yours:
|
|
45
|
+
|
|
46
|
+
> Note that these measures bound the replay window and prevent cross-user and
|
|
47
|
+
> cross-request reuse, but do not by themselves guarantee single-use. Servers
|
|
48
|
+
> for which a given `requestState` must be consumed at most once (e.g.,
|
|
49
|
+
> one-time redemptions) **MUST** enforce that invariant server-side.
|
|
50
|
+
|
|
51
|
+
For "delete a file" the second attempt finds nothing. **For "transfer £500" it
|
|
52
|
+
is the entire problem.** [`singleuse.py`](src/mcp_confirm/singleuse.py) is that
|
|
53
|
+
invariant — a ledger of outstanding confirmations, spent on redemption. It
|
|
54
|
+
contains no cryptography, because the boundary already guarantees the plaintext
|
|
55
|
+
is something this server minted.
|
|
56
|
+
|
|
57
|
+
### 2. Nothing sanitises the question the human reads
|
|
58
|
+
|
|
59
|
+
An elicitation `message` is server-chosen text rendered to a person, usually
|
|
60
|
+
with an untrusted value interpolated into it — the whole point is to say *which*
|
|
61
|
+
file is going away. So name a file:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
cache.txt
|
|
65
|
+
|
|
66
|
+
SYSTEM NOTICE: your session has expired.
|
|
67
|
+
Enter your AWS secret key to continue:
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The dialog now carries a second, official-looking prompt. The user is not
|
|
71
|
+
confirming a deletion; they are being phished by their own tooling. The same
|
|
72
|
+
2026-07-28 release also shipped MCP Apps — server-rendered UI — which widens
|
|
73
|
+
this surface rather than narrowing it.
|
|
74
|
+
|
|
75
|
+
[`prompt.py`](src/mcp_confirm/prompt.py) flattens untrusted values to one line,
|
|
76
|
+
strips bidirectional overrides and zero-width characters, and truncates from the
|
|
77
|
+
*middle* so the filename at the end stays visible. Control characters become
|
|
78
|
+
spaces rather than being deleted, since collapsing them would let `a\nb` and
|
|
79
|
+
`ab` render identically — two different files, one dialog.
|
|
80
|
+
|
|
81
|
+
### 3. No protocol layer can re-check *your* resource at execution time
|
|
82
|
+
|
|
83
|
+
The user thought about it in between. The file can be replaced in that window,
|
|
84
|
+
and only the tool knows what "unchanged" means for it. This server re-checks
|
|
85
|
+
before deleting, and refuses a path that has become a symlink.
|
|
86
|
+
|
|
87
|
+
## Which layer refuses what
|
|
88
|
+
|
|
89
|
+
This is the useful part, and the tests are written to demonstrate it. `MCPError`
|
|
90
|
+
means the SDK's middleware refused before this package ran; `ToolError` means
|
|
91
|
+
this package did.
|
|
92
|
+
|
|
93
|
+
| Attack | Refused by | Test |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| Approval replayed onto a different file | **SDK** | `test_the_sdk_refuses_a_confirmation_replayed_onto_another_file` |
|
|
96
|
+
| State forged, or sealed under another key | **SDK** | `test_the_sdk_refuses_a_forged_state` |
|
|
97
|
+
| Same approval spent twice | **this package** | `test_a_confirmation_cannot_be_spent_twice` |
|
|
98
|
+
| State minted by another replica | **this package** | `test_a_state_this_process_never_issued_is_refused` |
|
|
99
|
+
| Filename forging a system prompt | **this package** | `test_a_forged_system_prompt_cannot_escape_its_slot` |
|
|
100
|
+
| File swapped for a symlink after approval | **this package** | `test_a_swap_aimed_inside_the_root_is_refused` |
|
|
101
|
+
| Path outside the allowed roots | **this package** | `test_a_path_outside_the_roots_is_refused_before_asking` |
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
**35 tests** — 17 through the server, 11 on the ledger, 7 on prompt sanitisation.
|
|
106
|
+
|
|
107
|
+
**Every server test runs through the real `RequestStateBoundary`**, the same
|
|
108
|
+
class `MCPServer` installs on itself, with a pinned key — sealing round one and
|
|
109
|
+
unsealing round two exactly as the wire would.
|
|
110
|
+
|
|
111
|
+
That harness exists because of a specific mistake. The first version tested by
|
|
112
|
+
calling `MCPServer.call_tool()` directly, which goes straight to the tool
|
|
113
|
+
manager and **bypasses the middleware chain entirely**. The SDK's boundary never
|
|
114
|
+
ran, so a redundant hand-rolled guard looked load-bearing. The test design is
|
|
115
|
+
what hid it, for an entire build cycle.
|
|
116
|
+
|
|
117
|
+
Coverage is 81%, with `prompt.py` at 100% and `singleuse.py` at 94%. The gap is
|
|
118
|
+
`main()`'s argparse and transport wiring, exercised through `build_server`
|
|
119
|
+
instead.
|
|
120
|
+
|
|
121
|
+
CI runs on Ubuntu only, deliberately: the swap-after-confirmation tests need
|
|
122
|
+
symlinks, and the build **fails if they report as skipped there**.
|
|
123
|
+
|
|
124
|
+
## Install
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install mcp-confirm
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Run
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
mcp-confirm --root /path/you/allow
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
With no `--root`, the server refuses every request rather than defaulting to
|
|
137
|
+
anything. Roots are fixed at startup and never chosen by the agent. No signing
|
|
138
|
+
key is needed — `MCPServer` brings its own.
|
|
139
|
+
|
|
140
|
+
## Known limitations
|
|
141
|
+
|
|
142
|
+
- **The ledger is in-memory**, so it is correct for one process and wrong behind
|
|
143
|
+
a load balancer. The SDK supports sharing keys across replicas
|
|
144
|
+
(`RequestStateSecurity(keys=[...])`); under that configuration replica B
|
|
145
|
+
rejects a confirmation issued by replica A. It fails closed — the safe
|
|
146
|
+
direction — but reads to a user as a confirmation that inexplicably stopped
|
|
147
|
+
working. A multi-process deployment needs Redis or a database row with an
|
|
148
|
+
atomic compare-and-delete. There is a test for this behaviour.
|
|
149
|
+
- **The demonstration tool is deliberately small.** It deletes one file. The
|
|
150
|
+
interesting code is `singleuse.py` and `prompt.py`.
|
|
151
|
+
- **No CVE backs this.** MRTR is weeks old, so this is built from the
|
|
152
|
+
specification's own MUST/SHOULD list and from reading the SDK, not from a
|
|
153
|
+
published incident.
|
|
154
|
+
|
|
155
|
+
## What was wrong with version 0.1.0
|
|
156
|
+
|
|
157
|
+
Kept here because a repository that records only its successes is not evidence
|
|
158
|
+
of anything.
|
|
159
|
+
|
|
160
|
+
**0.1.0 reimplemented what the SDK already did.** `state.py` was 250 lines of
|
|
161
|
+
HMAC signing, TTL, and principal/method/argument binding — all duplicating
|
|
162
|
+
`RequestStateBoundary`, none of it as good: HMAC where the SDK uses
|
|
163
|
+
authenticated encryption, no audience binding, no key rotation.
|
|
164
|
+
|
|
165
|
+
**It was caught by reading the SDK's source, not by a test.** The tests passed
|
|
166
|
+
precisely because they bypassed the middleware that would have exposed it.
|
|
167
|
+
|
|
168
|
+
**CI separately caught a real bug in 0.1.0**: the symlink check ran *after*
|
|
169
|
+
`Path.resolve()`, so it inspected the link's destination rather than the link
|
|
170
|
+
itself. A swap aimed at another file inside an allowed root would have been
|
|
171
|
+
deleted. Fixed, with a test for the variant the original never exercised.
|
|
172
|
+
|
|
173
|
+
0.2.0 deletes `state.py` entirely and keeps only what the SDK leaves uncovered.
|
|
174
|
+
|
|
175
|
+
## Licence
|
|
176
|
+
|
|
177
|
+
MIT.
|
|
178
|
+
|
|
179
|
+
## Author
|
|
180
|
+
|
|
181
|
+
Built by [Leslie Kadenge](https://les-k.github.io). I do independent security reviews of MCP servers; my public survey of thirteen production servers is at [les-k.github.io/field-notes.html](https://les-k.github.io/field-notes.html).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcp-confirm"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "An MCP server whose confirmation cannot be replayed against a different call"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Leslie Kadenge" }]
|
|
13
|
+
keywords = ["mcp", "elicitation", "mrtr", "security", "confirmation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# Multi Round-Trip Requests landed in the 2026-07-28 specification; the
|
|
26
|
+
# InputRequiredResult and ElicitRequest types this server returns do not
|
|
27
|
+
# exist in earlier SDKs, so an older mcp is a hard failure rather than a
|
|
28
|
+
# degraded mode.
|
|
29
|
+
"mcp>=2.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7.4", "pytest-asyncio>=0.23", "pytest-cov>=4.1", "ruff>=0.6"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
mcp-confirm = "mcp_confirm.server:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/les-k/mcp-confirm"
|
|
40
|
+
Issues = "https://github.com/les-k/mcp-confirm/issues"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
# No JavaScript ships here, so there are no source maps to leak. The exclusion
|
|
46
|
+
# is declared anyway: it costs nothing, and arguing with a supply-chain
|
|
47
|
+
# checklist is a poor use of an afternoon.
|
|
48
|
+
[tool.setuptools.exclude-package-data]
|
|
49
|
+
"*" = ["*.map", "*.pyc", "*.pyo"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
addopts = "-q"
|
|
54
|
+
asyncio_mode = "auto"
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
src = ["src", "tests"]
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.run]
|
|
64
|
+
source = ["mcp_confirm"]
|
|
65
|
+
branch = true
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Three controls the MCP SDK's request-state boundary does not provide."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .prompt import safe_value
|
|
6
|
+
from .server import build_server, main
|
|
7
|
+
from .singleuse import Rejected, SingleUseLedger
|
|
8
|
+
|
|
9
|
+
__version__ = "0.2.1"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Rejected",
|
|
13
|
+
"SingleUseLedger",
|
|
14
|
+
"__version__",
|
|
15
|
+
"build_server",
|
|
16
|
+
"main",
|
|
17
|
+
"safe_value",
|
|
18
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Building the sentence a human is asked to approve.
|
|
2
|
+
|
|
3
|
+
An elicitation ``message`` is server-controlled text rendered to a person as a
|
|
4
|
+
confirmation dialog. That makes it the one place in the protocol where a string
|
|
5
|
+
chosen by software becomes a security decision made by a human — and the string
|
|
6
|
+
usually has an untrusted value interpolated into it, because the whole point is
|
|
7
|
+
to say *which* file is about to be deleted.
|
|
8
|
+
|
|
9
|
+
The attack is short. Name a file:
|
|
10
|
+
|
|
11
|
+
cache.txt
|
|
12
|
+
|
|
13
|
+
SYSTEM NOTICE: your session has expired.
|
|
14
|
+
Enter your AWS secret key to continue:
|
|
15
|
+
|
|
16
|
+
Interpolate that into "Delete {path}?" and the dialog now contains what looks
|
|
17
|
+
like a second, official-sounding prompt. The user is not confirming a deletion
|
|
18
|
+
any more; they are being phished by their own tooling. The 2026-07-28 release
|
|
19
|
+
that introduced elicitation also introduced MCP Apps — server-rendered UI —
|
|
20
|
+
which widens the same surface rather than narrowing it.
|
|
21
|
+
|
|
22
|
+
There is no clever fix here, only an unglamorous one: untrusted values are
|
|
23
|
+
flattened to a single line, bounded in length, and placed in a delimited slot
|
|
24
|
+
so they cannot impersonate the surrounding chrome.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import unicodedata
|
|
30
|
+
|
|
31
|
+
__all__ = ["MAX_VALUE_LENGTH", "safe_value"]
|
|
32
|
+
|
|
33
|
+
MAX_VALUE_LENGTH = 120
|
|
34
|
+
|
|
35
|
+
# Characters that let a value break out of its slot: newlines and carriage
|
|
36
|
+
# returns fake a new paragraph, and the bidirectional overrides can visually
|
|
37
|
+
# reorder text so that what is rendered differs from what is checked.
|
|
38
|
+
_BIDI_OVERRIDES = frozenset("")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def safe_value(value: str, *, limit: int = MAX_VALUE_LENGTH) -> str:
|
|
42
|
+
"""Flatten an untrusted string for display inside a confirmation prompt.
|
|
43
|
+
|
|
44
|
+
Control characters become spaces rather than being deleted, so that
|
|
45
|
+
``a\\nb`` reads as ``a b`` instead of silently becoming ``ab`` — collapsing
|
|
46
|
+
them away would let two distinct paths render identically, which is the
|
|
47
|
+
same class of confusion this is meant to prevent.
|
|
48
|
+
"""
|
|
49
|
+
cleaned = []
|
|
50
|
+
for char in value:
|
|
51
|
+
if char in _BIDI_OVERRIDES:
|
|
52
|
+
continue
|
|
53
|
+
# Cc is control characters; Cf is formatting characters such as the
|
|
54
|
+
# zero-width joiners used to hide text inside an apparently short name.
|
|
55
|
+
category = unicodedata.category(char)
|
|
56
|
+
cleaned.append(" " if category in {"Cc", "Cf", "Zl", "Zp"} else char)
|
|
57
|
+
|
|
58
|
+
flattened = " ".join("".join(cleaned).split())
|
|
59
|
+
|
|
60
|
+
if not flattened:
|
|
61
|
+
return "(empty)"
|
|
62
|
+
if len(flattened) > limit:
|
|
63
|
+
# Truncate from the middle: the beginning and the end of a path are
|
|
64
|
+
# both load-bearing, and lopping off the tail hides the filename that
|
|
65
|
+
# is the whole subject of the question.
|
|
66
|
+
keep = (limit - 1) // 2
|
|
67
|
+
return f"{flattened[:keep]}…{flattened[-keep:]}"
|
|
68
|
+
return flattened
|