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.
@@ -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
+ [![CI](https://github.com/les-k/mcp-confirm/actions/workflows/ci.yml/badge.svg)](https://github.com/les-k/mcp-confirm/actions/workflows/ci.yml)
54
+ [![Python](https://img.shields.io/badge/python-3.11%20%E2%80%93%203.13-blue)](https://www.python.org/)
55
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+ [![CI](https://github.com/les-k/mcp-confirm/actions/workflows/ci.yml/badge.svg)](https://github.com/les-k/mcp-confirm/actions/workflows/ci.yml)
5
+ [![Python](https://img.shields.io/badge/python-3.11%20%E2%80%93%203.13-blue)](https://www.python.org/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ import sys
2
+
3
+ from .server import main
4
+
5
+ sys.exit(main())
@@ -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