@chrischall/mcp-utils 2.15.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +505 -26
  2. package/dist/auth/index.d.ts +25 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +105 -14
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/config/index.d.ts +13 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +3 -2
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/dates/index.d.ts +45 -5
  11. package/dist/dates/index.d.ts.map +1 -1
  12. package/dist/dates/index.js +64 -6
  13. package/dist/dates/index.js.map +1 -1
  14. package/dist/errors/index.d.ts +121 -7
  15. package/dist/errors/index.d.ts.map +1 -1
  16. package/dist/errors/index.js +154 -9
  17. package/dist/errors/index.js.map +1 -1
  18. package/dist/fs/output.d.ts +25 -8
  19. package/dist/fs/output.d.ts.map +1 -1
  20. package/dist/fs/output.js +27 -5
  21. package/dist/fs/output.js.map +1 -1
  22. package/dist/graphql/client.d.ts +8 -1
  23. package/dist/graphql/client.d.ts.map +1 -1
  24. package/dist/graphql/client.js +11 -3
  25. package/dist/graphql/client.js.map +1 -1
  26. package/dist/healthcheck/index.d.ts +11 -9
  27. package/dist/healthcheck/index.d.ts.map +1 -1
  28. package/dist/healthcheck/index.js +39 -23
  29. package/dist/healthcheck/index.js.map +1 -1
  30. package/dist/http/body-limit.d.ts +37 -0
  31. package/dist/http/body-limit.d.ts.map +1 -0
  32. package/dist/http/body-limit.js +86 -0
  33. package/dist/http/body-limit.js.map +1 -0
  34. package/dist/http/fetch-bounded.d.ts +91 -0
  35. package/dist/http/fetch-bounded.d.ts.map +1 -0
  36. package/dist/http/fetch-bounded.js +138 -0
  37. package/dist/http/fetch-bounded.js.map +1 -0
  38. package/dist/http/index.d.ts +231 -6
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +432 -56
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/response-cache.d.ts +36 -3
  43. package/dist/http/response-cache.d.ts.map +1 -1
  44. package/dist/http/response-cache.js +84 -8
  45. package/dist/http/response-cache.js.map +1 -1
  46. package/dist/http/throttle.d.ts +37 -4
  47. package/dist/http/throttle.d.ts.map +1 -1
  48. package/dist/http/throttle.js +65 -6
  49. package/dist/http/throttle.js.map +1 -1
  50. package/dist/http/url-safety.d.ts +142 -0
  51. package/dist/http/url-safety.d.ts.map +1 -0
  52. package/dist/http/url-safety.js +211 -0
  53. package/dist/http/url-safety.js.map +1 -0
  54. package/dist/netguard/index.d.ts +196 -0
  55. package/dist/netguard/index.d.ts.map +1 -0
  56. package/dist/netguard/index.js +365 -0
  57. package/dist/netguard/index.js.map +1 -0
  58. package/dist/server/confirm-env.d.ts +17 -8
  59. package/dist/server/confirm-env.d.ts.map +1 -1
  60. package/dist/server/confirm-env.js +22 -13
  61. package/dist/server/confirm-env.js.map +1 -1
  62. package/dist/server/confirm-write.js +1 -1
  63. package/dist/server/confirm-write.js.map +1 -1
  64. package/dist/server/index.d.ts +6 -2
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +24 -9
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/session/index.d.ts +6 -0
  69. package/dist/session/index.d.ts.map +1 -1
  70. package/dist/session/index.js +12 -1
  71. package/dist/session/index.js.map +1 -1
  72. package/dist/zod/index.d.ts +6 -0
  73. package/dist/zod/index.d.ts.map +1 -1
  74. package/dist/zod/index.js +8 -1
  75. package/dist/zod/index.js.map +1 -1
  76. package/docs/CLIENT-BEHAVIOUR.md +236 -0
  77. package/docs/CONFIRM-TOKEN.md +476 -0
  78. package/docs/MIGRATION-3.md +106 -0
  79. package/package.json +12 -2
@@ -0,0 +1,236 @@
1
+ # What MCP clients actually do
2
+
3
+ A measured record, not a reading of the spec. Every row here was observed,
4
+ and each says **when** and **from how much**, because all of it will drift.
5
+
6
+ Two sources, and the difference between them is the whole point of this file:
7
+
8
+ - **Telemetry** — `mcp-host`'s `usage_events`, which records what a client
9
+ **sends**. Permanent, and re-queryable at any time (see *Re-running* below).
10
+ - **The probe** — `~/git/mcp-probe`, a throwaway connector registered as slug
11
+ `probe`, which measures what a client **honours**. Nothing in the telemetry
12
+ can answer that, and guessing it cost real work in September 2026.
13
+
14
+ ## The clients
15
+
16
+ Measured 2026-09-21, from `meta.clientCapabilities` on `initialize`.
17
+
18
+ | client | version | declares |
19
+ | --- | --- | --- |
20
+ | `Anthropic/ClaudeAI` | 1.0.0 | `extensions` |
21
+ | `Anthropic/Toolbox` | 1.0.0 | **nothing at all** |
22
+ | `claude-code` | 2.1.278 | `elicitation`, `roots` |
23
+
24
+ Three surfaces, three answers. Anything conditioned on a capability must be
25
+ decided **per request**, never per deployment — and `undefined` must never be
26
+ read as `false`, because a 2025-era connection carries no envelope at all.
27
+
28
+ ## What claude.ai SENDS
29
+
30
+ From ~10.6k requests in the week to 2026-09-20.
31
+
32
+ | | |
33
+ | --- | --- |
34
+ | 2026 routing headers (`Mcp-Method`/`Mcp-Name`) | **yes** — 10,595 of 10,595 |
35
+ | `server/discover` | **yes** — 2,894 calls |
36
+ | declared protocol revision | **`2025-06-18`** |
37
+ | `Mcp-Session-Id` | returned |
38
+ | `notifications/cancelled` | **yes** — 101 in the week |
39
+ | `resources/list` | yes — 37 calls |
40
+ | `resources/subscribe` | **no** — 0, against a server advertising it |
41
+ | `_meta.progressToken` | **effectively no** — 1 of 2,958 `tools/call` rows EVER, and that one was our own test |
42
+
43
+ It declares `2025-06-18` while sending 2026 routing headers: **2026 routing
44
+ over a 2025 session lifecycle.** Do not infer the revision from the headers.
45
+
46
+ ## What claude.ai HONOURS
47
+
48
+ Probed 2026-09-21. This is the half telemetry cannot reach.
49
+
50
+ | we send | claude.ai |
51
+ | --- | --- |
52
+ | `destructiveHint: true` | **IGNORED** |
53
+ | `readOnlyHint: true` | no observable difference |
54
+ | an unannotated tool (spec default = destructive) | no observable difference |
55
+ | `structuredContent` + `outputSchema` | **never reaches the model** |
56
+ | `audio` block | **dropped**, replaced by a text notice |
57
+ | `resource_link` block | **dropped**, replaced by a text notice |
58
+ | `text` / `image` / embedded `resource` | delivered |
59
+ | `title` annotation | **stripped** — the model's schema has `name` and `description` only |
60
+ | `inputRequired` (elicitation) | refused, opaquely — see below |
61
+ | a registered prompt | `prompts/list` never called |
62
+
63
+ **The annotation result.** Four tools identical but for their annotations all
64
+ ran with no confirmation step, including the unannotated control. claude.ai's
65
+ own words: *"this interface didn't surface a distinct 'stop and ask' moment
66
+ for any of the four."*
67
+
68
+ **The structured-content result.** A probe returning `ANSWER=RED` as text and
69
+ `{answer: "BLUE"}` as structured content, with an `outputSchema` declared, was
70
+ read as `RED`. Emitting both costs result bytes on a metered host and is
71
+ discarded.
72
+
73
+ **Tool schemas are DEFERRED.** The model receives names, then must run a
74
+ `tool_search` to load parameters before it can call anything — observed
75
+ failing with *"'mcp__probe__probe_b_saw_red' has not been loaded yet"* and
76
+ recovering via a search. At fleet scale (434 tools across the gog servers
77
+ alone) **a description is discovery, not decoration**: a tool whose name and
78
+ description do not match how someone would search is effectively unreachable.
79
+
80
+ ### There is no in-band human gate on claude.ai
81
+
82
+ Neither elicitation nor the annotation. A hosted MCP cannot make claude.ai
83
+ stop and ask. A preview→confirm token was scoped and declined on 2026-09-21,
84
+ because it is only an anti-*mistake* device: a model that intends to send can
85
+ call both rounds.
86
+
87
+ **Reversed on 2026-09-24, with that limitation accepted.** The token shipped as
88
+ `requireConfirmationWithFallback` plus `confirmationFromEnv`, and the fleet moved
89
+ onto it from `schemaConfirm`. It still cannot make claude.ai stop and ask. What
90
+ it adds over `confirm: true`:
91
+ - a preview call must come first (`confirm: true` can be passed blind on the
92
+ first call);
93
+ - the approval is bound to the exact arguments and to a fresh read of the target,
94
+ so a draft or event edited in between is refused;
95
+ - it is single-use.
96
+
97
+ `MCP_CONFIRM_MODE=ask-user` (the default) *instructs* the model to get the user's
98
+ approval in chat. That is an instruction, not a gate. A client that declares
99
+ elicitation still gets the real prompt — unless `MCP_CONFIRM_ELICITATION=off`.
100
+
101
+ ### Declares elicitation, never shows it: opencode 2.0.x
102
+
103
+ Measured 2026-10-06 on opencode 2.0.11 and 2.0.24 (stdio, `2025-11-25`): it
104
+ declares `elicitation: {form: {applyDefaults: true}, url: {}}` and receives the
105
+ `elicitation/create`, but files the prompt under the hard-coded session
106
+ `"global"` instead of the chat session that made the call (which it does send,
107
+ as `_meta["ai.opencode/sessionID"]` on `tools/call`). No view renders it, so a
108
+ gated tool hangs indefinitely in the TUI; `opencode run` cancels it at once
109
+ (`cancelled: true`). Capability declaration cannot reveal this, and naming the
110
+ client in code would outlive the fix, so it is an operator switch:
111
+ `MCP_CONFIRM_ELICITATION=off` in that server's opencode `env` puts it on the
112
+ token rail.
113
+
114
+ ### The canonical failure string
115
+
116
+ ```json
117
+ {"error": "Error occurred during tool execution", "request_id": "..."}
118
+ ```
119
+
120
+ That is what a `requireConfirmation`-guarded tool looks like on a client with
121
+ no elicitation. Reproduced **verbatim** by a clean-room probe that does
122
+ nothing but return `inputRequired`, so the string is a symptom of the
123
+ elicitation gate and **not** of whatever the tool was doing. It cost three
124
+ days on `gog_gmail_forward` before that was known.
125
+
126
+ The refusal is raised **after the handler returns**, so a handler cannot
127
+ catch it. It must be predicted — which is what `callerAcceptsFormElicitation`
128
+ in the `caller` module is for.
129
+
130
+ ### Diagnosing it
131
+
132
+ It does not look like a capability problem, which is why it cost days: the
133
+ failure is fast, HTTP 200 at the gateway, and identical whatever the
134
+ arguments — so it invites a hypothesis about the payload. **The tell is the
135
+ duration.** A guarded tool that never ran answers in ~100 ms where the real
136
+ work took seconds:
137
+
138
+ ```sql
139
+ SELECT at, duration_ms, json_extract(meta_json,'$.name')
140
+ FROM usage_events WHERE kind='mcp_request' ORDER BY at DESC LIMIT 20;
141
+ ```
142
+
143
+ **Reproduce with the runner's own client shape** — `new Client(…, {
144
+ versionNegotiation: {mode:'auto'}, inputRequired: {autoFulfill: false} })`
145
+ with NO capabilities, against the **published** package. A local Claude Code
146
+ call will not reproduce it, because Claude Code declares elicitation.
147
+
148
+ ## Spec defaults worth memorising
149
+
150
+ From `schema/2025-06-18/schema.ts`, `ToolAnnotations`:
151
+
152
+ | field | default |
153
+ | --- | --- |
154
+ | `readOnlyHint` | `false` |
155
+ | **`destructiveHint`** | **`true`** |
156
+ | `idempotentHint` | `false` |
157
+ | `openWorldHint` | `true` |
158
+
159
+ **An unannotated tool publishes as destructive.** Measured 2026-09-20 across
160
+ the eight gog servers: 434 tools → 162 read-only, **6 additive**, 266
161
+ destructive, with `gog_gmail_drafts_forward` — whose description begins
162
+ "SAVES ONLY. DOES NOT SEND" — raising the same alarm as `gog_gmail_send`.
163
+
164
+ ## Three SDK gotchas, all SILENT
165
+
166
+ Against `@modelcontextprotocol/server` 2.0.0. None throws, so each costs a
167
+ debugging round:
168
+
169
+ - **The cancellation signal is `ctx.mcpReq.signal`, not `ctx.signal`.** `ctx`
170
+ carries only `sessionId`/`mcpReq`/`http`; a probe reading `ctx.signal`
171
+ concludes the SDK delivers no cancellation at all.
172
+ - **`ctx.mcpReq.notify` takes a notification OBJECT**, not `(method, params)`.
173
+ Handed a string it spreads it character by character and puts
174
+ `{"0":"n","1":"o",…}` on the wire, which the peer rejects — while the server
175
+ sees a clean return.
176
+ - **The progress token is at `_meta`, not `meta`.** Reading `meta` yields
177
+ `undefined`, the notification goes out well-formed but tokenless, and the
178
+ client discards it as *"progress notification for an unknown token"*.
179
+
180
+ Because all three are invisible server-side, **test through a real `Client`**
181
+ (`InMemoryTransport.createLinkedPair`) and assert `client.onerror` stayed
182
+ empty. A unit test on the server cannot tell a delivered notification from a
183
+ dropped one.
184
+
185
+ ## Two traps in measuring this
186
+
187
+ Both produce a confident wrong answer, which is worse than no answer:
188
+
189
+ - **A probe calling `ctx.mcpReq.elicitInput()` measures the protocol ERA, not
190
+ the capability.** That is the deprecated 2025 push API; on a 2026 leg it
191
+ fails with *"server-to-client requests are not available on protocol
192
+ revision 2026-07-28"* regardless of what the client declared, so it reports
193
+ the identical result for every client. Use `inputRequired(...)` — what
194
+ `requireConfirmation` actually returns.
195
+ - **`isError: true` is not a thrown exception.** A harness checking only for a
196
+ throw reads a refusal as success. The first run of our sweep printed
197
+ "elicitation SUCCEEDED" three times.
198
+
199
+ ## Re-running this
200
+
201
+ **Telemetry** — `meta.clientCapabilities`, `protocolVersion`,
202
+ `progressToken`, `session`, `cost` on `usage_events` (mcp-host
203
+ `docs/USAGE.md`):
204
+
205
+ ```sql
206
+ SELECT json_extract(meta_json,'$.clientName') AS client,
207
+ json_extract(meta_json,'$.clientCapabilities') AS caps,
208
+ COUNT(*) AS n
209
+ FROM usage_events
210
+ WHERE kind='mcp_request'
211
+ AND json_extract(meta_json,'$.clientName') IS NOT NULL
212
+ GROUP BY client, caps;
213
+ ```
214
+
215
+ Known gap: **`clientName` is recorded on `initialize` only**, so a
216
+ `tools/call` cannot be attributed to a client.
217
+
218
+ **The probe** — `~/git/mcp-probe`, connect `/c/probe/mcp`, run the four
219
+ prompts in its README. Most groups **self-report**: the answer is which tool
220
+ the model calls next, so it lands in `usage_events.meta.name` and needs
221
+ nobody watching a screen. Tool *arguments* are never the channel — they are
222
+ not recorded, and must not be.
223
+
224
+ ## What this file implies for building
225
+
226
+ - **Annotate every tool** — silence publishes as destructive. Do it because
227
+ the annotations are then truthful and other clients may honour them, **not**
228
+ as a safety layer on claude.ai.
229
+ - **Write descriptions for search**, not for a reader who already found the
230
+ tool.
231
+ - **Honour cancellation** — it is the one advanced feature claude.ai actually
232
+ uses. `cancel` module.
233
+ - **Do not emit** `structuredContent`, `audio` or `resource_link` for
234
+ claude.ai. Nothing consumes them and the first costs bytes.
235
+ - **Do not build on elicitation** for a hosted connector without checking
236
+ `callerAcceptsFormElicitation(ctx)` first.