New:Microsoft Teams Notifications Are Now Available in Socket.Learn more
Get Started

@chrischall/mcp-utils

Package Overview
Dependencies
Maintainers
1
Versions
56
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@chrischall/mcp-utils

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

latest
Source
npmnpm
Version
0.28.0
Version published
Weekly downloads
4.4K
-30.02%
Maintainers
1
Weekly downloads
 
Created
Source

@chrischall/mcp-utils

CI npm 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.

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+.

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:

ImportContents
@chrischall/mcp-utilscore barrel: server + response + errors + config + fs + http + concurrency + dates + zod + auth + scrape
@chrischall/mcp-utils/sessionsession registry, session store, state persistence, token manager, cookie-session manager
@chrischall/mcp-utils/fetchproxyfetchproxy transport adapter, bot-wall / retry / concurrency helpers
@chrischall/mcp-utils/healthcheckcredential-style healthcheck factory (no fetchproxy peer needed)
@chrischall/mcp-utils/htmlopt-in HTML scraping helpers (needs node-html-parser)
@chrischall/mcp-utils/scrapeconvenience alias for the zero-dep scrape module (also in the core barrel)
@chrischall/mcp-utils/testin-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';

Modules

server — bootstrap & lifecycle

createMcpServer, 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 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).

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, 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 keptfloorplan_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 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 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/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.

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 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.

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.

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-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.

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 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.

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 &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 (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 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).

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

const resolver = createAuthResolver({ /* ... */ });
const refresh = createOAuth2Refresher({ /* ... */ });
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.

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:

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)

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.

probeFn 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.

Two transports, one healthcheck

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 valuedetail 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.

Shared CI actions

This repo also hosts composite GitHub Actions the MCP fleet reuses, under .github/actions/:

Development

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

License

MIT

FAQs

Package last updated on 16 Sep 2026

Related posts