@chrischall/mcp-utils 2.15.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +505 -26
  2. package/dist/auth/index.d.ts +25 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +105 -14
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/config/index.d.ts +13 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +3 -2
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/dates/index.d.ts +45 -5
  11. package/dist/dates/index.d.ts.map +1 -1
  12. package/dist/dates/index.js +64 -6
  13. package/dist/dates/index.js.map +1 -1
  14. package/dist/errors/index.d.ts +121 -7
  15. package/dist/errors/index.d.ts.map +1 -1
  16. package/dist/errors/index.js +154 -9
  17. package/dist/errors/index.js.map +1 -1
  18. package/dist/fs/output.d.ts +25 -8
  19. package/dist/fs/output.d.ts.map +1 -1
  20. package/dist/fs/output.js +27 -5
  21. package/dist/fs/output.js.map +1 -1
  22. package/dist/graphql/client.d.ts +8 -1
  23. package/dist/graphql/client.d.ts.map +1 -1
  24. package/dist/graphql/client.js +11 -3
  25. package/dist/graphql/client.js.map +1 -1
  26. package/dist/healthcheck/index.d.ts +11 -9
  27. package/dist/healthcheck/index.d.ts.map +1 -1
  28. package/dist/healthcheck/index.js +39 -23
  29. package/dist/healthcheck/index.js.map +1 -1
  30. package/dist/http/body-limit.d.ts +37 -0
  31. package/dist/http/body-limit.d.ts.map +1 -0
  32. package/dist/http/body-limit.js +86 -0
  33. package/dist/http/body-limit.js.map +1 -0
  34. package/dist/http/fetch-bounded.d.ts +91 -0
  35. package/dist/http/fetch-bounded.d.ts.map +1 -0
  36. package/dist/http/fetch-bounded.js +138 -0
  37. package/dist/http/fetch-bounded.js.map +1 -0
  38. package/dist/http/index.d.ts +231 -6
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +432 -56
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/response-cache.d.ts +36 -3
  43. package/dist/http/response-cache.d.ts.map +1 -1
  44. package/dist/http/response-cache.js +84 -8
  45. package/dist/http/response-cache.js.map +1 -1
  46. package/dist/http/throttle.d.ts +37 -4
  47. package/dist/http/throttle.d.ts.map +1 -1
  48. package/dist/http/throttle.js +65 -6
  49. package/dist/http/throttle.js.map +1 -1
  50. package/dist/http/url-safety.d.ts +142 -0
  51. package/dist/http/url-safety.d.ts.map +1 -0
  52. package/dist/http/url-safety.js +211 -0
  53. package/dist/http/url-safety.js.map +1 -0
  54. package/dist/netguard/index.d.ts +196 -0
  55. package/dist/netguard/index.d.ts.map +1 -0
  56. package/dist/netguard/index.js +365 -0
  57. package/dist/netguard/index.js.map +1 -0
  58. package/dist/server/confirm-env.d.ts +17 -8
  59. package/dist/server/confirm-env.d.ts.map +1 -1
  60. package/dist/server/confirm-env.js +22 -13
  61. package/dist/server/confirm-env.js.map +1 -1
  62. package/dist/server/confirm-write.js +1 -1
  63. package/dist/server/confirm-write.js.map +1 -1
  64. package/dist/server/index.d.ts +6 -2
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +24 -9
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/session/index.d.ts +6 -0
  69. package/dist/session/index.d.ts.map +1 -1
  70. package/dist/session/index.js +12 -1
  71. package/dist/session/index.js.map +1 -1
  72. package/dist/zod/index.d.ts +6 -0
  73. package/dist/zod/index.d.ts.map +1 -1
  74. package/dist/zod/index.js +8 -1
  75. package/dist/zod/index.js.map +1 -1
  76. package/docs/CLIENT-BEHAVIOUR.md +236 -0
  77. package/docs/CONFIRM-TOKEN.md +476 -0
  78. package/docs/MIGRATION-3.md +106 -0
  79. package/package.json +12 -2
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`, propagates untouched,
121
- so a genuine bug still reads as one. Opt out with `surfaceHints: false`.
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?, confirmToken, subject, args?, instruction?, spent? })` turns three standard
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. Pass `args` (optional but recommended: the tool's validated arguments): it binds BOTH rails to them —
240
- the elicitation acceptance (`binding`, keyed from `MCP_CONFIRM_SECRET`) and the
241
- token (which then commits to `{ payload, args }`, so a `subject()` whose payload
242
- covers only some arguments cannot authorise different ones). `confirmToken` is
243
- dropped from `args` before hashing. A `subject()` that returns no `payload`
244
- throws rather than binding only the target.
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', confirmToken, args,
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`, and `isTimeoutError`. `BotWallError` takes an optional
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` and `x-api-key`-style
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 → cwd (creating the dir),
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`, `parseCookieHeader`,
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` classes.
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, // abort a hung request, throw RequestTimeoutError
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`; on expiry it throws
750
- `RequestTimeoutError` instead of hanging the tool call. A 429 retry gets a fresh
751
- timeout. Omit it to keep the previous unbounded behavior.
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`. For upstreams that don't speak
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