@george43g/mcp-kit 0.1.0 → 2.0.0

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.
package/README.md CHANGED
@@ -85,8 +85,9 @@ const dispatch = buildDispatcher({
85
85
  - A gated tool's response is **identical to an unknown tool name**. A distinct
86
86
  "this tool is disabled" error confirms the tool exists, which is what the gate
87
87
  is for.
88
- - **Omit it and nothing changes** — dev-only tools stay callable, which is what
89
- this dispatcher did before the option existed.
88
+ - **Omit it with a `devOnly` tool registered and `buildDispatcher` THROWS**, at
89
+ construction, naming the offending tools. See *Upgrading to 1.0.0* below.
90
+ - A registry with **no** `devOnly` tools never needs it and is unaffected.
90
91
 
91
92
  ## Transports
92
93
 
@@ -100,6 +101,117 @@ token is caller-supplied or read from `MCP_HTTP_TOKEN`.
100
101
 
101
102
  ## Untrusted content
102
103
 
103
- `sanitize()` strips control characters and bounds length. `wrapToolError()` and
104
- the UUID-gated helpers in `prompt-injection.ts` fence tool output so a tool's
105
- own error text cannot impersonate the host.
104
+ `sanitize()` strips control characters and bounds length (default 4096 — sized
105
+ for a snippet). `wrapToolError()` and the UUID-gated helpers in
106
+ `prompt-injection.ts` fence tool output so a tool's own error text cannot
107
+ impersonate the host.
108
+
109
+ For **large payloads** — page text, a file, a transcript — use
110
+ `sanitizeContent()` instead:
111
+
112
+ ```ts
113
+ import { sanitizeContent, CONTENT_BUDGET } from "@george43g/mcp-kit";
114
+
115
+ sanitizeContent(pageText); // 1 MiB budget
116
+ sanitizeContent(pageText, 64_000); // or your own
117
+ ```
118
+
119
+ Three differences from `sanitize()`, each deliberate:
120
+
121
+ | | `sanitize` | `sanitizeContent` |
122
+ |---|---|---|
123
+ | null/undefined in | returns `null` | returns `""` — no guard at every call site |
124
+ | default budget | 4096 | `CONTENT_BUDGET` = 1 MiB |
125
+ | truncation marker | `…` | `…[truncated]` |
126
+
127
+ The marker matters more than it looks: a **silently** shortened document is
128
+ indistinguishable from a document that really ended there, and a model reading it
129
+ will answer confidently from the fragment it received.
130
+
131
+ ## Upgrading to 1.0.0
132
+
133
+ **One breaking change, and it affects you only if you register `devOnly` tools.**
134
+
135
+ `buildDispatcher` now throws at construction when the registry contains a
136
+ `devOnly` tool and no `devOnlyEnabled` predicate was passed:
137
+
138
+ ```
139
+ buildDispatcher: 1 devOnly tool(s) registered with no devOnlyEnabled predicate: get_logs.
140
+ A devOnly tool with no predicate would be hidden from tools/list and still callable by name.
141
+ Fix: pass devOnlyEnabled to buildDispatcher, e.g. `devOnlyEnabled: () => envBool("MCP_DEV", false)`.
142
+ Or, if these tools are not meant to be gated at all, drop `devOnly` from their definitions.
143
+ ```
144
+
145
+ **Migration is one line** — pass the predicate, as shown in *Dev-only tools*
146
+ above. If you have no `devOnly` tools, there is nothing to do.
147
+
148
+ ### Upgrading to 2.0.0 — declare `@george43g/robustness` yourself
149
+
150
+ **`robustness` moved from `dependencies` to `peerDependencies`.** If your app
151
+ already depends on `@george43g/robustness` directly — as every known consumer
152
+ does, and as the generated template does — **there is nothing to do**. If it does
153
+ not, add it.
154
+
155
+ ```jsonc
156
+ // your package.json
157
+ "dependencies": {
158
+ "@george43g/robustness": ">=0.13.0 <1" // or a caret; any 0.x satisfies the peer
159
+ }
160
+ ```
161
+
162
+ **What this fixes, and it is not cosmetic.** As a plain dependency with a
163
+ *floor*, mcp-kit could resolve its **own second copy** of robustness whenever the
164
+ app's range and mcp-kit's stopped overlapping. The logger keeps prefix and file
165
+ state at module scope, so two copies means **two independent loggers**:
166
+ `setLogFilePrefix` in your app cannot reach the instance mcp-kit's `perf()` spans
167
+ write through, and dispatch spans silently land in the shared `$TMPDIR/mcp/`
168
+ bucket no matter how correctly your app brands itself.
169
+
170
+ That was live in **1.0.0**, whose floor was `>=0.12.0 <1`: an app on `^0.11.0`
171
+ that bumped mcp-kit alone got exactly this split. **2.0.0 removes the failure
172
+ mode by construction** — a peer is supplied by the consumer, so there is only
173
+ ever one instance. The peer range is deliberately wide (`>=0.11.0 <1`, every
174
+ symbol mcp-kit imports exists at 0.11.0), so no consumer is forced to move.
175
+
176
+ `tui-kit` has always declared robustness this way; mcp-kit was the outlier.
177
+
178
+ Verify with the one line that settles it:
179
+
180
+ ```sh
181
+ grep -oE "@george43g/robustness@[0-9.]+" pnpm-lock.yaml | sort -u
182
+ ```
183
+
184
+ One line is correct. Two means something still resolves its own copy.
185
+
186
+ **The generalisable lesson:** a plain dependency with a *wide* range behaves like
187
+ a peer — right up until the ranges stop overlapping. Two individually safe
188
+ changes (raising a floor, cutting a major) were jointly hazardous. Found by
189
+ `up-bank-mcp`.
190
+ Found by `up-bank-mcp` as range arithmetic over the published manifests.
191
+ **Not yet observed in a resolved tree by anyone** — treat the ordering as cheap
192
+ insurance rather than a reproduction.
193
+
194
+ **Why a throw and not a flipped default.** Before 1.0.0, omitting the predicate
195
+ left dev-only tools *callable* — hidden from `tools/list` and answering by name.
196
+ Two consumers hit that independently, and one measured a dev-only log reader
197
+ returning a real payload with the dev flag unset.
198
+
199
+ Defaulting to fail-closed would have fixed the symptom while leaving the real
200
+ question open: does `devOnly` mean *hidden from the listing* or *not callable*? A
201
+ default picks one silently, and the next reader inherits the same ambiguity.
202
+ Throwing makes the ambiguous state **unrepresentable** — you cannot register a
203
+ `devOnly` tool without saying when it is enabled — so `devOnly` genuinely is a
204
+ gate, and the name stops lying without a rename.
205
+
206
+ The failure is now loud, at startup, with a one-line fix, instead of silent and
207
+ at runtime.
208
+
209
+ **Also in 1.0.0, both additive:** `sanitizeContent()` / `CONTENT_BUDGET` (above),
210
+ and `dispatch_error` log records now have the home-directory prefix replaced with
211
+ `~` and are length-bounded. Stack frames always carry absolute paths, which
212
+ contain the username, and the redactor in `@george43g/robustness` has no
213
+ filesystem-path rule at any version — so an unmodified stack sent to a log
214
+ collector carried an identifier nothing downstream would strip. This removes that
215
+ one category; it does **not** make `err.message` safe. A throw that interpolates a
216
+ URL or an account number still logs it verbatim, and no kit-side guard can know
217
+ which of those your errors carry. Check your own throws.
@@ -42,8 +42,28 @@ export interface BuildDispatcherOptions {
42
42
  * Whether `devOnly` tools are callable. Evaluated PER DISPATCH, not once at
43
43
  * construction, so flipping the env in a test takes effect immediately.
44
44
  *
45
- * Omitted, dev-only tools stay callable — which is what this dispatcher did
46
- * before the option existed, so adding it changes nothing until you pass it.
45
+ * REQUIRED IF — AND ONLY IF — the registry contains a `devOnly` tool. Omit it
46
+ * with such a tool registered and `buildDispatcher` **throws**, naming the
47
+ * offending tools. A registry with no `devOnly` tools never needs it and is
48
+ * completely unaffected.
49
+ *
50
+ * Why a throw rather than a default, either way (1.0.0):
51
+ *
52
+ * Until 1.0.0 this was optional and omitting it left dev-only tools
53
+ * **callable** — the gate did nothing unless the caller remembered it, which
54
+ * is the failure mode `devOnly` exists to prevent. Two consumers independently
55
+ * hit it, and one measured a dev-only log reader answering with a real payload
56
+ * in a repo fronting a bank account.
57
+ *
58
+ * Flipping the default to fail-closed was the obvious fix and is NOT what this
59
+ * does, because it leaves the real question unanswered: does `devOnly` mean
60
+ * "hidden from the listing" or "not callable"? A default picks one silently.
61
+ * Throwing makes the ambiguous state **unrepresentable** — you cannot have a
62
+ * `devOnly` tool without saying when it is enabled — so `devOnly` genuinely is
63
+ * a gate and the name stops lying, with no rename needed.
64
+ *
65
+ * The failure is loud, at construction, with a one-line fix, instead of silent
66
+ * and at runtime.
47
67
  *
48
68
  * The gap it closes: `devOnly` was honoured only by `toMcpTools()`, so the
49
69
  * tool was hidden from `tools/list` and still executed if you named it
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAaH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAErE,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,YAAY,CAAC;IACvB,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,sEAAsE;IACtE,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,MAAM,CAAC;IAC3B;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;CAChC;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;AAUlG,wBAAgB,eAAe,CAAC,IAAI,EAAE,sBAAsB,GAAG,QAAQ,CA0FtE"}
1
+ {"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAgCrE,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,kBAAkB;IACjC,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,YAAY,CAAC;IACvB,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,sEAAsE;IACtE,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,MAAM,CAAC;IAC3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC;CAChC;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;AAUlG,wBAAgB,eAAe,CAAC,IAAI,EAAE,sBAAsB,GAAG,QAAQ,CA6GtE"}
package/dist/dispatch.js CHANGED
@@ -17,8 +17,40 @@
17
17
  * The exported `buildDispatcher` returns a function whose shape matches the
18
18
  * SDK's `CallToolRequest` handler.
19
19
  */
20
+ import { homedir } from "node:os";
20
21
  import { envNum, error as logError, warn as logWarn, noteActivity, perf, ToolTimeoutError, withTimeout, } from "@george43g/robustness";
21
22
  import { wrapToolError } from "./prompt-injection.js";
23
+ import { sanitize } from "./sanitize.js";
24
+ /**
25
+ * Replace the absolute home-directory prefix with `~` in anything headed for a
26
+ * log sink.
27
+ *
28
+ * WHY THIS EXISTS, and why redaction does not already cover it.
29
+ *
30
+ * `err.stack` contains absolute paths ALWAYS — every frame carries one — and on
31
+ * a normal machine every one of them starts with the user's home directory,
32
+ * which contains their username. `@george43g/robustness`'s redactor has exactly
33
+ * three rules at every published version: phone numbers, secret-shaped tokens,
34
+ * and emails (opt-in, default off). **There is no filesystem-path rule, no URL
35
+ * rule, and no free-text rule.** So an unmodified stack shipped to a log
36
+ * collector carries an identifier that nothing downstream will strip.
37
+ *
38
+ * This is cheap and lossless for debugging: `~/repos/foo/src/bar.ts:12` locates
39
+ * the frame exactly as well as the absolute path did.
40
+ *
41
+ * SCOPE, stated so nobody reads more into it than is there: this removes ONE
42
+ * category — the home prefix. It does not make `err.message` safe. A throw that
43
+ * interpolates a URL, an account number or a row of user data still logs it
44
+ * verbatim, and no kit-side guard can know which of those a consumer's errors
45
+ * carry. That check belongs in each consumer: assert your own throws do not
46
+ * interpolate sensitive values.
47
+ */
48
+ function scrubHome(text) {
49
+ if (text === null)
50
+ return undefined;
51
+ const home = homedir();
52
+ return home.length > 1 ? text.split(home).join("~") : text;
53
+ }
22
54
  function defaultTimeoutMs() {
23
55
  return envNum("MCP_TOOL_TIMEOUT_DEFAULT_MS", 30_000);
24
56
  }
@@ -26,6 +58,19 @@ function formatZodError(err) {
26
58
  return err.errors.map((e) => ` - ${e.path.join(".") || "(root)"}: ${e.message}`).join("\n");
27
59
  }
28
60
  export function buildDispatcher(opts) {
61
+ // Make the ambiguous state unrepresentable, at construction, before a single
62
+ // request can be served. See `devOnlyEnabled` above for why this is a throw
63
+ // rather than a default in either direction.
64
+ if (opts.devOnlyEnabled === undefined) {
65
+ const ungated = opts.registry.tools.filter((d) => d.devOnly === true).map((d) => d.name);
66
+ if (ungated.length > 0) {
67
+ throw new Error(`buildDispatcher: ${ungated.length} devOnly tool(s) registered with no devOnlyEnabled predicate: ` +
68
+ `${ungated.join(", ")}.\n` +
69
+ " A devOnly tool with no predicate would be hidden from tools/list and still callable by name.\n" +
70
+ ' Fix: pass devOnlyEnabled to buildDispatcher, e.g. `devOnlyEnabled: () => envBool("MCP_DEV", false)`.\n' +
71
+ " Or, if these tools are not meant to be gated at all, drop `devOnly` from their definitions.");
72
+ }
73
+ }
29
74
  const fallback = defaultTimeoutMs();
30
75
  return async (name, args, signal) => {
31
76
  noteActivity();
@@ -34,7 +79,11 @@ export function buildDispatcher(opts) {
34
79
  // A dev-only tool that is not enabled must be INDISTINGUISHABLE from one
35
80
  // that does not exist. A distinct "disabled" error confirms the tool is
36
81
  // there, which is the thing a gate exists to avoid.
37
- const devGated = def?.devOnly === true && opts.devOnlyEnabled !== undefined ? !opts.devOnlyEnabled() : false;
82
+ // Fail CLOSED in the residual case. The construction check above means a
83
+ // devOnly tool always has a predicate — unless a registry gained one after
84
+ // construction, which the readonly type discourages but does not prevent.
85
+ // If that ever happens the tool is gated, not exposed.
86
+ const devGated = def?.devOnly === true && !(opts.devOnlyEnabled?.() ?? false);
38
87
  if (!def || devGated) {
39
88
  opts.onError?.(name, new Error("unknown_tool"));
40
89
  return {
@@ -94,8 +143,8 @@ export function buildDispatcher(opts) {
94
143
  span.end({ error: true });
95
144
  opts.onError?.(name, err);
96
145
  logError(`dispatch_error: ${name}`, {
97
- message: err?.message,
98
- stack: err?.stack,
146
+ message: scrubHome(sanitize(err?.message, 1024)),
147
+ stack: scrubHome(sanitize(err?.stack, 4096)),
99
148
  });
100
149
  const message = err instanceof ToolTimeoutError
101
150
  ? `Timed out after ${err.timeoutMs}ms. The server has unblocked; the underlying work may still be running.`
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { buildDispatcher } from "./dispatch.js";
3
3
  export { type WrapInstructionsResult, wrapInstructions, wrapToolError, wrapUntrusted, } from "./prompt-injection.js";
4
4
  export type { BuildResourcesHandlerOptions, ResourceContent, ResourceHandlers, ResourceListEntry, ResourcesProvider, ResourceTemplate, } from "./resources.js";
5
5
  export { buildResourcesHandler } from "./resources.js";
6
- export { sanitize } from "./sanitize.js";
6
+ export { CONTENT_BUDGET, sanitize, sanitizeContent } from "./sanitize.js";
7
7
  export type { AnyToolDefinition, ContentBlock, ToolDefinition, ToolRegistry, } from "./tool-registry.js";
8
8
  export { makeRegistry } from "./tool-registry.js";
9
9
  export type { HttpServerHandle, HttpServerOptions } from "./transports/http.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,sBAAsB,EACtB,QAAQ,EACR,kBAAkB,EAClB,UAAU,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EACL,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,4BAA4B,EAC5B,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,YAAY,EACV,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,YAAY,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,sBAAsB,EACtB,QAAQ,EACR,kBAAkB,EAClB,UAAU,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EACL,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,4BAA4B,EAC5B,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC1E,YAAY,EACV,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAChF,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,YAAY,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { buildDispatcher } from "./dispatch.js";
2
2
  export { wrapInstructions, wrapToolError, wrapUntrusted, } from "./prompt-injection.js";
3
3
  export { buildResourcesHandler } from "./resources.js";
4
- export { sanitize } from "./sanitize.js";
4
+ export { CONTENT_BUDGET, sanitize, sanitizeContent } from "./sanitize.js";
5
5
  export { makeRegistry } from "./tool-registry.js";
6
6
  export { startHttpServer } from "./transports/http.js";
7
7
  export { startStdio } from "./transports/stdio.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAEL,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAS/B,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAOzC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAEL,gBAAgB,EAChB,aAAa,EACb,aAAa,GACd,MAAM,uBAAuB,CAAC;AAS/B,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAO1E,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC"}
@@ -15,4 +15,34 @@
15
15
  * sourced from a system you don't control.
16
16
  */
17
17
  export declare function sanitize(text: string | null | undefined, maxLength?: number): string | null;
18
+ /**
19
+ * Default budget for {@link sanitizeContent} — 1 MiB.
20
+ *
21
+ * Sized for *documents*, not snippets: page text, file contents, a transcript.
22
+ * `sanitize`'s 4096 default is right for a chat line and destroys anything
23
+ * larger, which is why a second entry point exists rather than a different
24
+ * default on the same one.
25
+ */
26
+ export declare const CONTENT_BUDGET = 1048576;
27
+ /**
28
+ * Sanitize a large untrusted CONTENT payload — page text, a file, a transcript.
29
+ *
30
+ * Same escape/control-character handling as {@link sanitize}. Three deliberate
31
+ * differences, each of which is why this could not just be `sanitize(x, 1e6)`:
32
+ *
33
+ * 1. **Never returns `null`.** `null`/`undefined` in yields `""` out, so callers
34
+ * can concatenate and measure without a guard at every site. `sanitize`
35
+ * returning `null` is right for "was there a message?"; it is wrong for
36
+ * "render this body".
37
+ * 2. **A 1 MiB budget** rather than 4096 — see {@link CONTENT_BUDGET}.
38
+ * 3. **Truncation is ANNOUNCED**, with `…[truncated]` rather than a bare `…`.
39
+ * A silently shortened document is indistinguishable from a document that
40
+ * really ended there, and a model reading it will confidently answer from the
41
+ * part it received. The marker is the whole point.
42
+ *
43
+ * Lifted from browser-tab-mcp, which carried it in a vendored copy of this
44
+ * package and asked for it upstream when the vendoring ended. Semantics are
45
+ * theirs and were deliberately not "improved" during the lift.
46
+ */
47
+ export declare function sanitizeContent(text: string | null | undefined, maxLength?: number): string;
18
48
  //# sourceMappingURL=sanitize.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAaH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,SAAS,SAAO,GAAG,MAAM,GAAG,IAAI,CAQzF"}
1
+ {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAaH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,SAAS,SAAO,GAAG,MAAM,GAAG,IAAI,CAQzF;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,UAAY,CAAC;AAExC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC/B,SAAS,SAAiB,GACzB,MAAM,CAQR"}
package/dist/sanitize.js CHANGED
@@ -30,4 +30,43 @@ export function sanitize(text, maxLength = 4096) {
30
30
  }
31
31
  return sanitized;
32
32
  }
33
+ /**
34
+ * Default budget for {@link sanitizeContent} — 1 MiB.
35
+ *
36
+ * Sized for *documents*, not snippets: page text, file contents, a transcript.
37
+ * `sanitize`'s 4096 default is right for a chat line and destroys anything
38
+ * larger, which is why a second entry point exists rather than a different
39
+ * default on the same one.
40
+ */
41
+ export const CONTENT_BUDGET = 1_048_576;
42
+ /**
43
+ * Sanitize a large untrusted CONTENT payload — page text, a file, a transcript.
44
+ *
45
+ * Same escape/control-character handling as {@link sanitize}. Three deliberate
46
+ * differences, each of which is why this could not just be `sanitize(x, 1e6)`:
47
+ *
48
+ * 1. **Never returns `null`.** `null`/`undefined` in yields `""` out, so callers
49
+ * can concatenate and measure without a guard at every site. `sanitize`
50
+ * returning `null` is right for "was there a message?"; it is wrong for
51
+ * "render this body".
52
+ * 2. **A 1 MiB budget** rather than 4096 — see {@link CONTENT_BUDGET}.
53
+ * 3. **Truncation is ANNOUNCED**, with `…[truncated]` rather than a bare `…`.
54
+ * A silently shortened document is indistinguishable from a document that
55
+ * really ended there, and a model reading it will confidently answer from the
56
+ * part it received. The marker is the whole point.
57
+ *
58
+ * Lifted from browser-tab-mcp, which carried it in a vendored copy of this
59
+ * package and asked for it upstream when the vendoring ended. Semantics are
60
+ * theirs and were deliberately not "improved" during the lift.
61
+ */
62
+ export function sanitizeContent(text, maxLength = CONTENT_BUDGET) {
63
+ if (text == null)
64
+ return "";
65
+ let sanitized = text.replace(ANSI_REGEX, "");
66
+ sanitized = sanitized.replace(CONTROL_CHAR_REGEX, "�");
67
+ if (sanitized.length > maxLength) {
68
+ sanitized = `${sanitized.slice(0, maxLength)}…[truncated]`;
69
+ }
70
+ return sanitized;
71
+ }
33
72
  //# sourceMappingURL=sanitize.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,UAAU,GAAG,IAAI,MAAM,CAC3B;IACE,8HAA8H;IAC9H,0DAA0D;CAC3D,CAAC,IAAI,CAAC,GAAG,CAAC,EACX,GAAG,CACJ,CAAC;AAEF,+EAA+E;AAC/E,MAAM,kBAAkB,GAAG,+BAA+B,CAAC;AAE3D,MAAM,UAAU,QAAQ,CAAC,IAA+B,EAAE,SAAS,GAAG,IAAI;IACxE,IAAI,IAAI,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC7C,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;IACvD,IAAI,SAAS,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;QACjC,SAAS,GAAG,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,GAAG,CAAC;IACtD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../src/sanitize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,UAAU,GAAG,IAAI,MAAM,CAC3B;IACE,8HAA8H;IAC9H,0DAA0D;CAC3D,CAAC,IAAI,CAAC,GAAG,CAAC,EACX,GAAG,CACJ,CAAC;AAEF,+EAA+E;AAC/E,MAAM,kBAAkB,GAAG,+BAA+B,CAAC;AAE3D,MAAM,UAAU,QAAQ,CAAC,IAA+B,EAAE,SAAS,GAAG,IAAI;IACxE,IAAI,IAAI,IAAI,IAAI;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC7C,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;IACvD,IAAI,SAAS,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;QACjC,SAAS,GAAG,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,GAAG,CAAC;IACtD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,SAAS,CAAC;AAExC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,eAAe,CAC7B,IAA+B,EAC/B,SAAS,GAAG,cAAc;IAE1B,IAAI,IAAI,IAAI,IAAI;QAAE,OAAO,EAAE,CAAC;IAC5B,IAAI,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IAC7C,SAAS,GAAG,SAAS,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;IACvD,IAAI,SAAS,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;QACjC,SAAS,GAAG,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,cAAc,CAAC;IAC7D,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@george43g/mcp-kit",
3
- "version": "0.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "MCP server building blocks: tool registry types, dispatcher with timeout/perf/abort/error-wrap baked in, stdio + Streamable HTTP transports, sanitize() for user content, UUID-gated prompt-injection helpers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,12 +51,12 @@
51
51
  "test:coverage": "vitest run --coverage"
52
52
  },
53
53
  "dependencies": {
54
- "@george43g/robustness": ">=0.11.0 <1",
55
54
  "@modelcontextprotocol/sdk": "^1.29.0",
56
55
  "zod": "^3.23.0",
57
56
  "zod-to-json-schema": "^3.25.0"
58
57
  },
59
58
  "devDependencies": {
59
+ "@george43g/robustness": "workspace:*",
60
60
  "@george43g/tsconfig": "workspace:*",
61
61
  "@george43g/vitest-config": "workspace:*",
62
62
  "@semantic-release/changelog": "^6.0.3",
@@ -68,5 +68,8 @@
68
68
  "semantic-release-monorepo": "^8.0.2",
69
69
  "typescript": "^5.7.0",
70
70
  "vitest": "^3.2.7"
71
+ },
72
+ "peerDependencies": {
73
+ "@george43g/robustness": ">=0.11.0 <1"
71
74
  }
72
75
  }
package/src/dispatch.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * SDK's `CallToolRequest` handler.
19
19
  */
20
20
 
21
+ import { homedir } from "node:os";
21
22
  import {
22
23
  envNum,
23
24
  error as logError,
@@ -29,8 +30,39 @@ import {
29
30
  } from "@george43g/robustness";
30
31
  import type { ZodError } from "zod";
31
32
  import { wrapToolError } from "./prompt-injection.js";
33
+ import { sanitize } from "./sanitize.js";
32
34
  import type { ContentBlock, ToolRegistry } from "./tool-registry.js";
33
35
 
36
+ /**
37
+ * Replace the absolute home-directory prefix with `~` in anything headed for a
38
+ * log sink.
39
+ *
40
+ * WHY THIS EXISTS, and why redaction does not already cover it.
41
+ *
42
+ * `err.stack` contains absolute paths ALWAYS — every frame carries one — and on
43
+ * a normal machine every one of them starts with the user's home directory,
44
+ * which contains their username. `@george43g/robustness`'s redactor has exactly
45
+ * three rules at every published version: phone numbers, secret-shaped tokens,
46
+ * and emails (opt-in, default off). **There is no filesystem-path rule, no URL
47
+ * rule, and no free-text rule.** So an unmodified stack shipped to a log
48
+ * collector carries an identifier that nothing downstream will strip.
49
+ *
50
+ * This is cheap and lossless for debugging: `~/repos/foo/src/bar.ts:12` locates
51
+ * the frame exactly as well as the absolute path did.
52
+ *
53
+ * SCOPE, stated so nobody reads more into it than is there: this removes ONE
54
+ * category — the home prefix. It does not make `err.message` safe. A throw that
55
+ * interpolates a URL, an account number or a row of user data still logs it
56
+ * verbatim, and no kit-side guard can know which of those a consumer's errors
57
+ * carry. That check belongs in each consumer: assert your own throws do not
58
+ * interpolate sensitive values.
59
+ */
60
+ function scrubHome(text: string | null): string | undefined {
61
+ if (text === null) return undefined;
62
+ const home = homedir();
63
+ return home.length > 1 ? text.split(home).join("~") : text;
64
+ }
65
+
34
66
  export interface ToolResult {
35
67
  content: ContentBlock[];
36
68
  structuredContent?: unknown;
@@ -57,8 +89,28 @@ export interface BuildDispatcherOptions {
57
89
  * Whether `devOnly` tools are callable. Evaluated PER DISPATCH, not once at
58
90
  * construction, so flipping the env in a test takes effect immediately.
59
91
  *
60
- * Omitted, dev-only tools stay callable — which is what this dispatcher did
61
- * before the option existed, so adding it changes nothing until you pass it.
92
+ * REQUIRED IF — AND ONLY IF — the registry contains a `devOnly` tool. Omit it
93
+ * with such a tool registered and `buildDispatcher` **throws**, naming the
94
+ * offending tools. A registry with no `devOnly` tools never needs it and is
95
+ * completely unaffected.
96
+ *
97
+ * Why a throw rather than a default, either way (1.0.0):
98
+ *
99
+ * Until 1.0.0 this was optional and omitting it left dev-only tools
100
+ * **callable** — the gate did nothing unless the caller remembered it, which
101
+ * is the failure mode `devOnly` exists to prevent. Two consumers independently
102
+ * hit it, and one measured a dev-only log reader answering with a real payload
103
+ * in a repo fronting a bank account.
104
+ *
105
+ * Flipping the default to fail-closed was the obvious fix and is NOT what this
106
+ * does, because it leaves the real question unanswered: does `devOnly` mean
107
+ * "hidden from the listing" or "not callable"? A default picks one silently.
108
+ * Throwing makes the ambiguous state **unrepresentable** — you cannot have a
109
+ * `devOnly` tool without saying when it is enabled — so `devOnly` genuinely is
110
+ * a gate and the name stops lying, with no rename needed.
111
+ *
112
+ * The failure is loud, at construction, with a one-line fix, instead of silent
113
+ * and at runtime.
62
114
  *
63
115
  * The gap it closes: `devOnly` was honoured only by `toMcpTools()`, so the
64
116
  * tool was hidden from `tools/list` and still executed if you named it
@@ -80,6 +132,22 @@ function formatZodError(err: ZodError): string {
80
132
  }
81
133
 
82
134
  export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
135
+ // Make the ambiguous state unrepresentable, at construction, before a single
136
+ // request can be served. See `devOnlyEnabled` above for why this is a throw
137
+ // rather than a default in either direction.
138
+ if (opts.devOnlyEnabled === undefined) {
139
+ const ungated = opts.registry.tools.filter((d) => d.devOnly === true).map((d) => d.name);
140
+ if (ungated.length > 0) {
141
+ throw new Error(
142
+ `buildDispatcher: ${ungated.length} devOnly tool(s) registered with no devOnlyEnabled predicate: ` +
143
+ `${ungated.join(", ")}.\n` +
144
+ " A devOnly tool with no predicate would be hidden from tools/list and still callable by name.\n" +
145
+ ' Fix: pass devOnlyEnabled to buildDispatcher, e.g. `devOnlyEnabled: () => envBool("MCP_DEV", false)`.\n' +
146
+ " Or, if these tools are not meant to be gated at all, drop `devOnly` from their definitions.",
147
+ );
148
+ }
149
+ }
150
+
83
151
  const fallback = defaultTimeoutMs();
84
152
  return async (name, args, signal) => {
85
153
  noteActivity();
@@ -89,8 +157,11 @@ export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
89
157
  // A dev-only tool that is not enabled must be INDISTINGUISHABLE from one
90
158
  // that does not exist. A distinct "disabled" error confirms the tool is
91
159
  // there, which is the thing a gate exists to avoid.
92
- const devGated =
93
- def?.devOnly === true && opts.devOnlyEnabled !== undefined ? !opts.devOnlyEnabled() : false;
160
+ // Fail CLOSED in the residual case. The construction check above means a
161
+ // devOnly tool always has a predicate — unless a registry gained one after
162
+ // construction, which the readonly type discourages but does not prevent.
163
+ // If that ever happens the tool is gated, not exposed.
164
+ const devGated = def?.devOnly === true && !(opts.devOnlyEnabled?.() ?? false);
94
165
 
95
166
  if (!def || devGated) {
96
167
  opts.onError?.(name, new Error("unknown_tool"));
@@ -156,8 +227,8 @@ export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
156
227
  span.end({ error: true });
157
228
  opts.onError?.(name, err);
158
229
  logError(`dispatch_error: ${name}`, {
159
- message: (err as Error)?.message,
160
- stack: (err as Error)?.stack,
230
+ message: scrubHome(sanitize((err as Error)?.message, 1024)),
231
+ stack: scrubHome(sanitize((err as Error)?.stack, 4096)),
161
232
  });
162
233
  const message =
163
234
  err instanceof ToolTimeoutError
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export type {
20
20
  ResourceTemplate,
21
21
  } from "./resources.js";
22
22
  export { buildResourcesHandler } from "./resources.js";
23
- export { sanitize } from "./sanitize.js";
23
+ export { CONTENT_BUDGET, sanitize, sanitizeContent } from "./sanitize.js";
24
24
  export type {
25
25
  AnyToolDefinition,
26
26
  ContentBlock,
package/src/sanitize.ts CHANGED
@@ -35,3 +35,46 @@ export function sanitize(text: string | null | undefined, maxLength = 4096): str
35
35
  }
36
36
  return sanitized;
37
37
  }
38
+
39
+ /**
40
+ * Default budget for {@link sanitizeContent} — 1 MiB.
41
+ *
42
+ * Sized for *documents*, not snippets: page text, file contents, a transcript.
43
+ * `sanitize`'s 4096 default is right for a chat line and destroys anything
44
+ * larger, which is why a second entry point exists rather than a different
45
+ * default on the same one.
46
+ */
47
+ export const CONTENT_BUDGET = 1_048_576;
48
+
49
+ /**
50
+ * Sanitize a large untrusted CONTENT payload — page text, a file, a transcript.
51
+ *
52
+ * Same escape/control-character handling as {@link sanitize}. Three deliberate
53
+ * differences, each of which is why this could not just be `sanitize(x, 1e6)`:
54
+ *
55
+ * 1. **Never returns `null`.** `null`/`undefined` in yields `""` out, so callers
56
+ * can concatenate and measure without a guard at every site. `sanitize`
57
+ * returning `null` is right for "was there a message?"; it is wrong for
58
+ * "render this body".
59
+ * 2. **A 1 MiB budget** rather than 4096 — see {@link CONTENT_BUDGET}.
60
+ * 3. **Truncation is ANNOUNCED**, with `…[truncated]` rather than a bare `…`.
61
+ * A silently shortened document is indistinguishable from a document that
62
+ * really ended there, and a model reading it will confidently answer from the
63
+ * part it received. The marker is the whole point.
64
+ *
65
+ * Lifted from browser-tab-mcp, which carried it in a vendored copy of this
66
+ * package and asked for it upstream when the vendoring ended. Semantics are
67
+ * theirs and were deliberately not "improved" during the lift.
68
+ */
69
+ export function sanitizeContent(
70
+ text: string | null | undefined,
71
+ maxLength = CONTENT_BUDGET,
72
+ ): string {
73
+ if (text == null) return "";
74
+ let sanitized = text.replace(ANSI_REGEX, "");
75
+ sanitized = sanitized.replace(CONTROL_CHAR_REGEX, "�");
76
+ if (sanitized.length > maxLength) {
77
+ sanitized = `${sanitized.slice(0, maxLength)}…[truncated]`;
78
+ }
79
+ return sanitized;
80
+ }