# @chrischall/mcp-utils

[![CI](https://github.com/chrischall/mcp-utils/actions/workflows/ci.yml/badge.svg)](https://github.com/chrischall/mcp-utils/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@chrischall/mcp-utils)](https://www.npmjs.com/package/@chrischall/mcp-utils)
[![license](https://img.shields.io/npm/l/@chrischall/mcp-utils)](LICENSE)

Shared scaffolding for the **chrischall MCP fleet** — the generic MCP glue
hoisted out of ~50 sibling servers so each one no longer reimplements server
bootstrap, tool-result formatting, helpful errors, hardened env/config, a bearer
API-client kit, zod atoms, session registries, a fetchproxy transport adapter,
auth resolver skeletons, an in-memory test harness, and opt-in HTML helpers.

```sh
npm install @chrischall/mcp-utils
```

Peer dependencies: `@modelcontextprotocol/sdk` and `zod`. The `@fetchproxy/server`
and `node-html-parser` peers are **optional** — only needed if you import the
`/fetchproxy` or `/html` subpaths respectively. Their declared range is `*` so a
consumer pinning any version installs cleanly; the real requirement is enforced
at the subpath: **`/fetchproxy` needs `@fetchproxy/server` >= 0.11** (it
re-exports APIs added there — `withDeadline`, `backoffDelayMs`, `BRIDGE_CONCURRENCY`,
the bridge-error classifier). MCPs on older `@fetchproxy/server` can use the core
barrel freely; adopt `/fetchproxy` only after bumping to 0.11+.

## Entry points

The core building blocks are re-exported from the package root. Heavier or
optional-dependency modules are published as **subpath entries** to keep the core
import light:

| Import | Contents |
| --- | --- |
| `@chrischall/mcp-utils` | core barrel: `server` + `response` + `errors` + `config` + `fs` + `http` + `concurrency` + `dates` + `zod` + `auth` + `scrape` |
| `@chrischall/mcp-utils/session` | session registry, session store, state persistence, token manager, cookie-session manager |
| `@chrischall/mcp-utils/fetchproxy` | fetchproxy transport adapter, bot-wall / retry / concurrency helpers |
| `@chrischall/mcp-utils/healthcheck` | credential-style healthcheck factory (no fetchproxy peer needed) |
| `@chrischall/mcp-utils/html` | opt-in HTML scraping helpers (needs `node-html-parser`) |
| `@chrischall/mcp-utils/scrape` | convenience alias for the zero-dep `scrape` module (also in the core barrel) |
| `@chrischall/mcp-utils/test` | in-memory test harness for tool registration |

```ts
import { createMcpServer, textResult, requireEnvVar } from '@chrischall/mcp-utils';
import { createSessionRegistry } from '@chrischall/mcp-utils/session';
import { createFetchproxyTransport } from '@chrischall/mcp-utils/fetchproxy';
```

## Modules

### `server` — bootstrap & lifecycle

`createMcpServer`, `runMcp`, `withGracefulShutdown`, `surfaceToolHints`.

```ts
import { runMcp, textResult } from '@chrischall/mcp-utils';

await runMcp({
  name: 'my-mcp',
  version: '1.0.0',
  register: (server) => {
    server.tool('ping', {}, async () => textResult({ ok: true }));
  },
  // shutdown: { onSignal: () => client.close() },
});
```

`runMcp` wires the server to a stdio transport and installs `SIGINT`/`SIGTERM`
handlers via `withGracefulShutdown`. Use `createMcpServer` directly if you need
the server instance without connecting a transport.

Both render a thrown `McpToolError`'s `hint` into the failing tool's text:

```
no such option 999

Hint: Available: 1 (Bus), 2 (Walker)
```

The MCP tool boundary itself surfaces only `message`, so a `hint` — the
actionable half — used to be dropped even though `wrapToolError` preserved it.
Anything that is not an `McpToolError`, or has no `hint`, propagates untouched,
so a genuine bug still reads as one. Opt out with `surfaceHints: false`.
`createTestHarness` applies the same wrapper, so a tool's failure text under
test is the text production returns.

### `response` — tool-result formatting

`textResult` / `jsonResult` (alias), `rawTextResult`, `imageResult`,
`errorResult`, `flattenJsonApi`, `deepMapStringField`, `pruneUndefined`,
`toArray`.

`pruneUndefined(obj)` shallow-copies an object dropping `undefined`-valued keys
(the compact-projection idiom: skylight's `compact`, viator/alltrails' `prune`);
`toArray(v)` coerces `T | T[] | null | undefined` to `T[]` (the XML→JSON
single-item guard from canvas-parent / infinitecampus).

```ts
import { textResult, errorResult, flattenJsonApi, deepMapStringField } from '@chrischall/mcp-utils';

return textResult({ items });                 // pretty-printed JSON
return errorResult('not found');              // { isError: true }
return textResult(flattenJsonApi(payload));   // collapse JSON:API envelopes

// Rewrite a string field throughout a response (e.g. normalize a date format):
deepMapStringField(payload, 'eventDate', dmyToIso);
```

#### The `view` vocabulary — read tools answer in the cheap shape by default

`VIEWS`, `DEFAULT_VIEW`, `View`, `viewParam`, `resolveView`, `viewResult`,
`minifiedResult`, `projectOrRaw`. See `docs/fleet-conventions.md`
("Response shape") for the convention these implement.

`view: 'compact' | 'full' | 'raw'`, defaulting to **`compact`** — a projection
that has to be requested is one that usually is not.

```ts
import { viewParam, resolveView, viewResult, projectOrRaw } from '@chrischall/mcp-utils';

const VIEWS_HERE = ['compact', 'full'] as const;   // only the rungs you honour

server.registerTool('svc_list_things', {
  inputSchema: {
    view: viewParam(VIEWS_HERE, { note: 'compact omits the upstream `meta` echo.' }),
  },
}, async (args) => {
  const view = resolveView(args.view, VIEWS_HERE);
  const rows = await client.list();
  // Project the ARRAY, so one odd record cannot half-answer, and fall back to
  // the whole payload (warning to stderr) if the upstream shape has drifted.
  const items = view === 'compact'
    ? projectOrRaw(rows, (rs) => rs.map(compactThing), { label: 'svc-mcp', context: 'GET /things' })
    : rows;
  return viewResult(view, { count: rows.length, items });
});
```

`viewParam` refuses a rung list without `compact` (a tool with no cheap answer
has nothing to default to) and refuses a single-rung list (a parameter that
decides nothing). Register only the rungs you honour: `raw` is meaningless
where a record is *assembled* from several endpoints rather than passed through
from one, and a value that silently aliases to another is a lie in the schema.
`raw` means "no projection" — never "no normalisation".

`viewResult` minifies `compact` and `full` and leaves `raw` indented (that rung
exists to be read by a person); `minifiedResult` is the same rule with no view
to hand. Formatting whitespace only — whitespace *inside* a value is content
and is never touched.

### `errors` — helpful errors

`McpToolError` and its subclasses (`SessionNotAuthenticatedError`,
`BotWallError`, `RateLimitError`, `UnreachableError`, `ModeMismatchError`),
plus `createHelpfulError`, `wrapToolError`, `truncateErrorMessage`,
`redactSecrets`, `maskSecret`, and `messageOf`. `BotWallError` takes an optional
`{ vendor }` (e.g. `'DataDome'`) woven into the message and exposed as a field;
`maskSecret(value)` renders a `first8…last4` fingerprint for set-credential
confirmations (short values are fully hidden). `redactSecrets` scrubs `Bearer`/`Basic` auth
headers, `Cookie`/`Set-Cookie` values (cookie names stay visible), JWTs,
well-known API-key shapes (`sk-…`, `ghp_…`, `xox?-…`, `AIza…`, `AKIA…`,
`whsec_…`), and secret-bearing URL query params; `truncateErrorMessage` applies
it before truncating, and `errorResult` applies it (without truncating). This core module has **no runtime dependencies** — the fetchproxy
typed-error hierarchy (`Fetchproxy*Error`), the raw `classifyBridgeError` /
`classifyRowError` re-exports, and the `bridgeErrorInfo` envelope helper live in
the [`/fetchproxy`](#fetchproxy) subpath instead, so
bearer-only MCPs can import the core barrel without installing
`@fetchproxy/server`.

```ts
import { wrapToolError, SessionNotAuthenticatedError } from '@chrischall/mcp-utils';

try {
  if (!token) throw new SessionNotAuthenticatedError({ hint: 'run the login tool first' });
} catch (err) {
  throw wrapToolError('my_tool', err);
}
```

Every error carries an optional `hint` — a "here's how to fix it" string the
tool surface can show the user.

### `config` — hardened env/config

`readEnvVar`, `requireEnvVar`, `parseBoolEnv`, `readPortEnv`, `readIntEnv`,
`readTtlMsEnv`, `expandPath`, `loadDotenvSafely`, `createCachedJsonArrayLoader`.

`readIntEnv` is the general hardened integer reader (strict parse + optional
`min`/`max`); `readTtlMsEnv(key, defaultMs)` reads a TTL in **seconds** and
returns **milliseconds**, honoring an explicit `0` as "disabled" — the
`<SVC>_CACHE_TTL` reader shared by the response-cache consumers.

```ts
import { requireEnvVar, parseBoolEnv, readPortEnv, expandPath } from '@chrischall/mcp-utils';

const apiKey = requireEnvVar('MY_API_KEY');
const debug = parseBoolEnv('MY_DEBUG', { default: false });
const port = readPortEnv('MY_WS_PORT', 37149);  // placeholder/NaN/out-of-range → fallback
const home = expandPath('~/.config/my-mcp');
```

`readPortEnv` parses a TCP port with the same placeholder hardening as
`readEnvVar`, plus integer + `1..65535` range validation — so an unexpanded
`${MY_WS_PORT}` or junk falls back to the default instead of handing `NaN` to
the server.

`loadDotenvSafely` is a no-throw `.env` loader (returns `false` instead of
failing when the file is absent).

`createCachedJsonArrayLoader` builds a cached, negative-cached loader for an
env-named JSON string-array file — the `loadCommunities`/`DEFAULT_COMMUNITIES`
pattern shared across the realty servers:

```ts
import { createCachedJsonArrayLoader } from '@chrischall/mcp-utils';

const loadCommunities = createCachedJsonArrayLoader({
  envVar: 'REDFIN_COMMUNITIES_FILE',  // path to a JSON string-array file
  defaults: DEFAULT_COMMUNITIES,      // returned when unset/missing/invalid
  label: 'redfin-mcp',
});

const communities = loadCommunities();  // parses + caches; re-reads only on path change
```

A successful parse is cached; a missing/unreadable file, invalid JSON, or a
non-string-array logs one stderr warning and negative-caches (returns defaults
without re-reading). Pass `readFile` to inject a reader in tests.

### `fs` — streaming file helpers (uploads) & binary output

`fileBlob`, `readFileHead`, `resolveOutputDir`, `uniquePath`,
`writeBinaryOutput`, `sniffMimeBytes`.

The binary-output kit (hoisted from gemini + flightaware) is the fleet
convention for tools that generate bytes: `resolveOutputDir(perCall,
'<SVC>_OUTPUT_DIR')` resolves arg → env → cwd (creating the dir),
`writeBinaryOutput({ dir, baseName, base64, mimeType })` writes to a
**non-overwriting** path (`name.png`, `name-2.png`, …) and returns it, and
`sniffMimeBytes` magic-byte-detects PNG/JPEG/WebP/GIF.

```ts
import { fileBlob, readFileHead } from '@chrischall/mcp-utils';

// A file-backed Blob: fetch streams it from disk, never buffered in memory.
const blob = await fileBlob(path, { type: 'image/jpeg', maxBytes: 20_000_000, label: 'Image' });
const form = new FormData();
form.append('file', blob, 'photo.jpg');

// Sniff a header (image dimensions, magic bytes) without reading the whole file.
const head = await readFileHead(path, 65_536);
```

Use `fileBlob` in place of `new Blob([readFileSync(path)])` for `FormData` uploads
— `fs.openAsBlob` backs the Blob with the file on disk, so a 20 MB upload uses
constant memory instead of a 20 MB Buffer.

### `http` — bearer API-client kit

`createApiClient` plus building blocks: `buildQueryString`, `buildOptionalBody`,
`formatApiError`, `parseLinkHeader`, `parseCookieJar`, `parseCookieHeader`,
`runBoundedBatch`, `createThrottle`, `createResponseCache`, `parseRetryAfterMs`,
`splitHost`, `buildUserAgent`, `parseContentDispositionFilename`, JWT helpers
(`decodeJwtExp`, `decodeJwtSessionId`, `decodeJwtClaim`, `validateJwtExpiry`),
and the `ApiError` / `UpstreamHttpError` / `UnauthorizedError` /
`RateLimitedError` / `RequestTimeoutError` classes.

`decodeJwtClaim(token, claim)` is the generic single-claim reader — returns the
raw claim value (`unknown`) or `undefined` for an undecodable token / absent
claim, so a repo doesn't hand-roll its own `extractXFromJwt`.

```ts
import { createApiClient } from '@chrischall/mcp-utils';

const api = createApiClient({
  baseUrl: 'https://api.example.com',
  getToken: () => store.currentToken(),  // resolved per-request; sync or async
  serviceName: 'Example',
  retry: { count: 1, delayMs: 2000 },    // fleet-wide "retry once after 2s" default
  timeout: 15_000,                        // abort a hung request, throw RequestTimeoutError
});

const data = await api.get('/v1/things', { query: { page: 2 } });
```

`timeout` (ms) bounds each attempt with an `AbortController`; on expiry it throws
`RequestTimeoutError` instead of hanging the tool call. A 429 retry gets a fresh
timeout. Omit it to keep the previous unbounded behavior.

`retry` also accepts `statuses` (e.g. `[429, 503]`), `honorRetryAfter: true`
(sleep the response's `Retry-After` instead of the fixed `delayMs`, bounded by
`maxRetryAfterMs`, default 30 s — hoisted from getyourguide / musicbrainz /
viator / tripadvisor), and the standalone `parseRetryAfterMs(header)` for custom
clients.

`api.fetchRaw(method, path)` is the binary path `fetchJson` can't express —
returns `{ status, contentType, headers, bytes }` with the same 401/429/error
mapping (gzip sales reports, PNG maps, attachment downloads).

`createResponseCache({ ttlMs: { dynamic, static }, maxEntries })` is the bounded
tiered-TTL response cache for billed / rate-limited reads (flightaware / viator
/ tripadvisor): key on the request path (and body for POST-reads), route
reference data through the long `static` tier via
`fetchThrough(key, load, 'static')`, and pair the TTLs with `readTtlMsEnv`.
Writes are never cached.

`parseCookieHeader(header)` parses an inbound *request* `Cookie:` header
(`name=value; name2=value2`) into a `Record<string, string>` (first `=` splits,
so values may contain `=`; last value wins on a duplicate name). It's the
counterpart to `parseCookieJar`, which parses *response* `Set-Cookie` headers
with their attributes and deletion semantics.

`UpstreamHttpError(status, message)` is a directly-`throw new`-able,
status-carrying HTTP error — the manual-throw parallel to `ApiError` (which
`createApiClient` throws internally). It `extends ApiError`, so both the
`err instanceof ApiError && err.status === 404` branch and a narrower
`instanceof UpstreamHttpError` check work. Use it from a transport/bridge code
path that doesn't route through `createApiClient` but still needs to branch on a
404.

```ts
import { runBoundedBatch } from '@chrischall/mcp-utils';

const rows = await runBoundedBatch(ids, (id, signal) => fetchRow(id, signal), {
  deadlineMs: 45_000,                        // overall hard deadline for the whole batch
  concurrency: 4,                            // optional fan-out cap
  onTimeout: (id, i) => ({ id, pending: true }), // backfill any row the deadline cut off
});
```

`runBoundedBatch(items, worker, opts)` races the whole batch against one overall
`deadlineMs`; any item still unsettled when it fires is filled by
`onTimeout(item, index)` (and its worker abandoned + `AbortSignal`-signalled) so
a single hung row can't wedge the call. It always returns a full-length,
input-ordered array. `setTimer`/`clearTimer` are injectable for tests. This
hoists zillow's bulk-tool deadline + `pending`-backfill primitive.

### `concurrency` — bounded async map & single-flight

`mapWithConcurrency`, `singleFlight`, `memoizeAsync` — zero-dependency async
primitives.

`singleFlight(fn)` shares ONE in-flight invocation across concurrent callers
(cleared on settle; a rejection doesn't poison the next call) — the
login/refresh/bridge-ready guard hand-rolled in honeybook / infinitecampus /
onehome / vibo / alltrails / tripadvisor / artsonia. `memoizeAsync(loader)` is
the keyed variant: a promise cache that coalesces concurrent loads per key and
evicts rejected loads so the next `get` retries (redfin's `LocalityPoolCache`),
with `delete`/`clear` for invalidation and test hooks.

```ts
import { mapWithConcurrency } from '@chrischall/mcp-utils';

const rows = await mapWithConcurrency(ids, 6, (id, i) => fetchRow(id, i));
```

`mapWithConcurrency(items, limit, fn)` keeps at most `limit` calls in flight (a
pool pulling off a shared cursor) and returns results in input order. It follows
`Promise.all` failure semantics — the first rejecting `fn` rejects the whole
call. This hoists the hand-rolled `mapLimit` copy-pasted across the fleet (e.g.
artsonia's `download.ts`). The [`/fetchproxy`](#fetchproxy) subpath re-exports a
same-named primitive from `@fetchproxy/server`; this is the zero-dep core one for
non-bridge repos. Use `runBoundedBatch` instead when you need an overall deadline
plus per-item backfill rather than a plain all-or-nothing map.

### `dates` — date-format converters

`isoToDmy`, `dmyToIso`, `isoToCompactTimestamp`, `todayIso`, `toIsoDateUtc`,
`shiftIsoDate`, `ensureSeconds`. For upstreams that don't speak
ISO 8601, so a server can keep its surface ISO (`yyyy-MM-dd`) and translate at
the API boundary. Pair with `deepMapStringField` to normalize a date field
across a whole response.

```ts
import { dmyToIso, isoToDmy, deepMapStringField } from '@chrischall/mcp-utils';

const apiDate = isoToDmy('2025-08-28');                 // '28-08-2025' (request)
deepMapStringField(payload, 'eventDate', dmyToIso);     // '28-08-2025' → '2025-08-28' (response)
```

### `scrape` — SSR JSON-store & page extraction (zero-dep)

`decodeHtmlEntities`, `stripHtml`, `sanitizeJsLiterals`, `matchBalanced`,
`extractJsonAfterMarker`, `extractJsonLdBlocks`, `findJsonLdEntity`,
`ogContent`, `findArrayByShape`, `deepCollectArrays`, `deepFindObject`,
`isCloudflareChallenge`, `stripJsonGuard`.

Pure string/JSON primitives for server-rendered pages — no `node-html-parser`
(DOM-level scraping stays in the [`/html`](#html) subpath). Consolidates the SSR
JSON-store stack re-implemented across musescore / tock / zillow / opentable /
tripadvisor / etix:

```ts
import {
  extractJsonAfterMarker, findJsonLdEntity, ogContent,
  findArrayByShape, isCloudflareChallenge, stripJsonGuard,
} from '@chrischall/mcp-utils';

// A redux/__NEXT_DATA__-style store (JS literals repaired via sanitize):
const store = extractJsonAfterMarker(html, ['window.$REDUX_STATE', '"appState"'], { sanitize: true });

// schema.org / OpenGraph readers:
const event = findJsonLdEntity(html, 'Event');      // checks blocks, @graph, mainEntity
const title = ogContent(html, 'og:title');

// Drift-tolerant array location + anti-XSSI guard stripping:
const homes = findArrayByShape(pageProps, ['savedHomesList'], (f) => !!f && typeof f === 'object');
const data = JSON.parse(stripJsonGuard(body));      // )]}'  while(1);  for(;;);  {}&&
```

`isCloudflareChallenge` matches the DEFINITIVE interstitial markers only
(`_cf_chl_opt`, `<title>Just a moment`) — never `cdn-cgi/challenge-platform`,
which Cloudflare inlines on cleared pages too. `decodeHtmlEntities` decodes
`&amp;` LAST so attribute-escaped JSON survives one level; `matchBalanced` is
the string/escape-aware bracket walker regex can't replace.

### `zod` — schema atoms

Reusable schemas (`PositiveInt`, `NonNegInt`, `NonEmptyString`, `IsoDate`,
`IsoTime`, `NumericIdString`, `SafePathSegment`, `schemaOrigin`,
`schemaConfirm`), pagination helpers
(`paginationSchema`, `pageSchema`, `calculateOffset`), tool-annotation builders
(`toolAnnotations`), time normalizers (`extractTime`, `normalizeTime`), and the
lenient response validator `parseLenient`.

`parseLenient(schema, raw, { label, context, mode? })` is the degrade-never-break
validator for reverse-engineered APIs (alltrails' `parseAllTrails`, ofw's
`parseOFW`, getyourguide's `parseGYG`): on success it returns the parsed data;
on drift it warns to **stderr** with the precise issue paths and returns the
RAW response (or throws an `McpToolError` in `mode: 'strict'` for write paths).

```ts
import { paginationSchema, calculateOffset, toolAnnotations } from '@chrischall/mcp-utils';

const inputSchema = { ...paginationSchema, q: NonEmptyString };
const offset = calculateOffset(page, size);
const annotations = toolAnnotations({ readOnly: true });
```

`NumericIdString` (`/^\d+$/`) and `SafePathSegment` (rejects `/`, `..`, `?`,
`#`, and whitespace) harden caller-supplied ids that get interpolated into
request paths — defense-in-depth against path traversal and query/fragment
injection.

### `auth` — auth resolver skeletons

`createAuthResolver`, `resolveAuthPattern`, `sessionLoginFlow`,
`createOAuth2Refresher`, `createCachedTokenSource`, `signEs256Jwt`, and the
supporting `FetchproxySession` / `AuthPattern` types.

`createCachedTokenSource({ mint, bufferMs })` caches any minted token until
shortly before expiry with a single-flight mint and an `invalidate()` hook for
401-replay — wrap it around `createOAuth2Refresher` (musicbrainz), an ES256
self-mint (app-store-connect), or a login exchange (zola). `signEs256Jwt(pem,
payload, { header: { kid } })` is the P-256/`ieee-p1363` JWS signer those
self-minted-JWT APIs need (the decode counterparts live in `http`).

```ts
import { createAuthResolver, createOAuth2Refresher } from '@chrischall/mcp-utils';

const resolver = createAuthResolver({ /* ... */ });
const refresh = createOAuth2Refresher({ /* ... */ });
```

### `session` — session registry, token manager & cookie-session manager *(subpath)*

```ts
import {
  createSessionRegistry,
  registerSessionTools,
  TokenManager,
  CookieSessionManager,
} from '@chrischall/mcp-utils/session';

const registry = createSessionRegistry();
registerSessionTools(server, { registry /* ... */ });
```

The `${prefix}_register_session` tool takes an optional `mark_active`
(default `false`); passing `mark_active: true` makes the newly-registered
session active in the same call instead of requiring a follow-up
`${prefix}_set_active_session`.

Includes `SessionStore`, `normalizeOrigin`, `AuthMode`, and `TokenManager`
(with `TOKEN_REFRESH_SKEW_MS` for proactive refresh).

`CookieSessionManager<S, R = Response>` is the cookie-session analog of
`TokenManager` for sites authenticated by a browser-style cookie session rather
than a bearer token. It owns *when* to log in (single-flight, so concurrent
callers coalesce into ONE login), clears the in-flight promise on settle (a
rejected login never sticks — the next `ensure()` retries), and `withSession()`
re-logs-in and replays a request **exactly once** on a detected expiry (no
infinite loop). The injected `isExpired(res)` predicate is the hook for body/URL
heuristics — so a `200` serving an HTML login page or a redirect away from the
target is treated as expired, not just `401`/`403`. An optional
`isPermanentError` caches genuine missing-config errors while leaving transient
login failures retryable.

`isExpired` is **optional** — omit it for ensure-only consumers with no
per-request expiry path (e.g. Skylight, whose re-auth lives in `TokenManager`);
it defaults to `() => false`, so `withSession()` simply never replays.

The second type param `R` (default `Response`) is the response type
`withSession`'s `call` resolves to. The manager is response-agnostic — it only
hands `R` to `isExpired` and returns it untouched — so override `R` for a custom
or non-fetch transport (e.g. Artsonia's `{ setCookie?, location?, url, body }`).
Existing adopters writing `CookieSessionManager<MySession>` keep `R = Response`
with **no call-site changes**.

```ts
const sessions = new CookieSessionManager<{ cookieHeader: string; csrfToken?: string }>({
  login: () => loginWithPassword(),                 // mints a fresh cookie session
  isExpired: async (res) =>
    res.status === 401 || /<form[^>]*id="login"/i.test(await res.clone().text()),
});

const res = await sessions.withSession((s) =>
  fetch(url, { headers: { cookie: s.cookieHeader } }),
);

// Custom non-fetch transport: parameterize R (and isExpired reads R's members).
const custom = new CookieSessionManager<MySession, MyResponse>({
  login: () => loginWithPassword(),
  isExpired: (res) => /login\.asp/i.test(res.location ?? res.url),
});
```

Replaces the hand-rolled re-login / single-flight / 401-replay code in
`artsonia-mcp`, `canvas-parent-mcp`, `evite-mcp`, `signupgenius-mcp`, and
`skylight-mcp`.

#### Surviving a restart — `StatePersistence` *(opt-in)*

Both managers own a credential only for the life of the process. On a
scale-to-zero host that means a full login on every cold start — children idle
out after ten minutes, several services rate-limit the login endpoint, and one
escalates repeated attempts to a captcha that breaks server-side auth outright.
Pass `persistence` and the credential survives instead:

```ts
import {
  TokenManager,
  createFileStatePersistence,
  resolveStateDir,
  type BearerTokens,
} from '@chrischall/mcp-utils/session';
import { join } from 'node:path';

const tokens = new TokenManager({
  // Function form: run the login ONLY when nothing usable was restored.
  initial: () => loginWithPassword(),
  refresh: (rt) => exchangeRefreshToken(rt),
  persistence: createFileStatePersistence<BearerTokens>({
    filePath: join(resolveStateDir({ subdir: '.acme-mcp' }), 'tokens.json'),
  }),
});
```

What that buys, in order of how often it applies: a stored token that is still
valid costs **nothing**; a stored token that has expired but carries a refresh
token costs **one refresh** instead of a login; only an empty or unusable store
runs `initial`. A refresh token revoked between runs is not terminal — the
stored copy is discarded and the login re-runs, so a stale file cannot brick the
server. A *transient* refresh failure is treated differently: a `RateLimitedError`,
a `RequestTimeoutError` or a 5xx `ApiError` surfaces to the caller with the
refresh token left intact, because destroying a valid credential and burning a
login on a passing outage is the cost this feature exists to avoid. Override
`isRefreshRevoked` for a service that signals revocation some other way.

`createFileStatePersistence` writes atomically (temp file + rename), leaves the
file `0600`, and creates any missing directory `0700` — but does **not**
re-permission a directory that already exists, since a bare `resolveStateDir()`
is `$HOME` and `mcp-host` creates the data dir before the child starts. It never
throws: a read-only or full disk degrades to in-memory operation, costing a
login rather than a failed request. `resolveStateDir` prefers `MCP_DATA_DIR` — the variable `mcp-host`
injects for a registration with `state.dataDir: true` — then `HOME`, then the OS
home directory. It reads both through `readEnvVar`, so blank values, the
`'null'` / `'undefined'` sentinels and unexpanded `${...}` placeholders are all
treated as unset (`MCP_DATA_DIR=null` would otherwise be a *relative* `./null`
directory, quietly parking the credential under the process cwd).

> On `mcp-host`, set `state.dataDir: true` in the repo's `mint.yaml` when you
> adopt this. Without it the child's `$HOME` is on the container rootfs, which
> an idle-stop discards — the runner's unpersisted-state detector will report
> the omission, but the writes still vanish.

`CookieSessionManager` takes the same option, storing `{ session, sessionAt }`
so `maxAgeMs` keeps counting from the original login. Its `invalidate()` clears
the stored copy — without that, a session detected as expired would be read back
off disk and the expiry would loop.

#### Capabilities lifted from the hand-rolled stores

Four repos (`freshbooks-mcp`, `kiaaccess-mcp`, `alphaportal-mcp`, `vibo-mcp`)
persisted tokens before this helper existed. Auditing them before migrating
turned up behaviour the first cut did not have:

- **`onPersistError`** — a failed write is swallowed by default, which is right
  when it merely costs a future re-login. It is wrong for a service that rotates
  **single-use** refresh tokens: the old one is already spent upstream, so a new
  one that never reaches disk locks the account out on the next start. Throw
  from the hook to make the write fatal (`freshbooks-mcp`'s case). Accordingly
  `createFileStatePersistence.save` now *reports* a failed write by throwing;
  `load` stays total. A failure raised this way is wrapped in a
  `StatePersistenceError` so it can never be mistaken for a revoked credential —
  the refresh that produced it succeeded, so discarding the stored record would
  destroy the only surviving copy, which is the lockout the option exists to
  prevent.
- **`boundTo`** — bind a record to the credential that minted it, so a rotated
  password or a re-run OAuth bootstrap discards the cache instead of being
  shadowed by it. Only a salted HMAC digest is written, never the credential, and
  the salt is fresh per write so the same credential never leaves the same
  artifact twice. It is a change-detector, not a password store — pass a
  non-secret discriminator where you have one. (`freshbooks-mcp` tracked this as
  `seededFromEnv`, storing the raw token.)
- **`createKeyedFileStatePersistence`** — many records in one file, keyed by
  account, each key handed out as a plain `StatePersistence` a manager takes
  directly. Required for any server authenticating as more than one identity,
  and for anything serving several users from one process, where a
  single-record file would hand one user's token to the next. Keys normalize
  trim+lowercase by default, because they are account identities, not origins.
  Writes are whole-file read-modify-write, so two processes saving different
  keys at the same instant can drop one update — the loser re-authenticates
  rather than reading anything wrong, which is the right trade for a credential
  cache and would not be for a general store.
- **`resolveStateFile({ envVar, subdir, fileName })`** — an env override for the
  path, checked through the same hardened `readEnvVar`. Every one of the four
  had one, and every one used it to keep its test suite off the developer's real
  `$HOME`.

Records are written in a small envelope (`{ v: 1, boundTo?, state }`). A bare
record written by an earlier version is still read, so nothing already on disk
is lost.

The file-backed stores return `SyncStatePersistence<T>` — the same contract with
the promise arm dropped, since they read one small file and cannot suspend.
Composing one (wrapping `load` to add a legacy fallback, say) therefore needs no
narrowing cast, and the value is still accepted anywhere `StatePersistence` is.

Persistence is **opt-in throughout**: a manager constructed without it behaves
exactly as before, and no credential reaches a disk because a dependency was
upgraded. The interface is two methods (`load` / `save`, plus an optional
`clear`), each allowed to be async, so a backend other than the local filesystem
can be dropped in.

### `fetchproxy` — transport adapter *(subpath, optional peer)*

```ts
import {
  createFetchproxyTransport,
  createBootstrapOpts,
  registerBridgeHealthcheckTool,
  mapWithConcurrency,
  TokenBucket,
  classifyBotWall,
} from '@chrischall/mcp-utils/fetchproxy';
```

Wraps `@fetchproxy/server` with the fleet's transport, bot-wall classification,
deadline/retry, token-bucket rate limiting, and bounded-concurrency helpers, and
re-exports the fetchproxy typed-error hierarchy.

**Transport verb adapters.** Beyond the `start` / `close` / `status` lifecycle,
`createFetchproxyTransport` exposes the verb passthroughs redfin / homes /
compass / musescore had each hand-rolled over the server:

- `fetch(init)` → `{ status, body, url }` via `server.request(...)`;
- `requestJson(method, path, init?)` → `{ data, result }` via
  `server.requestJson(...)` (serialization + header defaults + 204→null +
  `JSON.parse`; the caller keeps its per-site `throwIfNotOk` over `result`);
- `runProbe(fetchFn, probePath)` → the healthcheck probe loop.

The one per-site bit is the subdomain: pass `defaultSubdomain: 'www'` for sites
served from `www` (redfin/homes/compass); omit it for apex-served sites
(musescore). A per-call `subdomain` always overrides the default, and absolute
`http(s)://` paths self-describe their host. Other per-site verbs (e.g.
musescore's `download` capability) stay caller-supplied — the factory covers the
common subset, not the long tail.

**Opt-in startup banner.** Set `logListening: true` and `start()` emits the
canonical fleet banner to **stderr** (stdout is the JSON-RPC channel) once the
bridge is listening:

```
[<serverName>:bridge] listening on 127.0.0.1:<port> (role=<role ?? 'unknown'>, version=<version>)
```

The port is read from the live `bridgeHealth()`, so an overridden port is
reflected (no hardcoded literal). Default `false` keeps current consumers silent
— they opt in to drop their hand-rolled banner. This is independent of
`debugEnvVar`, which gates the richer per-request debug logging.

**`serverVersion` in `status()`.** `status()` returns the `bridgeHealth()`
snapshot with `serverVersion` additively pinned to the `version` opt — the field
redfin / homes / compass each projected by hand. Consumers can delegate
`status()` straight through instead of re-wrapping the health snapshot.

**Mock-injectable server (test seam).** Pass `createServer` to inject a mock
`FetchproxyServer` instead of the factory constructing a real one (default
`(opts) => new FetchproxyServer(opts)`). A consumer's vitest can capture the
constructor opts and stub verbs (e.g. `download`) without
`vi.mock('@fetchproxy/server')` — which can't reach the `new FetchproxyServer`
call inside this package's prebuilt dist. The default path is unchanged and adds
no new eager `@fetchproxy/server` import.

```ts
// In a consumer's transport test:
const ctorOpts = vi.fn();
const t = createFetchproxyTransport({
  serverName: 'musescore-mcp', version, domains: ['musescore.com'],
  createServer: (opts) => {
    ctorOpts(opts);
    return { download: downloadMock, /* …stubbed verbs… */ } as never;
  },
});
expect(ctorOpts.mock.calls[0][0].capabilities).toEqual(['fetch', 'download']);
```

**Bridge-healthcheck tool factory.** `registerBridgeHealthcheckTool({ server,
prefix, probePath, hostLabel, transport, probeFn })` registers a
`<prefix>_healthcheck` tool that round-trips `probePath` through the bridge and
reports bridge role / port / timing plus an actionable hint ladder
(`bridge_down` → wake the SW, `role === null` → check startup, `timeout` →
extension not connected, …). The failure hint cites the **actual configured
bridge port** from `bridgeHealth()`, not a hardcoded `37149` — fixing the bug
the per-site compass + musescore copies shared.

```ts
registerBridgeHealthcheckTool({
  server,
  prefix: 'compass',
  probePath: '/robots.txt',
  hostLabel: 'compass.com',
  transport,
  probeFn: (path) => client.fetchHtml(path),
});
```

Two optional hooks absorb the site-specific healthchecks workday / zillow /
etix hand-rolled: `classifyThrown(err)` maps the probe's thrown error to a
custom `{ kind, hint }` (e.g. an SSO bounce → `session_expired` with re-sign-in
copy; its hint wins the result hint), and `hints` overrides the default copy
per ladder arm (`{ timeout: 'DataDome may be challenging the tab — …' }`).

**The extension link.** A probe that fails with fetchproxy's
`FetchproxySessionNotReadyError` reports `error.kind: 'session_not_ready'`
(classified here, so it holds on a pre-2.5 server too) and the hint names the
missing leg: the pair code to approve in the popup, "no extension attached
(port N)", or "attached but never answered the hello" — the shape a hosted
bridge produces when the relay dials the child before it binds. With
`@fetchproxy/server` 2.5.0+ the `bridge` block also carries `session_state`,
`pending_pair_code` and `extension_connected` from `bridgeHealth().session`.

**Direct-first consumers** (hemnet, booli: a plain fetch that falls back to
the bridge when a bot wall answers) pass `path: () => ({ transport, mode })`
reporting which leg serves calls now, and may pass `transport` as a getter
that returns the bridge once it exists. The probe then runs through `probeFn`
directly (the probe itself is often what flips the fallback), the result
carries the path as `transport`, and the `bridge` block appears only once a
bridge has been built:

```ts
registerBridgeHealthcheckTool({
  server, prefix: 'hemnet', probePath: '/graphql', hostLabel: 'www.hemnet.se',
  transport: () => fallback.bridgeTransport(),        // undefined until walled
  path: () => fallback.status(),                       // { transport: 'direct' | 'fetchproxy', mode }
  probeFn: () => client.healthcheck().then(JSON.stringify),
});
```

### `healthcheck` — credential healthchecks *(subpath, no optional peers)*

```ts
import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
```

Its own subpath rather than `/fetchproxy`, which pulls the optional
`@fetchproxy/server` peer that most callers of this factory do not install.

`registerCredentialHealthcheckTool({
server, prefix, hostLabel, probePath?, resolveCredential, probeFn })` is the
twin for connectors whose health is about a **credential** rather than a
browser bridge: OAuth connectors, API-key connectors, and the fetchproxy MCPs
that only *bootstrap* a token and then talk to an API directly.

It exists because three failures are otherwise indistinguishable and have
different fixes: nothing minted a credential, something minted one the far side
rejects, and the far side is down.

```ts
registerCredentialHealthcheckTool({
  server,
  prefix: 'freshbooks',
  hostLabel: 'api.freshbooks.com',
  probePath: '/auth/api/v1/users/me',
  resolveCredential: async () => ({ source: 'env', detail: { age_days: 3 } }),
  probeFn: () => client.getIdentity(),
});
```

Arms: `ok`, `no_credential`, `credential_rejected` (401/403), `timeout`,
`http`, `transport`, `unknown` — with the same `classifyThrown` / `hints`
hooks as the bridge factory.

Two behaviours worth knowing. **The probe is skipped entirely when no
credential resolved**, because probing without one returns 401 and reads as
"rejected", sending people off to re-authenticate a credential that does not
exist. And **`CredentialState` carries a source label plus a non-secret
`detail` bag, never the value** — `detail` is echoed verbatim into the result,
and a healthcheck is the tool people paste into a chat when something is
broken. Error messages go through `truncateErrorMessage`, so redaction runs
before any upstream text reaches the result.

### `html` — scraping helpers *(subpath, optional peer)*

```ts
import {
  parsePropertyTable,
  findLinksUnderHeading,
  extractJsonFromHtml,
  extractPlainTextFromHtml,
} from '@chrischall/mcp-utils/html';
```

Requires the optional `node-html-parser` peer. Also provides `urlToPath`,
`locationToSlug`, and `buildIdExtractor`.

### `test` — in-memory test harness *(subpath)*

```ts
import { createTestHarness, parseToolResult } from '@chrischall/mcp-utils/test';

const harness = createTestHarness();
register(harness.server);
const result = await harness.call('ping', {});
expect(parseToolResult(result)).toEqual({ ok: true });
```

Also includes `versionSyncTest`, `mockFetchproxyBootstrap`, `setupClientMocks`,
and `makeBootstrapResult`.

## Shared CI actions

This repo also hosts composite GitHub Actions the MCP fleet reuses, under
[`.github/actions/`](.github/actions/):

- [`install-mcp-publisher`](.github/actions/install-mcp-publisher) — **moved to
  [chrischall/workflows](https://github.com/chrischall/workflows)** with the fleet
  pipeline consolidation. Reference it there:

  ```yaml
  - uses: chrischall/workflows/.github/actions/install-mcp-publisher@main
  ```

## Development

```sh
npm run build      # tsc -b → dist/
npm test           # tsc typecheck + vitest run
npm run test:watch # vitest (watch mode)
```

## License

MIT
