@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.
- package/README.md +505 -26
- package/dist/auth/index.d.ts +25 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +105 -14
- package/dist/auth/index.js.map +1 -1
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +3 -2
- package/dist/config/index.js.map +1 -1
- package/dist/dates/index.d.ts +45 -5
- package/dist/dates/index.d.ts.map +1 -1
- package/dist/dates/index.js +64 -6
- package/dist/dates/index.js.map +1 -1
- package/dist/errors/index.d.ts +121 -7
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +154 -9
- package/dist/errors/index.js.map +1 -1
- package/dist/fs/output.d.ts +25 -8
- package/dist/fs/output.d.ts.map +1 -1
- package/dist/fs/output.js +27 -5
- package/dist/fs/output.js.map +1 -1
- package/dist/graphql/client.d.ts +8 -1
- package/dist/graphql/client.d.ts.map +1 -1
- package/dist/graphql/client.js +11 -3
- package/dist/graphql/client.js.map +1 -1
- package/dist/healthcheck/index.d.ts +11 -9
- package/dist/healthcheck/index.d.ts.map +1 -1
- package/dist/healthcheck/index.js +39 -23
- package/dist/healthcheck/index.js.map +1 -1
- package/dist/http/body-limit.d.ts +37 -0
- package/dist/http/body-limit.d.ts.map +1 -0
- package/dist/http/body-limit.js +86 -0
- package/dist/http/body-limit.js.map +1 -0
- package/dist/http/fetch-bounded.d.ts +91 -0
- package/dist/http/fetch-bounded.d.ts.map +1 -0
- package/dist/http/fetch-bounded.js +138 -0
- package/dist/http/fetch-bounded.js.map +1 -0
- package/dist/http/index.d.ts +231 -6
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +432 -56
- package/dist/http/index.js.map +1 -1
- package/dist/http/response-cache.d.ts +36 -3
- package/dist/http/response-cache.d.ts.map +1 -1
- package/dist/http/response-cache.js +84 -8
- package/dist/http/response-cache.js.map +1 -1
- package/dist/http/throttle.d.ts +37 -4
- package/dist/http/throttle.d.ts.map +1 -1
- package/dist/http/throttle.js +65 -6
- package/dist/http/throttle.js.map +1 -1
- package/dist/http/url-safety.d.ts +142 -0
- package/dist/http/url-safety.d.ts.map +1 -0
- package/dist/http/url-safety.js +211 -0
- package/dist/http/url-safety.js.map +1 -0
- package/dist/netguard/index.d.ts +196 -0
- package/dist/netguard/index.d.ts.map +1 -0
- package/dist/netguard/index.js +365 -0
- package/dist/netguard/index.js.map +1 -0
- package/dist/server/confirm-env.d.ts +17 -8
- package/dist/server/confirm-env.d.ts.map +1 -1
- package/dist/server/confirm-env.js +22 -13
- package/dist/server/confirm-env.js.map +1 -1
- package/dist/server/confirm-write.js +1 -1
- package/dist/server/confirm-write.js.map +1 -1
- package/dist/server/index.d.ts +6 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +24 -9
- package/dist/server/index.js.map +1 -1
- package/dist/session/index.d.ts +6 -0
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +12 -1
- package/dist/session/index.js.map +1 -1
- package/dist/zod/index.d.ts +6 -0
- package/dist/zod/index.d.ts.map +1 -1
- package/dist/zod/index.js +8 -1
- package/dist/zod/index.js.map +1 -1
- package/docs/CLIENT-BEHAVIOUR.md +236 -0
- package/docs/CONFIRM-TOKEN.md +476 -0
- package/docs/MIGRATION-3.md +106 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ re-exports APIs added there — `withDeadline`, `backoffDelayMs`, `BRIDGE_CONCUR
|
|
|
24
24
|
the bridge-error classifier). MCPs on older `@fetchproxy/server` can use the core
|
|
25
25
|
barrel freely; adopt `/fetchproxy` only after bumping to 0.11+.
|
|
26
26
|
|
|
27
|
+
> **Upgrading from 2.x?** 3.0 has three breaking changes: `resolveOutputDir` no longer falls back to the cwd, timed-out writes throw `WriteOutcomeUnknownError`, and `confirmationFromEnv` requires `account` and `args`. See [docs/MIGRATION-3.md](docs/MIGRATION-3.md) (also shipped in the package under `node_modules/@chrischall/mcp-utils/docs/`).
|
|
28
|
+
|
|
27
29
|
## Entry points
|
|
28
30
|
|
|
29
31
|
The core building blocks are re-exported from the package root. Heavier or
|
|
@@ -37,6 +39,7 @@ import light:
|
|
|
37
39
|
| `@chrischall/mcp-utils/fetchproxy` | fetchproxy transport adapter, bot-wall / retry / concurrency helpers |
|
|
38
40
|
| `@chrischall/mcp-utils/healthcheck` | credential-style healthcheck factory (no fetchproxy peer needed) |
|
|
39
41
|
| `@chrischall/mcp-utils/graphql` | GraphQL POST transport + operation-kind lexer (no optional peers) |
|
|
42
|
+
| `@chrischall/mcp-utils/netguard` | SSRF guard for fetching third-party URLs: public-address table, DNS-pinning undici dispatcher, redirect-checked `fetchPublic` (needs `undici`) |
|
|
40
43
|
| `@chrischall/mcp-utils/html` | opt-in HTML scraping helpers (needs `node-html-parser`) |
|
|
41
44
|
| `@chrischall/mcp-utils/scrape` | convenience alias for the zero-dep `scrape` module (also in the core barrel) |
|
|
42
45
|
| `@chrischall/mcp-utils/test` | in-memory test harness for tool registration |
|
|
@@ -117,8 +120,15 @@ Hint: Available: 1 (Bus), 2 (Walker)
|
|
|
117
120
|
|
|
118
121
|
The MCP tool boundary itself surfaces only `message`, so a `hint` — the
|
|
119
122
|
actionable half — used to be dropped even though `wrapToolError` preserved it.
|
|
120
|
-
Anything that is not an `McpToolError`, or has no `hint`,
|
|
121
|
-
so a genuine bug still reads as one
|
|
123
|
+
Anything that is not an `McpToolError`, or has no `hint`, renders as its bare
|
|
124
|
+
message, so a genuine bug still reads as one, but every thrown error's text
|
|
125
|
+
now goes through `redactSecrets` first (the same redaction `errorResult`
|
|
126
|
+
applies, never truncated). Before, a non-`McpToolError` was rethrown and the
|
|
127
|
+
SDK rendered its raw message, so a `TypeError` quoting a signed URL or a
|
|
128
|
+
third-party client error echoing a token reached the caller verbatim. The
|
|
129
|
+
SDK's `UrlElicitationRequiredError` is still rethrown untouched, because the
|
|
130
|
+
client must receive it as a protocol error. Opt out with `surfaceHints: false`,
|
|
131
|
+
which also switches off that redaction.
|
|
122
132
|
`createTestHarness` applies the same wrapper, so a tool's failure text under
|
|
123
133
|
test is the text production returns.
|
|
124
134
|
|
|
@@ -234,14 +244,25 @@ A refused token returns `isError: true` and acts on nothing:
|
|
|
234
244
|
| `TOKEN_INVALID` | tampered, issued for another tool, account or target, or signed with another key |
|
|
235
245
|
|
|
236
246
|
**Fleet env layer.** `confirmationFromEnv({ ...requireConfirmationOptions, tool,
|
|
237
|
-
account
|
|
247
|
+
account, args, confirmToken, subject, instruction?, spent? })` turns three standard
|
|
238
248
|
variables into those options, so every server reads and documents them the same
|
|
239
|
-
way.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
way. Since 3.0, `account` and `args` are **required keys** (see
|
|
250
|
+
[`docs/MIGRATION-3.md`](docs/MIGRATION-3.md)):
|
|
251
|
+
|
|
252
|
+
- `account: string | undefined` — the principal the action runs as, exactly as
|
|
253
|
+
`confirmWrite` requires it. Pass `undefined` explicitly on a single-account
|
|
254
|
+
server; the point is that forgetting it no longer compiles.
|
|
255
|
+
- `args` — the tool's validated arguments (`{}` for a tool with none);
|
|
256
|
+
`undefined` or `null` throws. They bind BOTH rails: the elicitation
|
|
257
|
+
acceptance (`binding` over `{ account, args }`, keyed from
|
|
258
|
+
`MCP_CONFIRM_SECRET`) and the token (which carries the account in its claims
|
|
259
|
+
and commits to `{ payload, args }`, so a `subject()` whose payload covers only
|
|
260
|
+
some arguments cannot authorise different ones).
|
|
261
|
+
|
|
262
|
+
So a token or an acceptance minted for one account or one set of arguments is
|
|
263
|
+
refused for any other. `confirmToken` is dropped from `args` before hashing. A
|
|
264
|
+
`subject()` that returns no `payload` throws rather than binding only the
|
|
265
|
+
target.
|
|
245
266
|
|
|
246
267
|
| variable | default | |
|
|
247
268
|
|---|---|---|
|
|
@@ -263,7 +284,7 @@ in-memory store.
|
|
|
263
284
|
```ts
|
|
264
285
|
const gate = await requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
265
286
|
action: 'thing.delete', message: 'Review and confirm this deletion.', details: { id },
|
|
266
|
-
tool: 'thing_delete',
|
|
287
|
+
tool: 'thing_delete', account: undefined, args, confirmToken,
|
|
267
288
|
subject: () => ({ target: id, payload: { id }, preview: { id } }),
|
|
268
289
|
}));
|
|
269
290
|
if (gate) return gate;
|
|
@@ -516,9 +537,10 @@ and is never touched.
|
|
|
516
537
|
### `errors` — helpful errors
|
|
517
538
|
|
|
518
539
|
`McpToolError` and its subclasses (`SessionNotAuthenticatedError`,
|
|
519
|
-
`BotWallError`, `RateLimitError`, `UnreachableError`, `ModeMismatchError
|
|
520
|
-
plus `createHelpfulError`, `wrapToolError`, `truncateErrorMessage`,
|
|
521
|
-
`redactSecrets`, `maskSecret`, `messageOf`,
|
|
540
|
+
`BotWallError`, `RateLimitError`, `UnreachableError`, `ModeMismatchError`,
|
|
541
|
+
`UpstreamFormatError`), plus `createHelpfulError`, `wrapToolError`, `truncateErrorMessage`,
|
|
542
|
+
`redactSecrets`, `maskSecret`, `messageOf`, `isTimeoutError`, and `errorStatusOf` /
|
|
543
|
+
`errorKindOf` (with the `McpToolErrorKind` type and `MCP_TOOL_ERROR_KINDS` set). `BotWallError` takes an optional
|
|
522
544
|
`{ vendor }` (e.g. `'DataDome'`) woven into the message and exposed as a field;
|
|
523
545
|
`maskSecret(value)` renders a `first8…last4` fingerprint for set-credential
|
|
524
546
|
confirmations (short values are fully hidden). `redactSecrets` scrubs `Bearer`/`Basic` auth
|
|
@@ -526,8 +548,8 @@ headers, `Cookie`/`Set-Cookie` values (cookie names stay visible), JWTs,
|
|
|
526
548
|
well-known API-key shapes (`sk-…`, `ghp_…`, `xox?-…`, `AIza…`, `AKIA…`,
|
|
527
549
|
`whsec_…`), Google OAuth2 access/refresh tokens (`ya29.…` / `1//…` — never
|
|
528
550
|
when welded inside a base64 blob), secret-bearing URL query params (including cookie-style session
|
|
529
|
-
ids such as `sessionid`/`PHPSESSID`/`JSESSIONID`/`sid
|
|
530
|
-
names), and secret JSON values — quoted or numeric — plus the values under
|
|
551
|
+
ids such as `sessionid`/`PHPSESSID`/`JSESSIONID`/`sid`, `x-api-key`-style
|
|
552
|
+
names, and PKCE `code_verifier` / bare `verifier` capabilities), and secret JSON values (including `code_verifier`) — quoted or numeric — plus the values under
|
|
531
553
|
`"cookie"`/`"set-cookie"` JSON keys (names kept); `truncateErrorMessage` applies
|
|
532
554
|
it before truncating, and `errorResult` applies it (without truncating). Every
|
|
533
555
|
pattern is linear in the input (`redos.test.ts` times each against 200 KB
|
|
@@ -569,6 +591,50 @@ try {
|
|
|
569
591
|
Every error carries an optional `hint` — a "here's how to fix it" string the
|
|
570
592
|
tool surface can show the user.
|
|
571
593
|
|
|
594
|
+
**Classify by structure, never by message text.** `McpToolError` also takes an
|
|
595
|
+
optional `status` (the upstream HTTP status) and `kind` — an `McpToolErrorKind`,
|
|
596
|
+
the credential healthcheck's failure arms: `no_credential`,
|
|
597
|
+
`credential_rejected`, `edge_blocked`, `session_expired`,
|
|
598
|
+
`verification_pending`, `timeout`, `http`, `transport`, `too_large`, `unknown`. Seven fleet
|
|
599
|
+
repos used to regex the message instead (`/401|403|forbidden/`,
|
|
600
|
+
`/\b429\b|\b503\b/`, a `/auth|sign/` that matched "assign"), because a
|
|
601
|
+
hint-adding `McpToolError` dropped the status of the `ApiError` it wrapped. The
|
|
602
|
+
library's own throwers set them:
|
|
603
|
+
|
|
604
|
+
| Error | `status` | `kind` |
|
|
605
|
+
| --- | --- | --- |
|
|
606
|
+
| `SessionNotAuthenticatedError` | — | `session_expired` |
|
|
607
|
+
| `RateLimitError` / `RateLimitedError` | 429 | `http` |
|
|
608
|
+
| `UnreachableError` | when given | `http` with a status, else `transport` |
|
|
609
|
+
| `UpstreamFormatError` | when known | — |
|
|
610
|
+
| `UnauthorizedError` | 401 | `credential_rejected` |
|
|
611
|
+
| `RequestTimeoutError` | — | `timeout` |
|
|
612
|
+
| `EdgeBlockedError` | the edge's | `edge_blocked` |
|
|
613
|
+
| `ResponseTooLargeError` | — | `too_large` |
|
|
614
|
+
| `WriteOutcomeUnknownError`, `GraphqlTransportError` | — | `timeout` or `transport` (by `timedOut`) |
|
|
615
|
+
| `OAuth2RefreshError` | the endpoint's | `credential_rejected` for a 4xx other than 408/429, else `http` |
|
|
616
|
+
| `TokenManager`'s "no refresh token is available" | — | `no_credential` |
|
|
617
|
+
| `ApiError` / `UpstreamHttpError` / `GraphqlResponseError` | the response's | — (the status says which; `GraphqlResponseError` takes an optional `kind`) |
|
|
618
|
+
|
|
619
|
+
`errorStatusOf(err)` / `errorKindOf(err)` read them duck-typed off any error
|
|
620
|
+
and a short `cause` chain (the outermost link that declares one wins; a `kind`
|
|
621
|
+
outside the vocabulary is ignored), and `wrapToolError` carries both over to
|
|
622
|
+
the error it returns. Attach them when you wrap:
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
} catch (err) {
|
|
626
|
+
throw new McpToolError('Viator rejected the API key.', {
|
|
627
|
+
hint: 'Check VIATOR_API_KEY.',
|
|
628
|
+
status: errorStatusOf(err),
|
|
629
|
+
kind: 'credential_rejected',
|
|
630
|
+
cause: err,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
// …and branch on structure:
|
|
634
|
+
if (errorKindOf(err) === 'credential_rejected') reauth();
|
|
635
|
+
if (errorStatusOf(err) === 503) retryLater();
|
|
636
|
+
```
|
|
637
|
+
|
|
572
638
|
### `config` — hardened env/config
|
|
573
639
|
|
|
574
640
|
`readEnvVar`, `requireEnvVar`, `parseBoolEnv`, `readPortEnv`, `readIntEnv`,
|
|
@@ -588,6 +654,16 @@ const port = readPortEnv('MY_WS_PORT', 37149); // placeholder/NaN/out-of-range
|
|
|
588
654
|
const home = expandPath('~/.config/my-mcp');
|
|
589
655
|
```
|
|
590
656
|
|
|
657
|
+
`readEnvVar` trims the value by default. For **secrets** — passwords above
|
|
658
|
+
all — pass `{ trim: false }` (also accepted by `requireEnvVar`): a leading or
|
|
659
|
+
trailing space can be part of the credential, and trimming it silently turns a
|
|
660
|
+
correct password into a rejected one. The unset checks still run on the trimmed
|
|
661
|
+
view, so a blank, `'null'`/`'undefined'` or `${...}` placeholder is still unset.
|
|
662
|
+
|
|
663
|
+
```ts
|
|
664
|
+
const password = requireEnvVar('MY_PASSWORD', { trim: false }); // ' p4ss ' stays ' p4ss '
|
|
665
|
+
```
|
|
666
|
+
|
|
591
667
|
`readPortEnv` parses a TCP port with the same placeholder hardening as
|
|
592
668
|
`readEnvVar`, plus integer + `1..65535` range validation — so an unexpanded
|
|
593
669
|
`${MY_WS_PORT}` or junk falls back to the default instead of handing `NaN` to
|
|
@@ -624,7 +700,11 @@ without re-reading). Pass `readFile` to inject a reader in tests.
|
|
|
624
700
|
|
|
625
701
|
The binary-output kit (hoisted from gemini + flightaware) is the fleet
|
|
626
702
|
convention for tools that generate bytes: `resolveOutputDir(perCall,
|
|
627
|
-
'<SVC>_OUTPUT_DIR')` resolves arg → env →
|
|
703
|
+
'<SVC>_OUTPUT_DIR', { name: '<svc>-mcp' })` resolves arg → env →
|
|
704
|
+
`~/Downloads/<svc>-mcp` (created `0700`) and creates the dir. It **never falls
|
|
705
|
+
back to the cwd** — that is `/` (unwritable) under Claude Desktop/.mcpb and the
|
|
706
|
+
user's repo under Claude Code. Without `name`, the no-arg/no-env case throws a
|
|
707
|
+
config error (`McpToolError`) naming the env var, so pass `name`.
|
|
628
708
|
`writeBinaryOutput({ dir, baseName, base64, mimeType })` writes to a
|
|
629
709
|
**non-overwriting** path (`name.png`, `name-2.png`, …) and returns it.
|
|
630
710
|
Each name is claimed with an exclusive, no-follow create
|
|
@@ -712,13 +792,17 @@ clone of this repo: `node scripts/audit-fs-confinement.mjs ../your-mcp`.
|
|
|
712
792
|
### `http` — bearer API-client kit
|
|
713
793
|
|
|
714
794
|
`createApiClient` plus building blocks: `buildQueryString`, `buildOptionalBody`,
|
|
715
|
-
`formatApiError`, `parseLinkHeader`, `parseCookieJar`, `
|
|
795
|
+
`formatApiError`, `parseLinkHeader`, `parseCookieJar`, `mergeSetCookies`,
|
|
796
|
+
`parseCookieHeader`,
|
|
716
797
|
`runBoundedBatch`, `createThrottle`, `createResponseCache`, `parseRetryAfterMs`,
|
|
798
|
+
`fetchBounded`, the URL-safety atoms `apiPath`, `readOriginEnv`,
|
|
799
|
+
`assertAllowedUrl` and `findPathHazard`,
|
|
717
800
|
`splitHost`, `buildUserAgent`, `parseContentDispositionFilename`, JWT helpers
|
|
718
801
|
(`decodeJwtExp`, `decodeJwtSessionId`, `decodeJwtClaim`, `validateJwtExpiry`),
|
|
719
|
-
`detectEdgeBlock`, and the `ApiError` / `UpstreamHttpError` /
|
|
802
|
+
`detectEdgeBlock`, `parseJsonBody`, and the `ApiError` / `UpstreamHttpError` /
|
|
720
803
|
`EdgeBlockedError` / `UnauthorizedError` / `RateLimitedError` /
|
|
721
|
-
`RequestTimeoutError`
|
|
804
|
+
`RequestTimeoutError` / `WriteOutcomeUnknownError` / `ResponseTooLargeError` /
|
|
805
|
+
`UrlNotAllowedError` / `RedirectRefusedError` classes.
|
|
722
806
|
|
|
723
807
|
`parseContentDispositionFilename(header)` returns the download's filename or
|
|
724
808
|
`undefined`. It prefers RFC 8187 `filename*=` (charset prefix optional; UTF-8
|
|
@@ -740,15 +824,47 @@ const api = createApiClient({
|
|
|
740
824
|
getToken: () => store.currentToken(), // resolved per-request; sync or async
|
|
741
825
|
serviceName: 'Example',
|
|
742
826
|
retry: { count: 1, delayMs: 2000 }, // fleet-wide "retry once after 2s" default
|
|
743
|
-
timeout: 15_000, //
|
|
827
|
+
timeout: 15_000, // default 30 s; 0/false disables
|
|
744
828
|
});
|
|
745
829
|
|
|
746
830
|
const data = await api.get('/v1/things', { query: { page: 2 } });
|
|
747
831
|
```
|
|
748
832
|
|
|
749
|
-
`timeout` (ms) bounds each attempt with an `AbortController
|
|
750
|
-
|
|
751
|
-
|
|
833
|
+
`timeout` (ms) bounds each attempt with an `AbortController`, from the request
|
|
834
|
+
until its body has been read; on expiry it throws `RequestTimeoutError` instead
|
|
835
|
+
of hanging the tool call. A 429 retry gets a fresh timeout. It **defaults to
|
|
836
|
+
30 s** (`DEFAULT_REQUEST_TIMEOUT_MS`, the same budget `createGraphqlClient`
|
|
837
|
+
uses); before 2.16 an omitted `timeout` meant unbounded. Pass a larger value for
|
|
838
|
+
a slow download, or `0` / `false` to disable it. The caller's cancellation
|
|
839
|
+
applies either way.
|
|
840
|
+
|
|
841
|
+
**A write whose outcome is unknown says so.** When a request whose method is
|
|
842
|
+
not safe (anything but `GET` / `HEAD` / `OPTIONS` / `TRACE`) was sent but timed
|
|
843
|
+
out, lost its connection, or broke off while its response body was read, the
|
|
844
|
+
client throws `WriteOutcomeUnknownError` instead of a plain
|
|
845
|
+
`RequestTimeoutError` or `fetch`'s raw `TypeError` — those read as "safe to
|
|
846
|
+
retry", and the model re-sent the email or booking (fleet audit 2026-09,
|
|
847
|
+
cluster 7). It is an `McpToolError` with `outcomeUnknown: true`,
|
|
848
|
+
`retrySafe: false` (so `retryOnceOnTimeout` never replays it), `timedOut` /
|
|
849
|
+
`timeoutMs`, `method`, the original error as `cause`, and the hint *"The write
|
|
850
|
+
may have happened — check before retrying; do not resend blindly."* Name the
|
|
851
|
+
tool that checks with `writeOutcomeHint`. Reads, failures before anything was
|
|
852
|
+
sent (a token that would not mint, a refused path), any HTTP response, and a
|
|
853
|
+
caller's cancellation are unchanged. A request that is safe to repeat although
|
|
854
|
+
it is a POST (a search) passes `idempotent: true`; `writeOutcomeUnknown: false`
|
|
855
|
+
turns the behaviour off for a whole client. `createGraphqlClient` has the same
|
|
856
|
+
rule (`GraphqlTransportError.outcomeUnknown`).
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
try {
|
|
860
|
+
await api.fetchJson('POST', '/messages', { body });
|
|
861
|
+
} catch (err) {
|
|
862
|
+
if (err instanceof WriteOutcomeUnknownError) {
|
|
863
|
+
// Do NOT resend: tell the model to check the sent folder first.
|
|
864
|
+
}
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
```
|
|
752
868
|
|
|
753
869
|
`retry` also accepts `statuses` (e.g. `[429, 503]`), `honorRetryAfter: true`
|
|
754
870
|
(sleep the response's `Retry-After` instead of the fixed `delayMs`, bounded by
|
|
@@ -756,6 +872,130 @@ timeout. Omit it to keep the previous unbounded behavior.
|
|
|
756
872
|
viator / tripadvisor), and the standalone `parseRetryAfterMs(header)` for custom
|
|
757
873
|
clients.
|
|
758
874
|
|
|
875
|
+
#### URL safety — paths, base-URL overrides, links and redirects
|
|
876
|
+
|
|
877
|
+
`createApiClient` keeps every request on `baseUrl`'s origin, and it also
|
|
878
|
+
**refuses a path that URL normalisation would rewrite**: a dot segment
|
|
879
|
+
(`/trails/../admin`, `%2e%2e`, `.%2e`, …), a backslash, a control character
|
|
880
|
+
(U+0000–U+001F or DEL), or a trailing space, anywhere before the `?`. The URL
|
|
881
|
+
parser deletes tab, LF and CR and strips trailing controls and spaces before
|
|
882
|
+
it resolves dot segments, so `.\t.` and a final `.. ` would both become `..`. `encodeURIComponent('..')` is `..`, so an encoded tool argument
|
|
883
|
+
could still walk the path to another endpoint with the credential attached
|
|
884
|
+
(fleet audit 2026-09, cluster 5). The query string is not judged. Build paths
|
|
885
|
+
with the `apiPath` tag, which encodes each value as exactly one segment and
|
|
886
|
+
throws a `TypeError` on an empty, `.` or `..` value:
|
|
887
|
+
|
|
888
|
+
```ts
|
|
889
|
+
import { apiPath } from '@chrischall/mcp-utils';
|
|
890
|
+
|
|
891
|
+
await api.fetchJson('GET', apiPath`/trails/${args.id}/reviews`);
|
|
892
|
+
// args.id = '../admin' → '/trails/..%2Fadmin/reviews' (one segment)
|
|
893
|
+
// args.id = '..' → TypeError before any request
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
`findPathHazard(path)` is the check itself, for clients that build URLs by
|
|
897
|
+
hand.
|
|
898
|
+
|
|
899
|
+
A base-URL override read from the environment goes through `readOriginEnv`,
|
|
900
|
+
which accepts a bare `host[:port]` or an origin URL and returns
|
|
901
|
+
`https://host[:port]`. It refuses, with a `UrlNotAllowedError` that names the
|
|
902
|
+
variable but never its value: a non-http(s) scheme, `http:` (unless
|
|
903
|
+
`requireHttps: false`, or `allowHttpLoopback: true` for localhost), userinfo,
|
|
904
|
+
any path/query/fragment (so `https://https://host` is caught), and a host
|
|
905
|
+
outside `allowHosts`. `default` is checked by the same rules.
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
import { readOriginEnv, assertAllowedUrl } from '@chrischall/mcp-utils';
|
|
909
|
+
|
|
910
|
+
const baseUrl = readOriginEnv('GROUPON_API_URL', {
|
|
911
|
+
default: 'https://api.groupon.com',
|
|
912
|
+
allowHosts: ['api.groupon.com', '*.groupon.com'], // exact, *.suffix, or an anchored RegExp
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// A link from a tool argument or an upstream response, before sending a cookie to it:
|
|
916
|
+
const url = assertAllowedUrl(args.url, { allowHosts: ['www.thumbtack.com'] });
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
`assertAllowedUrl(url, { allowHosts, requireHttps?, allowHttpLoopback? })`
|
|
920
|
+
returns the parsed `URL`, or throws a `UrlNotAllowedError` (with a `reason`
|
|
921
|
+
code) that names only the host, never the path or query.
|
|
922
|
+
|
|
923
|
+
Response size: by default `createApiClient` reads a body of any size. Pass
|
|
924
|
+
`maxResponseBytes` for a client-wide cap, or `maxBytes` on one call (it
|
|
925
|
+
overrides the default, larger or smaller) — e.g. a receipt download. A
|
|
926
|
+
`Content-Length` over the cap is refused before reading; otherwise the body is
|
|
927
|
+
streamed with a running count and cancelled the moment it passes the cap.
|
|
928
|
+
Either way `fetchJson` / `fetchHtml` / `fetchRaw` throw `ResponseTooLargeError`
|
|
929
|
+
(an `McpToolError`, `kind: 'too_large'`, carrying `maxBytes`; it never echoes
|
|
930
|
+
the body). The request timeout still covers the whole read; a write that hits
|
|
931
|
+
the cap is not reported as outcome-unknown (the response arrived); and a non-2xx
|
|
932
|
+
whose error body is over the cap still throws its `ApiError`, body dropped.
|
|
933
|
+
The cap covers every body the client reads, including the 401 / 429 bodies it
|
|
934
|
+
scans for an edge refusal page: over the cap they count as no page, so
|
|
935
|
+
`UnauthorizedError` / `RateLimitedError` (or your hooks' errors) still throw. It
|
|
936
|
+
is the same implementation as `fetchBounded`'s `maxBytes`.
|
|
937
|
+
|
|
938
|
+
```ts
|
|
939
|
+
const api = createApiClient({ baseUrl, getToken, maxResponseBytes: 5 * 1024 * 1024 });
|
|
940
|
+
const receipt = await api.fetchRaw('GET', apiPath`/receipts/${id}`, { maxBytes: 25 * 1024 * 1024 });
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
Redirects: by default `createApiClient` leaves `fetch` to follow them, which
|
|
944
|
+
re-sends the bearer wherever they point. Pass `redirect: 'same-origin'` to have
|
|
945
|
+
the client follow them itself: each `Location` is checked against the base
|
|
946
|
+
origin before anything is re-sent, and a hop to another origin, a scheme
|
|
947
|
+
downgrade or a hop that adds userinfo throws `RedirectRefusedError`.
|
|
948
|
+
`maxRedirects` defaults to 5 (`DEFAULT_MAX_REDIRECTS`) and must be a
|
|
949
|
+
non-negative integer. A 303, or a 301/302
|
|
950
|
+
after a POST, becomes a body-less GET, while 307/308 keep the method and body.
|
|
951
|
+
`'manual'`, `'error'` and `'follow'` are passed straight to `fetch`.
|
|
952
|
+
|
|
953
|
+
```ts
|
|
954
|
+
const api = createApiClient({ baseUrl, getToken, redirect: 'same-origin' });
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
#### `fetchBounded` — for clients that cannot use `createApiClient`
|
|
958
|
+
|
|
959
|
+
Cookie scrapers, multi-host clients, HTML readers and downloads don't fit a
|
|
960
|
+
single-base bearer client, and a bare `fetch` gets none of its protections.
|
|
961
|
+
`fetchBounded` is one call that does:
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
import { fetchBounded } from '@chrischall/mcp-utils';
|
|
965
|
+
|
|
966
|
+
const { status, ok, headers, body } = await fetchBounded(
|
|
967
|
+
'https://files.example.com/report.pdf',
|
|
968
|
+
{ headers: { Cookie: jar.header() } }, // any RequestInit, incl. its own signal
|
|
969
|
+
{ read: 'bytes', maxBytes: 20 * 1024 * 1024, service: 'Example' },
|
|
970
|
+
);
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
- **Timeout** `timeoutMs` (default 30 s, `DEFAULT_REQUEST_TIMEOUT_MS`; `0` /
|
|
974
|
+
`false` disables) runs from the request until the body has been read, raced
|
|
975
|
+
rather than trusted to the stream, and throws `RequestTimeoutError`.
|
|
976
|
+
- **Cancellation**: `init.signal` and the ambient tool-call signal are both
|
|
977
|
+
honoured; a cancel rejects with the caller's own reason, never as a timeout.
|
|
978
|
+
- **Size cap** `maxBytes`: a `Content-Length` over it is refused before
|
|
979
|
+
reading, and a body that grows past it is cancelled mid-stream —
|
|
980
|
+
`ResponseTooLargeError` (`kind: 'too_large'`, the same error
|
|
981
|
+
`createApiClient`'s `maxBytes` throws).
|
|
982
|
+
- **Body** `read`: `'text'` (default), `'json'` (empty → `undefined`;
|
|
983
|
+
unparseable → an `McpToolError` naming the status and content type, never
|
|
984
|
+
echoing the body), `'bytes'` (`Uint8Array`), or `'none'` (cancelled unread,
|
|
985
|
+
for a status check).
|
|
986
|
+
|
|
987
|
+
It does not judge the status — a 404 comes back like a 200, body read — since
|
|
988
|
+
these clients each decide what a non-2xx means. Every failure path cancels
|
|
989
|
+
the body stream so the connection is released.
|
|
990
|
+
|
|
991
|
+
`createThrottle({ minIntervalMs })` serializes calls and spaces their starts
|
|
992
|
+
(a proactive rate limit, e.g. MusicBrainz's 1 req/s). A call that has not
|
|
993
|
+
started yet is **cancellable**: when the ambient tool-call signal (or a
|
|
994
|
+
per-call `throttle(fn, { signal })`) fires while it is queued or sleeping its
|
|
995
|
+
interval, it rejects at once with the signal's reason, `fn` never runs, and
|
|
996
|
+
the slot does not consume the interval. Once `fn` has started, the task owns
|
|
997
|
+
its own cancellation.
|
|
998
|
+
|
|
759
999
|
Request bodies: `body` is JSON (`application/json`, the default); `form`
|
|
760
1000
|
(a `URLSearchParams` or a plain record) is sent form-encoded as
|
|
761
1001
|
`application/x-www-form-urlencoded;charset=UTF-8`; `rawBody` is a string sent
|
|
@@ -800,12 +1040,52 @@ reference data through the long `static` tier via
|
|
|
800
1040
|
`fetchThrough(key, load, 'static')`, and pair the TTLs with `readTtlMsEnv`.
|
|
801
1041
|
Writes are never cached.
|
|
802
1042
|
|
|
1043
|
+
`fetchThrough` single-flights per key: concurrent misses for the same key share
|
|
1044
|
+
one in-flight `load()`, so an agent fanning out parallel tool calls pays for one
|
|
1045
|
+
upstream request, not N. A rejection reaches every waiter and is never cached;
|
|
1046
|
+
the next call reloads. A disabled tier (TTL `0`) still dedups in-flight calls
|
|
1047
|
+
but stores nothing. `clear()` also drops in-flight loads — callers already
|
|
1048
|
+
waiting still settle, but a load started before the clear never writes into the
|
|
1049
|
+
cache. Pass the caller's cancellation signal as the fourth argument so one
|
|
1050
|
+
caller's cancel never fails the others:
|
|
1051
|
+
|
|
1052
|
+
```ts
|
|
1053
|
+
cache.fetchThrough(path, () => api.get(path, { signal }), 'dynamic', { signal });
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
A waiter whose own `signal` aborts rejects with `signal.reason` (the shared load
|
|
1057
|
+
keeps going for everyone else). If the shared load fails after the caller
|
|
1058
|
+
leading it cancelled (its `signal` aborted), every waiter whose signal is still
|
|
1059
|
+
live re-runs its own `load` instead of failing. That works whatever the
|
|
1060
|
+
rejection looks like, including the MCP SDK's string abort reason, which raw
|
|
1061
|
+
`fetch` rethrows as-is and `fetchBounded` wraps in a plain `Error`. A rejection
|
|
1062
|
+
named `AbortError` / `CancelledError` / `CanceledError` is also treated as a
|
|
1063
|
+
cancellation, for loads cancelled by something other than the leader's signal.
|
|
1064
|
+
|
|
1065
|
+
The first caller's `tier` decides how the shared result is stored: a call for
|
|
1066
|
+
`'static'` that joins an in-flight `'dynamic'` load for the same key gets the
|
|
1067
|
+
dynamic TTL (or nothing stored, if that tier is disabled). Use distinct keys
|
|
1068
|
+
when the same request must be cached under different tiers.
|
|
1069
|
+
|
|
803
1070
|
`parseCookieHeader(header)` parses an inbound *request* `Cookie:` header
|
|
804
1071
|
(`name=value; name2=value2`) into a `Record<string, string>` (first `=` splits,
|
|
805
1072
|
so values may contain `=`; last value wins on a duplicate name). It's the
|
|
806
1073
|
counterpart to `parseCookieJar`, which parses *response* `Set-Cookie` headers
|
|
807
1074
|
with their attributes and deletion semantics.
|
|
808
1075
|
|
|
1076
|
+
`mergeSetCookies(jar, setCookie)` applies a response's `Set-Cookie`s to a
|
|
1077
|
+
`Map<string, string>` you own (and persist), in place. A deletion marker
|
|
1078
|
+
(`Max-Age<=0`, an `Expires` before 2000, or an empty value) removes the name;
|
|
1079
|
+
any other entry sets it. It returns `true` only when the jar actually changed,
|
|
1080
|
+
so you persist only then. It takes a `Headers` object, the `getSetCookie()`
|
|
1081
|
+
array, or a comma-joined string (split safely around `Expires` commas):
|
|
1082
|
+
|
|
1083
|
+
```ts
|
|
1084
|
+
import { mergeSetCookies } from '@chrischall/mcp-utils';
|
|
1085
|
+
|
|
1086
|
+
if (mergeSetCookies(this.jar, res.headers)) await this.save();
|
|
1087
|
+
```
|
|
1088
|
+
|
|
809
1089
|
`UpstreamHttpError(status, message)` is a directly-`throw new`-able,
|
|
810
1090
|
status-carrying HTTP error — the manual-throw parallel to `ApiError` (which
|
|
811
1091
|
`createApiClient` throws internally). It `extends ApiError`, so both the
|
|
@@ -836,6 +1116,31 @@ page (or that carries `cf-mitigated`) throws `EdgeBlockedError` rather than
|
|
|
836
1116
|
the API's own answer and its body is not read; any other 401 is still an
|
|
837
1117
|
`UnauthorizedError` exactly as before.
|
|
838
1118
|
|
|
1119
|
+
A 2xx body that is not JSON (an HTML sign-in page or interstitial served with a
|
|
1120
|
+
200) makes `fetchJson` throw `UpstreamFormatError` (an `McpToolError` with a
|
|
1121
|
+
hint) instead of a raw `SyntaxError: Unexpected token '<'`. The message names
|
|
1122
|
+
the service, request, status and content type, never the body; the parser's
|
|
1123
|
+
error is kept as `cause`. A Cloudflare challenge (or `cf-mitigated`) served
|
|
1124
|
+
with a 200 is checked for first and throws `EdgeBlockedError`. Pass
|
|
1125
|
+
`expect: 'object' | 'array'` per request to also reject `null`, a scalar, the
|
|
1126
|
+
other container, an empty body or a 204 (`err.received` says which); without
|
|
1127
|
+
it, any valid JSON is returned and an empty body or 204 still resolves
|
|
1128
|
+
`undefined`. `parseJsonBody(text, { expect, service, method, path, status,
|
|
1129
|
+
headers })` is the same rule for a client that reads the body itself, such as
|
|
1130
|
+
an OAuth token exchange:
|
|
1131
|
+
|
|
1132
|
+
```ts
|
|
1133
|
+
import { UpstreamFormatError, parseJsonBody } from '@chrischall/mcp-utils';
|
|
1134
|
+
|
|
1135
|
+
const me = await api.fetchJson<Me>('GET', '/v1/me', { expect: 'object' });
|
|
1136
|
+
|
|
1137
|
+
const res = await fetch(tokenUrl, { method: 'POST', body: form });
|
|
1138
|
+
const token = parseJsonBody<TokenResponse>(await res.text(), {
|
|
1139
|
+
expect: 'object', service: 'Skylight', method: 'POST', path: '/oauth/token',
|
|
1140
|
+
status: res.status, headers: res.headers,
|
|
1141
|
+
});
|
|
1142
|
+
```
|
|
1143
|
+
|
|
839
1144
|
```ts
|
|
840
1145
|
import { runBoundedBatch } from '@chrischall/mcp-utils';
|
|
841
1146
|
|
|
@@ -882,6 +1187,10 @@ await fetch(url, { signal: currentCallSignal() });
|
|
|
882
1187
|
const done = killOnCancel(child);
|
|
883
1188
|
try { /* … */ } finally { done(); } // the disposer is required
|
|
884
1189
|
|
|
1190
|
+
// `createThrottle` slots and `createOAuth2Refresher` waits honour it too.
|
|
1191
|
+
// For a fetch that cannot go through createApiClient, `fetchBounded` adds a
|
|
1192
|
+
// timeout, a body-read deadline and a byte cap (see the `http` section).
|
|
1193
|
+
|
|
885
1194
|
// For a long loop with no fetch to hang the signal on:
|
|
886
1195
|
for (const page of pages) { throwIfCancelled(); /* … */ }
|
|
887
1196
|
```
|
|
@@ -1018,7 +1327,8 @@ through `reportProgress` before each re-read. Lifted from kiaaccess-mcp's
|
|
|
1018
1327
|
### `dates` — date-format converters
|
|
1019
1328
|
|
|
1020
1329
|
`isoToDmy`, `dmyToIso`, `isoToCompactTimestamp`, `todayIso`, `toIsoDateUtc`,
|
|
1021
|
-
`shiftIsoDate`, `ensureSeconds
|
|
1330
|
+
`shiftIsoDate`, `ensureSeconds`, `resolveUserTimeZone`, `isValidTimeZone`,
|
|
1331
|
+
`USER_TIME_ZONE_ENV`. For upstreams that don't speak
|
|
1022
1332
|
ISO 8601, so a server can keep its surface ISO (`yyyy-MM-dd`) and translate at
|
|
1023
1333
|
the API boundary. Pair with `deepMapStringField` to normalize a date field
|
|
1024
1334
|
across a whole response.
|
|
@@ -1030,6 +1340,25 @@ const apiDate = isoToDmy('2025-08-28'); // '28-08-2025' (request
|
|
|
1030
1340
|
deepMapStringField(payload, 'eventDate', dmyToIso); // '28-08-2025' → '2025-08-28' (response)
|
|
1031
1341
|
```
|
|
1032
1342
|
|
|
1343
|
+
**"Today" is the user's date, and hosted servers run in UTC.** `todayIso()`
|
|
1344
|
+
reads the calendar date in `opts.timeZone` → the fleet-wide **`MCP_USER_TZ`**
|
|
1345
|
+
env var (an IANA zone such as `America/New_York`) → the host's local zone. On
|
|
1346
|
+
a laptop the host zone is the user's own; on mcp-host every child runs in
|
|
1347
|
+
**UTC**, so without `MCP_USER_TZ` "today" rolls over at UTC midnight (7–8 pm
|
|
1348
|
+
US Eastern). Set `MCP_USER_TZ` per registration there, or pass `timeZone`
|
|
1349
|
+
when the zone comes from the account or venue. An invalid `MCP_USER_TZ` is
|
|
1350
|
+
ignored (host zone); an invalid explicit `timeZone` throws a `RangeError`.
|
|
1351
|
+
`resolveUserTimeZone({ timeZone?, env? })` returns the same choice (or
|
|
1352
|
+
`undefined` for "host zone") for your own `Intl` formatting.
|
|
1353
|
+
|
|
1354
|
+
```ts
|
|
1355
|
+
import { todayIso } from '@chrischall/mcp-utils';
|
|
1356
|
+
|
|
1357
|
+
todayIso(); // MCP_USER_TZ, else host-local
|
|
1358
|
+
todayIso({ timeZone: 'America/Los_Angeles' }); // the venue's date
|
|
1359
|
+
todayIso(new Date(2026, 0, 5)); // legacy form: pinned clock (tests)
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1033
1362
|
### `scrape` — SSR JSON-store & page extraction (zero-dep)
|
|
1034
1363
|
|
|
1035
1364
|
`decodeHtmlEntities`, `stripHtml`, `sanitizeJsLiterals`, `matchBalanced`,
|
|
@@ -1113,6 +1442,12 @@ const offset = calculateOffset(page, size);
|
|
|
1113
1442
|
const annotations = toolAnnotations({ readOnly: true });
|
|
1114
1443
|
```
|
|
1115
1444
|
|
|
1445
|
+
`IsoTime` accepts `H:MM` or `HH:MM` (24h) and always **parses to zero-padded
|
|
1446
|
+
`HH:MM`** — `'9:05'` comes out `'09:05'`, the form `normalizeTime` emits — so a
|
|
1447
|
+
validated time compares equal to an upstream's `09:05` slot. It stays a plain
|
|
1448
|
+
`ZodString` (the padding is a `.overwrite()`), so its JSON Schema keeps the
|
|
1449
|
+
`pattern`.
|
|
1450
|
+
|
|
1116
1451
|
`NumericIdString` (`/^\d+$/`) and `SafePathSegment` (rejects `/`, `..`, `?`,
|
|
1117
1452
|
`#`, and whitespace) harden caller-supplied ids that get interpolated into
|
|
1118
1453
|
request paths — defense-in-depth against path traversal and query/fragment
|
|
@@ -1137,6 +1472,12 @@ discard its only live copy.
|
|
|
1137
1472
|
If `onRotate` throws, the exchange is not retried; the refresher keeps the new
|
|
1138
1473
|
token and throws `OAuth2RotationPersistError` (carrying the `result`), which
|
|
1139
1474
|
`TokenManager` surfaces without wiping its store.
|
|
1475
|
+
Each exchange is bounded by `timeout` (default 30 s, body read included; `0` /
|
|
1476
|
+
`false` disables) and throws `RequestTimeoutError` on expiry. A cancelled tool
|
|
1477
|
+
call is released at once — and one already cancelled never starts an exchange
|
|
1478
|
+
— but an exchange already in flight is never aborted for it: the POST may have
|
|
1479
|
+
rotated the refresh token upstream, and it is shared with every coalesced
|
|
1480
|
+
caller, so it finishes (or times out) and `onRotate` still runs.
|
|
1140
1481
|
|
|
1141
1482
|
`createCachedTokenSource({ mint, bufferMs })` caches any minted token until
|
|
1142
1483
|
shortly before expiry with a single-flight mint and an `invalidate()` hook for
|
|
@@ -1425,6 +1766,33 @@ upgraded. The interface is two methods (`load` / `save`, plus an optional
|
|
|
1425
1766
|
`clear`), each allowed to be async, so a backend other than the local filesystem
|
|
1426
1767
|
can be dropped in.
|
|
1427
1768
|
|
|
1769
|
+
#### Credentials at rest — the fleet standard
|
|
1770
|
+
|
|
1771
|
+
`SessionStore`, `createFileStatePersistence`, `createKeyedFileStatePersistence`
|
|
1772
|
+
and every cookie jar a server persists through them are **plaintext credential
|
|
1773
|
+
files**: bearer and refresh tokens, session cookies, whatever the record holds.
|
|
1774
|
+
They are written mode `0600` in a `0700` directory, and that is the whole of
|
|
1775
|
+
their protection. This is accepted on purpose, not an oversight:
|
|
1776
|
+
|
|
1777
|
+
- **Encrypting them would add little.** The key would have to be readable by the
|
|
1778
|
+
same OS user who can already read the file, so anyone who can open the store
|
|
1779
|
+
can also open the key.
|
|
1780
|
+
- **An OS keychain is not portable.** It is macOS-only in practice, and hosted
|
|
1781
|
+
servers (mcp-host) run on Linux, where the data dir under `MCP_DATA_DIR` is the
|
|
1782
|
+
durable home.
|
|
1783
|
+
|
|
1784
|
+
What a caller must never do with a store's contents:
|
|
1785
|
+
|
|
1786
|
+
- **Log them** — not to stderr, not in a debug line, not in a thrown error's
|
|
1787
|
+
message. Route anything that might contain one through `redactSecrets` /
|
|
1788
|
+
`errorResult`.
|
|
1789
|
+
- **Return them in a tool result** — no "session status" tool echoes a token,
|
|
1790
|
+
cookie or raw record back to the model. Report presence, expiry or the
|
|
1791
|
+
account label; never the credential.
|
|
1792
|
+
|
|
1793
|
+
The permissions are the boundary, so keep them: never widen the file or
|
|
1794
|
+
directory mode, and never copy a store somewhere that does not apply them.
|
|
1795
|
+
|
|
1428
1796
|
### `fetchproxy` — transport adapter *(subpath, optional peer)*
|
|
1429
1797
|
|
|
1430
1798
|
```ts
|
|
@@ -1663,8 +2031,14 @@ registerCredentialHealthcheckTool({
|
|
|
1663
2031
|
|
|
1664
2032
|
Arms: `ok`, `no_credential`, `credential_rejected` (401/403),
|
|
1665
2033
|
`edge_blocked`, `session_expired`, `verification_pending`, `timeout`, `http`,
|
|
1666
|
-
`transport`, `unknown` — with the same `classifyThrown` / `hints` hooks as the
|
|
1667
|
-
bridge factory.
|
|
2034
|
+
`transport`, `too_large`, `unknown` — with the same `classifyThrown` / `hints` hooks as the
|
|
2035
|
+
bridge factory. Every failure arm is an `McpToolErrorKind`, so a thrown error
|
|
2036
|
+
that DECLARES a `kind` (see [`errors`](#errors--helpful-errors)) names its arm
|
|
2037
|
+
directly: the order is `classifyThrown`, then `edge_blocked`, then the declared
|
|
2038
|
+
kind, then the status ladder (status read with `errorStatusOf`, so a 401 on
|
|
2039
|
+
a wrapped `cause` counts), then message matching. A declared kind also
|
|
2040
|
+
replaces the `no_credential` fallback for a `resolveCredential` throw — a
|
|
2041
|
+
rejected OAuth2 refresh in the resolver reports `credential_rejected`.
|
|
1668
2042
|
|
|
1669
2043
|
`edge_blocked` is decided BEFORE the status: a CDN/WAF block page answers 403
|
|
1670
2044
|
exactly as a rejecting API does, and reporting it as `credential_rejected`
|
|
@@ -1859,6 +2233,54 @@ message bodies. `extractPlainTextFromHtml` is the older dependency-free regex
|
|
|
1859
2233
|
pass (every tag becomes a space, so `<b>F</b>ree` → `F ree`; a short entity
|
|
1860
2234
|
table); it is unchanged so existing callers' output does not shift.
|
|
1861
2235
|
|
|
2236
|
+
### `netguard` — SSRF guard for third-party URLs *(subpath, optional peer)*
|
|
2237
|
+
|
|
2238
|
+
```ts
|
|
2239
|
+
import { isPublicAddress, createPublicOnlyDispatcher, fetchPublic } from '@chrischall/mcp-utils/netguard';
|
|
2240
|
+
|
|
2241
|
+
// Follow a link somebody else chose (a tool argument, an email tracker, an images_url):
|
|
2242
|
+
const r = await fetchPublic(link, { timeoutMs: 15_000, maxBytes: 10 * 1024 * 1024 });
|
|
2243
|
+
r.status; r.headers; r.body; // Uint8Array; r.url is the final URL, r.hops the redirects followed
|
|
2244
|
+
|
|
2245
|
+
// Stop at the first hop on your own service, without fetching it:
|
|
2246
|
+
const t = await fetchPublic(trackerUrl, { stopBefore: (u) => u.hostname.endsWith('.accessoticketing.com') });
|
|
2247
|
+
if (t.stopped) t.url;
|
|
2248
|
+
```
|
|
2249
|
+
|
|
2250
|
+
Requires the optional `undici` peer (`^7 || ^8`), which is why it is a subpath
|
|
2251
|
+
and never in the root barrel. Consolidates accessoticketing-mcp `netguard.ts`,
|
|
2252
|
+
gemini-mcp `fetch-image.ts` and mcp-host's `isPublicAddress`
|
|
2253
|
+
(chrischall/fleet-audit#1150).
|
|
2254
|
+
|
|
2255
|
+
- **`isPublicAddress(ip)`** — one IPv4 + IPv6 block table: RFC 1918, loopback,
|
|
2256
|
+
link-local / cloud metadata (`169.254.169.254`), CGNAT, TEST-NET and
|
|
2257
|
+
benchmarking, multicast, reserved, ULA (`fc00::/7`, incl. Fly's 6PN),
|
|
2258
|
+
IPv6 link-/site-local, Teredo, documentation, and the IPv4-carrying forms
|
|
2259
|
+
(`::ffff:` mapped, SIIT, NAT64, 6to4) judged by the IPv4 inside them. Fails
|
|
2260
|
+
closed on anything that is not a well-formed address.
|
|
2261
|
+
- **`createPublicOnlyDispatcher({ resolve?, isAllowedAddress?, agent?, connect? })`**
|
|
2262
|
+
— an undici `Agent` whose connector resolves each connection's host itself,
|
|
2263
|
+
refuses it with `UrlNotAllowedError` (reason `'host'`) when the name resolves
|
|
2264
|
+
to nothing or ANY address is not public, and connects to exactly the
|
|
2265
|
+
addresses it checked. No second lookup means no DNS-rebinding window between
|
|
2266
|
+
check and connect. IP-literal hosts are judged as themselves. `resolve` is
|
|
2267
|
+
injectable (tests never touch DNS); `isAllowedAddress` widens the predicate
|
|
2268
|
+
deliberately (a loopback test server).
|
|
2269
|
+
- **`fetchPublic(url, { maxRedirects = 10, timeoutMs, maxBytes, requireHttps, allowHosts, stopBefore, dispatcher, resolve, ...init })`**
|
|
2270
|
+
— undici `fetch` through that dispatcher with manual redirects:
|
|
2271
|
+
`assertAllowedUrl` on the first URL and every hop (https by default), at most
|
|
2272
|
+
`maxRedirects` follow-up requests (`maxRedirects + 1` in all — the limit is
|
|
2273
|
+
exact, never off by one), `authorization`/`cookie`/`proxy-authorization`
|
|
2274
|
+
dropped once a hop leaves the original origin, 303 (and 301/302 after a POST)
|
|
2275
|
+
turned into a body-less GET, one `timeoutMs` deadline across every hop and
|
|
2276
|
+
the body read (`RequestTimeoutError`), and a `maxBytes` cap on the final
|
|
2277
|
+
body (`ResponseTooLargeError`). An unreachable host is an `McpToolError` of
|
|
2278
|
+
kind `'transport'` naming only the host. A dispatcher you pass is used as-is
|
|
2279
|
+
and left open; otherwise one is built per call and destroyed when it settles.
|
|
2280
|
+
|
|
2281
|
+
TLS is still validated against the hostname (SNI comes from the URL), so
|
|
2282
|
+
pinning the address costs no certificate checking.
|
|
2283
|
+
|
|
1862
2284
|
### `test` — in-memory test harness *(subpath)*
|
|
1863
2285
|
|
|
1864
2286
|
```ts
|
|
@@ -1898,6 +2320,63 @@ This repo also hosts composite GitHub Actions the MCP fleet reuses, under
|
|
|
1898
2320
|
- uses: chrischall/workflows/.github/actions/install-mcp-publisher@main
|
|
1899
2321
|
```
|
|
1900
2322
|
|
|
2323
|
+
## Fleet lint scripts
|
|
2324
|
+
|
|
2325
|
+
`scripts/` holds the lints chrischall/workflows' `reusable-mcp-ci.yml` runs
|
|
2326
|
+
against every fleet repo (read from a pinned release tag of this repo; the
|
|
2327
|
+
scripts are not in the npm package, so run them from a clone).
|
|
2328
|
+
|
|
2329
|
+
`node scripts/audit-annotations.mjs <repo>/dist/index.js` serves the built
|
|
2330
|
+
server over stdio, prints each tool's effective class, and **fails** on a
|
|
2331
|
+
boolean `confirm` input or an `--expect <n>` count mismatch. It also prints
|
|
2332
|
+
GitHub `::warning::` lines for three surface checks, which **do not change the
|
|
2333
|
+
exit code unless you pass `--strict`**:
|
|
2334
|
+
|
|
2335
|
+
- **annotations** — a tool that is not `readOnlyHint: true` without an
|
|
2336
|
+
explicit boolean `destructiveHint` (the spec default is `true`, so silence
|
|
2337
|
+
publishes it as destructive), and any tool without an explicit
|
|
2338
|
+
`openWorldHint` (local-only tools declare `openWorld: false`).
|
|
2339
|
+
- **manifest-tools** — when a `manifest.json` sits beside the server's
|
|
2340
|
+
`package.json`: its `tools[]` names vs the served `tools/list`, both
|
|
2341
|
+
directions (`tools_generated: true` limits it to stale entries).
|
|
2342
|
+
- **env** — literal env keys the built code reads (`readEnvVar`,
|
|
2343
|
+
`requireEnvVar`, `parseBoolEnv`, `readPortEnv`, `readIntEnv`,
|
|
2344
|
+
`readTtlMsEnv`, `process.env.X`) vs `manifest.json`
|
|
2345
|
+
(`server.mcp_config.env` + `user_config`), `server.json`
|
|
2346
|
+
(`packages[].environmentVariables`) and `.mcp.json`: undeclared reads, dead
|
|
2347
|
+
declarations, a var marked required that the code only reads optionally, a
|
|
2348
|
+
`user_config` entry nothing passes to the server, and a cwd-relative script
|
|
2349
|
+
path in `.mcp.json` (anchor it with `${CLAUDE_PLUGIN_ROOT}`). `MCP_*` keys
|
|
2350
|
+
(this library's own knobs) and runtime vars (`NODE_ENV`, `HOME`, …) are
|
|
2351
|
+
ignored. A key built at runtime (`` readEnvVar(`${P}_TOKEN`) ``) is invisible.
|
|
2352
|
+
|
|
2353
|
+
The env check reads every `.js`/`.mjs`/`.cjs` under the entry's directory,
|
|
2354
|
+
skipping `node_modules`, dot-directories, `test`/`tests`/`__tests__`,
|
|
2355
|
+
`coverage` and `*.test.*`/`*.spec.*` files. It attributes env reads to the
|
|
2356
|
+
**server's own code**: when the tsc output is there (`dist/index.js` and its
|
|
2357
|
+
imports — any built file other than an esbuild `bundle.js`), reads come from
|
|
2358
|
+
that alone, because a bundle also inlines every dependency and their env reads
|
|
2359
|
+
are not server config (`ws`'s `WS_NO_BUFFER_UTIL`, @fetchproxy/server's
|
|
2360
|
+
`FETCHPROXY_WS_PORT`, debug/mime/depd/readable-stream knobs). Only a
|
|
2361
|
+
bundle-only build is scanned through the bundle, minus a fixed list of those
|
|
2362
|
+
dependency keys (`WS_NO_*`, `FETCHPROXY_*`, `DEBUG_FD`, `DEBUG_MIME`,
|
|
2363
|
+
`NO_DEPRECATION`, `TRACE_DEPRECATION`, `READABLE_STREAM`; `NODE_V8_COVERAGE`
|
|
2364
|
+
is ignored everywhere as a runtime var). A server that reads one of those keys
|
|
2365
|
+
in its own code still has to declare it. The dead-declaration check searches
|
|
2366
|
+
the bundle as well, so a declared knob that only a dependency reads is not
|
|
2367
|
+
called dead. No esbuild `--define` is needed to quieten dependency reads. A file or directory it cannot read
|
|
2368
|
+
(a dangling symlink, `EACCES`) becomes a warning, not a crash, and so does any
|
|
2369
|
+
other failure inside the surface checks: they can never change the exit code
|
|
2370
|
+
without `--strict`.
|
|
2371
|
+
|
|
2372
|
+
These warnings reach fleet PRs only after a release of this repo that carries
|
|
2373
|
+
them ships **and** `MCP_UTILS_LINT_TAG` in chrischall/workflows'
|
|
2374
|
+
`reusable-mcp-ci.yml` is bumped past `v2.7.0`, which is the tag it pins today.
|
|
2375
|
+
Until then, run the script from a clone to see them.
|
|
2376
|
+
|
|
2377
|
+
`node scripts/audit-fs-confinement.mjs <repo>` is the source lint described
|
|
2378
|
+
under [`fs`](#fs--streaming-file-helpers-uploads--binary-output).
|
|
2379
|
+
|
|
1901
2380
|
## Development
|
|
1902
2381
|
|
|
1903
2382
|
```sh
|