
Security News
GitHub Actions Adds cache-mode to Limit Cache Poisoning Risk
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.
@chrischall/mcp-utils
Advanced tools
Shared scaffolding for the chrischall MCP fleet — 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
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.
npm install @chrischall/mcp-utils
Peer dependencies: @modelcontextprotocol/server and zod.
@modelcontextprotocol/client, @fetchproxy/server, and node-html-parser are
optional — only needed if you import the /test, /fetchproxy, or /html
subpaths respectively. The latter two use a declared range of * 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+.
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 |
import { createMcpServer, textResult, requireEnvVar } from '@chrischall/mcp-utils';
import { createSessionRegistry } from '@chrischall/mcp-utils/session';
import { createFetchproxyTransport } from '@chrischall/mcp-utils/fetchproxy';
server — bootstrap & lifecyclecreateMcpServer, runMcp, withGracefulShutdown, surfaceToolHints,
requireConfirmation.
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.
For a mutating tool, requireConfirmation uses the 2026-07-28 stateless
multi-round-trip flow instead of a caller-supplied confirm argument. Return
its result when defined; undefined means the client accepted the elicitation
and checked the schema-validated confirmation box.
import { requireConfirmation, textResult } from '@chrischall/mcp-utils';
server.registerTool('calendar_delete', config, async ({ eventId }, ctx) => {
const confirmation = requireConfirmation(ctx, {
action: 'calendar.delete',
message: 'Review and confirm this deletion.',
details: { eventId },
});
if (confirmation) return confirmation;
await calendar.delete(eventId);
return textResult({ deleted: true, eventId });
});
The details are a preview, not trusted retry state. Recompute authorization and the write from the tool's original validated arguments each round.
response — tool-result formattingtextResult / 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).
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);
view vocabulary — read tools answer in the cheap shape by defaultVIEWS, DEFAULT_VIEW, View, viewParam, resolveView, viewResult,
minifiedResult, projectOrRaw, stripMediaUrls. 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.
import { viewParam, resolveView, viewResult, projectOrRaw } from '@chrischall/mcp-utils';
import { z } from 'zod';
const VIEWS_HERE = ['compact', 'full'] as const; // only the rungs you honour
server.registerTool('svc_list_things', {
inputSchema: z.object({
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".
stripMediaUrls(payload) is the highest-value projection that needs no
knowledge of the API: it drops avatar / picture / cover_photo /
thumbnail keys — including the …Link / …Uri / …Url suffixed forms every
Google Workspace API uses (thumbnailLink, iconUri, photoUrl), and the
snake_case and kebab-case forms most other APIs use (image_url,
primary_photo_url, avatar_image_url) — and bare
image URLs. The suffix is load-bearing outside consumer-social APIs: without it
the rule matched none of Google's media fields, and thumbnailLink alone is 32%
of a gog drive ls listing. Its key rule stays anchored at the START, so a key
that merely contains a media noun survives — Drive's hasThumbnail: false is a
fact about the file, and webViewLink sits in the same object as
thumbnailLink. Measured on a real 187.6 KB
splitwise-mcp groups response — which does not fit in a tool result at all —
minifying alone is −25%, minifying and stripping media is −73%. It
deliberately keeps null (an absent key and a null one are different facts;
ofw-mcp's viewedAt: null means "never opened"), keeps page URLs, and never
mutates its input. Do not apply it to a tool whose product IS the image —
alltrails_get_trail_photos, sw_get_receipt, redfin's photo tools (whose
records are literally a photoUrls bundle) — where it empties the response
rather than shrinking it.
Arrays of bare image URLs under a non-media key are kept — floorplan_urls: ['a.jpg', 'b.jpg'] comes back whole. That is deliberate: removing a key is
visible, removing elements is not, and a caller reading .length to report
"4 floor plans" would be quietly wrong. Use drop for those. An array under a
media-named key (photos: [...]) is already removed by the key rule.
Two escape hatches, and they are symmetric: keep preserves a key that looks
like media but is the thing the caller asked for; drop adds keys this pattern
does not know. Both take string | RegExp, so a service with an unguessed naming convention can fix itself
without a library release — which is what Google Workspace's
thumbnailLink/iconUri/photoUrl cost the first time round. keep wins over
drop.
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 errorsMcpToolError 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 subpath instead, so
bearer-only MCPs can import the core barrel without installing
@fetchproxy/server.
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/configreadEnvVar, 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.
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:
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 outputfileBlob, 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.
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 kitcreateApiClient 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.
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.
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-flightmapWithConcurrency, 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.
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 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 convertersisoToDmy, 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.
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 subpath). Consolidates the SSR
JSON-store stack re-implemented across musescore / tock / zillow / opentable /
tripadvisor / etix:
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
& LAST so attribute-escaped JSON survives one level; matchBalanced is
the string/escape-aware bracket walker regex can't replace.
zod — schema atomsReusable schemas (PositiveInt, NonNegInt, NonEmptyString, IsoDate,
IsoTime, NumericIdString, SafePathSegment, schemaOrigin,
schemaConfirm (deprecated in favor of requireConfirmation), 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).
import {
NonEmptyString,
paginationSchema,
calculateOffset,
toolAnnotations,
} from '@chrischall/mcp-utils';
import { z } from 'zod';
const inputSchema = z.object({ ...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 skeletonscreateAuthResolver, 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).
import { createAuthResolver, createOAuth2Refresher } from '@chrischall/mcp-utils';
const resolver = createAuthResolver({ /* ... */ });
const refresh = createOAuth2Refresher({ /* ... */ });
session — session registry, token manager & cookie-session manager (subpath)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.
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.
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:
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, setstate.dataDir: truein the repo'smint.yamlwhen you adopt this. Without it the child's$HOMEis 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.
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)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.
// 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.
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:
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)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.
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),
session_expired, verification_pending, timeout, http, transport,
unknown — with the same classifyThrown / hints hooks as the bridge
factory.
sessionProbe / sessionClassifierprobeFn reports failure only by throwing, so a probe that resolves is
reported healthy whatever it resolved to. Connectors whose probe rides a client
that throws on non-2xx comply by accident — and that accident does not hold
against a soft wall, where a dead session comes back 200 with a login page.
One connector reported ok: true and "the credential works" on an account that
could not load a single record.
sessionProbe builds a compliant probe from the one closure only you can
write:
probeFn: sessionProbe({
request: () => auth.request('Home'), // must not sign in
signedOut: (body) => isAuthWall(body), // the site-specific part
hostLabel: 'my.atriumhealth.org',
}),
classifyThrown: sessionClassifier({
hostLabel: 'my.atriumhealth.org',
remedies: {
signIn: 'mah_sign_in',
sendCode: 'mah_send_verification_code',
verifyCode: 'mah_verify_code',
},
verificationPending: () => auth.mfaPending,
credentialsRejected: () => auth.credentialsRejected,
}),
Name the remedy tools; never let them be derived. remedies is explicit
because connectors do not share a naming scheme: simplepractice signs in with
simplepractice_request_sign_in_link, kiaaccess with kia_start_login and
kia_verify_otp. Copy generated from the tool prefix produced
<prefix>_sign_in, which exists in exactly one connector — so the hint sent
people to a tool that was not there, which is worse than generic advice given
that the tool's whole job is to point at the fix. Every field is optional and
omitting one keeps the copy true but generic; the verification copy stays
generic unless BOTH code tools are named, since half a flow leaves the caller
with a code and nowhere to put it.
If your probe rides a client that already throws, keep your own probeFn
and throw the exported class for the soft wall your client cannot see:
probeFn: async () => {
const html = await client.page('Home'); // throws on non-2xx already
if (isAuthWall(html)) throw new SessionNotLiveError(HOST, 'sign-in page');
return html;
},
The library owns the generic rules — a 3xx is signed out (a manual-redirect bounce has no body to judge), any other non-2xx is an upstream error carrying its status, a 2xx is signed out if your closure says so — and turns the three signed-out states into arms with distinct remedies. A refused credential outranks a pending verification: both flags can be set, and retrying a code against a password the far side refuses is futile.
signedOut stays yours because getting it wrong is silent and specific. One
portal links to two-factor setup from every signed-in page, so a body-wide
match on twoFactor reports "signed out" for every request. A library that
guessed this would be wrong in both directions.
A server that picks its transport from what is configured — credentials, so sign in directly; otherwise relay through the browser — must not register one of the two factories at boot. The tool NAME is the same either way, so no client ever sees two healthchecks, but its title, description and result shape then follow the environment the process happened to start in. A host that enumerates tools from a child spawned without credentials publishes a bridge tool for a server that will never use a bridge.
registerAdaptiveHealthcheckTool (/fetchproxy, since it needs the bridge
arm) fixes the identity and varies only the body:
registerAdaptiveHealthcheckTool({
server,
prefix: 'mah',
hostLabel: 'my.atriumhealth.org',
usingBridge: () => bridge !== undefined,
bridge: { probePath: 'Home', transport, probeFn: (p) => client.page(p) },
credential: { probePath: '/Home', resolveCredential, probeFn },
});
usingBridge() is read per CALL, not captured at registration, so the answer
follows the path requests are actually on. Both arms keep their own
diagnostics verbatim — this dispatches, it does not reimplement.
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)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)import { createTestHarness, parseToolResult } from '@chrischall/mcp-utils/test';
const harness = await createTestHarness(register, {
elicitation: async (request) => {
expect(request.params.message).toContain('Confirm');
return { action: 'accept', content: { confirmed: true } };
},
});
try {
const result = await harness.callTool('ping', {});
expect(parseToolResult(result)).toEqual({ ok: true });
} finally {
await harness.close();
}
TestHarnessOptions.elicitation advertises the client capability and handles
form or URL elicitation requests, so tests can drive stateless
input_required retry rounds through the real client/server path.
Also includes versionSyncTest, mockFetchproxyBootstrap, setupClientMocks,
and makeBootstrapResult.
This repo also hosts composite GitHub Actions the MCP fleet reuses, under
.github/actions/:
install-mcp-publisher — moved to
chrischall/workflows with the fleet
pipeline consolidation. Reference it there:
- uses: chrischall/workflows/.github/actions/install-mcp-publisher@main
npm run build # tsc -b → dist/
npm test # tsc typecheck + vitest run
npm run test:watch # vitest (watch mode)
MIT
FAQs
Shared scaffolding for the chrischall MCP fleet — 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
The npm package @chrischall/mcp-utils receives a total of 4,011 weekly downloads. As such, @chrischall/mcp-utils popularity was classified as popular.
We found that @chrischall/mcp-utils demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.

Company News
Allow myself to introduce... myself.

Research
/Security News
A Twitch browser extension on Chrome and Firefox forwards users’ live OAuth session tokens through proxies controlled by a Russian bot service.