@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 +117 -5
- package/dist/dispatch.d.ts +22 -2
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +52 -3
- package/dist/dispatch.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/sanitize.d.ts +30 -0
- package/dist/sanitize.d.ts.map +1 -1
- package/dist/sanitize.js +39 -0
- package/dist/sanitize.js.map +1 -1
- package/package.json +5 -2
- package/src/dispatch.ts +77 -6
- package/src/index.ts +1 -1
- package/src/sanitize.ts +43 -0
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
|
|
89
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
own error text cannot
|
|
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.
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
46
|
-
*
|
|
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
|
package/dist/dispatch.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;
|
|
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
|
-
|
|
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.`
|
package/dist/dispatch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.js","sourceRoot":"","sources":["../src/dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EACL,MAAM,EACN,KAAK,IAAI,QAAQ,EACjB,IAAI,IAAI,OAAO,EACf,YAAY,EACZ,IAAI,EACJ,gBAAgB,EAChB,WAAW,GACZ,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;
|
|
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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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;
|
|
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"}
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -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
|
package/dist/sanitize.d.ts.map
CHANGED
|
@@ -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
|
package/dist/sanitize.js.map
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
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
|
+
}
|