/** * Session scaffolding for the MCP fleet — four related-but-distinct surfaces * consolidated behind one subpath (`@chrischall/mcp-utils/session`): * * 1. {@link SessionRegistry} — an *ephemeral, in-memory* registry of signed-in * sessions keyed by account identity, plus {@link registerSessionTools} for * the structurally-identical `*_register_session` / `*_set_active_session` / * `*_get_session_context` MCP tool trio. Used by the realty MCPs * (zillow/redfin/compass/homes/onehome). * * 2. {@link SessionStore} — a *disk-persisted* store with hardened file perms * (0600 file / 0700 dir), normalized keys, and a most-recently-used "active" * pointer. Used by ofw/creditkarma/honeybook. * * 3. {@link TokenManager} — a bearer-token lifecycle manager: proactive refresh * inside a 5-minute skew window, reactive 401-replay, and a single-flight * semaphore so concurrent callers coalesce into ONE refresh. Used by * skylight/canvas/creditkarma/honeybook/zola. * * 4. {@link CookieSessionManager} — the cookie-session analog of TokenManager: * a single-flight login + reactive expiry-replay (with heuristic, not just * status-code, expiry detection) + clear-on-settle so a rejected login never * sticks. Used by artsonia/canvas/evite/signupgenius/skylight. * * Security-sensitive by design (file perms + token-refresh races), so this is * the one audited implementation the fleet shares. */ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; /** How a session was authenticated. Open-ended so per-MCP modes slot in. */ export type AuthMode = 'browser_session' | 'unknown' | (string & {}); /** A registered, signed-in session as surfaced to tools and clients. */ export interface SessionToken { /** Opaque label id (`Date.now().toString(36)` + random). Stable across re-registration. */ session_id: string; /** * Caller-supplied identity for the signed-in account (usually the saved * email). Re-registering the same identity updates the existing entry in * place rather than creating a duplicate. */ account_identity: string; auth_mode: AuthMode; /** Whether the session is currently usable for tool calls. */ auth_ready: boolean; /** ISO timestamp of when the session was last registered/refreshed. */ registered_at: string; /** ISO expiry, or `null` for "no known expiry". */ auth_expires_at: string | null; } /** Snapshot returned by {@link SessionRegistry.getContext}. */ export interface SessionContext { active_session_id: string | null; sessions: SessionToken[]; } /** Arguments to {@link SessionRegistry.register}. */ export interface RegisterArgs { account_identity: string; auth_mode?: AuthMode; /** * `undefined` keeps any existing expiry on re-registration; an explicit * value (including `null`) replaces it. This distinction matters — coercing * with `?? null` would silently wipe a previously-set expiry. */ auth_expires_at?: string | null; } /** * Per-process, in-memory registry of signed-in sessions. The constructor takes * no arguments, so it's safe to instantiate per test. Sessions are keyed by * `session_id` but de-duplicated by `account_identity`. */ export declare class SessionRegistry { private readonly sessions; private activeId; /** * Register a new session, or refresh the existing one keyed by * `account_identity`. The first session registered becomes the active one. */ register(args: RegisterArgs): SessionToken; /** Switch the active session. Returns `false` if the id is unknown. */ setActive(sessionId: string): boolean; /** Look up a session by id (returns a copy, or `null`). */ get(sessionId: string): SessionToken | null; /** Snapshot of the full registry plus the active id. */ getContext(): SessionContext; /** The active session id, if any. */ activeSessionId(): string | null; /** Number of registered sessions. */ size(): number; /** * Resolve which session a tool call should route through: * - `requested` set and known → it; * - `requested` set but unknown → throws; * - `requested` undefined → the active session; * - no sessions registered → `null` (caller uses the default transport). */ resolve(requested: string | undefined): string | null; /** Clear all state. Test helper. */ reset(): void; } /** Construct a fresh in-memory {@link SessionRegistry}. */ export declare function createSessionRegistry(): SessionRegistry; /** Options for {@link registerSessionTools}. */ export interface RegisterSessionToolsOptions { /** * Tool-name prefix, e.g. `'zillow'` → `zillow_register_session`. This is the * one per-MCP knob; everything else is identical across the fleet. */ prefix: string; /** Human label for the service (defaults to the prefix). */ serviceLabel?: string; } /** * Register the structurally-identical session tool trio against `server`, * backed by `registry`. Replaces every MCP's hand-rolled `src/tools/sessions.ts`. * * `${prefix}_register_session` accepts an optional `mark_active` boolean * (default `false`): when `true`, the newly-registered session is immediately * made active in the same call (equivalent to a follow-up * `${prefix}_set_active_session`). Omitting it preserves the * first-registered-wins active-session behaviour. */ export declare function registerSessionTools(server: McpServer, registry: SessionRegistry, opts: RegisterSessionToolsOptions): void; /** * Given a full URL or just an origin, return the origin without a trailing * slash. Falls back to trimming a trailing slash for non-URL input. */ export declare function normalizeOrigin(input: string): string; /** Options for {@link SessionStore}. `T` is the persisted record shape. */ export interface SessionStoreOptions { /** Absolute path to the JSON file. Parent dirs are created as needed. */ filePath: string; /** Extract the unique key (e.g. origin / account id) from a record. */ keyOf: (session: T) => string; /** * Normalize a key before storing/looking up. Defaults to * {@link normalizeOrigin}. The stored record's key field is also normalized. */ normalizeKey?: (key: string) => string; } /** * Disk-persisted session store with hardened file permissions. Records are kept * in a `Map` keyed by their normalized key, persisted as a JSON array (insertion * order). The file is written with mode `0600` and its directory `0700` so other * users on the machine cannot read captured credentials. * * `add` marks the record most-recently-used; {@link SessionStore.getActiveSession} * (and `get()` with no argument) returns it, so a tool call that omits an explicit * key picks up the latest session automatically. */ export declare class SessionStore> { private sessions; private mostRecentKey; private readonly filePath; private readonly keyOf; private readonly normalizeKey; constructor(opts: SessionStoreOptions); private loadFromDisk; /** * Move a corrupt store file to `.corrupt` (or `.corrupt-` if a * previous backup exists) so a subsequent save cannot overwrite the only * copy of the prior credentials. Best-effort: returns the backup path, or * `null` if the rename failed. */ private preserveCorruptFile; /** Serialize the store to its on-disk JSON form (array, insertion order). */ serialize(): string; /** Parse on-disk JSON back into a keyed `Map`; empty map on invalid input. */ private deserialize; private saveToDisk; /** Insert or replace a record, normalizing its key and marking it active. */ add(session: T): void; /** Look up by key; with no key, returns the active (most-recent) session. */ get(key?: string): T | null; /** The most-recently-added session, or `null`. */ getActiveSession(): T | null; /** All sessions in insertion order. */ list(): T[]; /** Remove a session; fixes up the active pointer. Returns whether it existed. */ remove(key: string): boolean; /** Clear in-memory state without touching disk. Test helper. */ resetForTest(): void; } /** Refresh proactively this many ms before the access token expires. */ export declare const TOKEN_REFRESH_SKEW_MS: number; /** A bearer access token + (optional) refresh token + absolute expiry. */ export interface BearerTokens { accessToken: string; /** Refresh token, if the flow uses one. */ refreshToken?: string; /** Absolute expiry in epoch milliseconds. */ expiresAt: number; } /** Result a {@link TokenManagerOptions.refresh} call must return. */ export interface RefreshedTokens { accessToken: string; /** Omit to keep the current refresh token (rotation is optional). */ refreshToken?: string; expiresAt: number; } /** Options for {@link TokenManager}. */ export interface TokenManagerOptions { /** Initial tokens (typically from env or a one-shot bootstrap). */ initial: BearerTokens; /** * Exchange the current refresh token for fresh tokens. Called at most once * per concurrent burst (the in-flight promise is shared). */ refresh: (refreshToken: string) => Promise; /** * Override the skew window (ms before expiry that triggers a proactive * refresh). Defaults to {@link TOKEN_REFRESH_SKEW_MS} (5 minutes). */ skewMs?: number; } /** * Manages a bearer access token's lifecycle: * * - **Proactive:** {@link TokenManager.getAccessToken} refreshes when the token * is within `skewMs` (default 5 min) of expiry, returning a still-valid token. * - **Reactive:** {@link TokenManager.withAuth} runs a request, and on a `401` * refreshes once and replays exactly once (no infinite loop). * - **Race-safe:** concurrent refreshes coalesce onto a single in-flight promise * (semaphore), so a burst of callers triggers exactly ONE token exchange. The * in-flight promise is cleared on settle so a later refresh can run again. */ export declare class TokenManager { private accessToken; private refreshToken; private expiresAt; private readonly refreshFn; private readonly skewMs; private inFlight; constructor(opts: TokenManagerOptions); /** Whether the token is within the skew window of (or past) expiry. */ private needsRefresh; /** * Single-flight refresh. Concurrent callers share one in-flight promise; it is * cleared on settle (success or failure) so a subsequent refresh can proceed. */ refreshNow(): Promise; /** Get a valid access token, refreshing proactively inside the skew window. */ getAccessToken(): Promise; /** Current absolute expiry (epoch ms). */ getExpiresAt(): number; /** * Run an authenticated request with reactive 401-replay. `call` receives a * valid access token and returns a `Response`. On `401`, the token is * refreshed once and `call` is invoked again exactly once. * * Guarded against double-refresh: if a concurrent caller already rotated the * token while this request was in flight (single-flight settled and cleared), * a late `401` from a request sent under the OLD token does NOT trigger a * second refresh — under refresh-token rotation that would consume and * invalidate the freshly-issued refresh token. It just replays with the * current token. */ withAuth(call: (accessToken: string) => Promise): Promise; } /** * The minimal cookie-session shape the manager understands. Callers usually * supply a richer `S` (extra cookies, a parsed JWT, a frame id, …) — only * `cookieHeader` is structurally required, `csrfToken` is the common optional * second field (Django/Rails CSRF rotation). */ export interface CookieSession { /** The `Cookie:` request header value for authenticated calls. */ cookieHeader: string; /** A rotating CSRF token, when the site uses one (e.g. Evite's `X-CSRFToken`). */ csrfToken?: string; } /** * Options for {@link CookieSessionManager}. `S` is the caller-defined session * shape; `R` is the response type `withSession`'s `call` resolves to, defaulting * to the web {@link Response} (override it for a custom/non-fetch transport). */ export interface CookieSessionManagerOptions { /** * Site-specific login that mints a fresh cookie session. The manager owns * *when* this runs — lazily on first {@link CookieSessionManager.ensure}, * and again after {@link CookieSessionManager.invalidate} (or an expiry * detected by {@link CookieSessionManagerOptions.isExpired}). Called at most * once per concurrent burst (the in-flight promise is shared, single-flight). */ login: () => Promise; /** * Decide whether a response indicates the session expired and a re-login is * warranted. This is the injection point for body/URL heuristics: status * codes alone are insufficient — SignUpGenius serves a `200` HTML login page * on expiry, and Artsonia expires by redirecting away from the target URL. * May read the body/headers (return a promise) or just the status (sync). * When `R` is the web `Response` (the default), the body is NOT consumed for * you — if you read it, pass a clone (`res.clone()`) so the caller can still * read the original. (A custom `R` has whatever read semantics you give it.) * * **Optional.** Omit it for ensure-only consumers with no per-request expiry * path (e.g. Skylight, whose re-auth lives in {@link TokenManager}): the * default `() => false` treats every response as non-expired, so * {@link CookieSessionManager.withSession}'s replay path simply never triggers. */ isExpired?: (res: R) => boolean | Promise; /** * Distinguish a *permanent* configuration error (missing/invalid credentials) * from a *transient* login failure (network blip, 5xx, login rate-limit). A * permanent error is cached and rethrown on every subsequent {@link ensure} * (the server can never recover without new config); a transient error leaves * state unset so the next {@link ensure} retries the login. Mirrors Skylight's * `NO_ENV_CONFIG_MARKER` check. Defaults to treating ALL failures as transient * (always retry) — the safe default for sites with no config/transient split. */ isPermanentError?: (err: unknown) => boolean; /** * Proactive session TTL, in milliseconds. A session older than this (by * login/seed time) is treated as stale: the next {@link ensure} invalidates * and re-logs-in (single-flight — a stale burst coalesces onto ONE login). * Omit for reactive-only expiry via {@link isExpired}. Mirrors * infinitecampus's 5-hour `SESSION_TTL_MS` (which hand-rolled an * `ensureFresh` wrapper for exactly this) and {@link TokenManager}'s * proactive-refresh discipline. */ maxAgeMs?: number; /** Injectable clock for {@link maxAgeMs} staleness (defaults to `Date.now`) — for tests. */ now?: () => number; /** * Called when {@link CookieSessionManager.withSession}'s expiry-replay * re-login FAILS — by default that failure is swallowed and the original * expired-looking response is returned, which hides an actionable login * error (e.g. "fetchproxy bridge down — open a signed-in tab") behind a * generic 401. Use this hook to record the error (infinitecampus's * `lastLoginError` bookkeeping) or `throw err` inside it to surface the * login failure to the caller instead of the stale response. */ onReplayLoginError?: (err: unknown) => void; } /** * Cookie-session analog of {@link TokenManager}: owns a site's cookie-session * lifecycle with the same single-flight / replay / clear-on-settle discipline, * so the fleet's cookie-session MCPs stop re-implementing (and subtly * mis-implementing) it. Structurally prevents the audit-flagged class of bugs: * * - **Single-flight:** concurrent {@link ensure} callers coalesce onto ONE * in-flight {@link CookieSessionManagerOptions.login} (no thundering-herd * re-login against a rate-limited endpoint). * - **Clear-on-settle:** the in-flight promise is cleared whether it resolves * or rejects, so a *rejected* login never sticks — the next {@link ensure} * retries (fixes Evite's poisoned-promise H1 and Skylight's gap). * - **Exactly-one replay:** {@link withSession} re-logs-in and replays a request * AT MOST once on expiry; a persistent expiry surfaces rather than looping. * - **Heuristic expiry:** {@link CookieSessionManagerOptions.isExpired} is the * injection point for body/URL detection (SignUpGenius's 200-HTML-login-page, * Artsonia's redirect-away), not just status codes. * - **Permanent vs. transient:** only a permanent config error is cached * ({@link CookieSessionManagerOptions.isPermanentError}); transient login * failures stay retryable (Skylight's marker discipline). * * Two type parameters: `S` is the caller-defined session shape, and `R` is the * response type {@link CookieSessionManager.withSession}'s `call` resolves to, * defaulting to the web {@link Response}. The manager is response-agnostic — it * only hands `R` to {@link CookieSessionManagerOptions.isExpired} and never * inspects it — so override `R` for a custom/non-fetch transport (e.g. * Artsonia's `{ setCookie?, location?, url, body }`). Existing adopters that * write `CookieSessionManager` keep `R = Response` unchanged. * * {@link CookieSessionManagerOptions.isExpired} is **optional** and defaults to * `() => false`, so ensure-only consumers with no per-request expiry path * (Skylight) can omit it; `withSession` then simply never replays. * * Target consumers (the 5 cohort MCPs whose hand-rolled re-login this replaces): * `artsonia-mcp` (`AuthManager`), `canvas-parent-mcp` (`ensureAuth` + 401-replay, * the reference impl), `evite-mcp` (`getSession`/`reauthenticate` + CSRF), * `signupgenius-mcp` (`ensureAuth` + 401/403/200-HTML replay), and `skylight-mcp` * (`makeGetClient` single-flight + `NO_ENV_CONFIG_MARKER` caching). */ export declare class CookieSessionManager { private session; private inFlight; /** A cached *permanent* config error: once set, every `ensure` rethrows it. */ private permanentError; /** When the current session was minted (login) or installed (seed) — for maxAgeMs. */ private sessionAt; private readonly loginFn; private readonly isExpiredFn; private readonly isPermanentErrorFn; private readonly maxAgeMs; private readonly now; private readonly onReplayLoginErrorFn; constructor(opts: CookieSessionManagerOptions); /** The current session, or `undefined` before the first successful login. */ get current(): S | undefined; /** True when {@link CookieSessionManagerOptions.maxAgeMs} says the session is too old. */ private isStale; /** * Return the current session, or single-flight a login if there is none. * * Concurrent callers share one in-flight login; the in-flight promise is * cleared on settle (success OR failure) so a rejected login never sticks and * the next call retries. A login error classified permanent by * {@link CookieSessionManagerOptions.isPermanentError} is cached and rethrown * on every later call; a transient error is not cached (next call retries). * With {@link CookieSessionManagerOptions.maxAgeMs} set, a session past its * TTL is invalidated first, so the call falls through to a (single-flight) * re-login. */ ensure(): Promise; /** * Install an externally-minted session (e.g. infinitecampus's CUPS linked- * district discovery, which mints sessions outside {@link * CookieSessionManagerOptions.login}). The seed becomes the current session * with a fresh {@link CookieSessionManagerOptions.maxAgeMs} clock, and any * in-flight login is DETACHED: its waiters still receive its result, but a * late resolution will not overwrite the seed. A cached permanent config * error is left intact — the login path is still misconfigured, and the next * post-seed re-login should keep saying so. */ seed(session: S): void; /** * One login attempt. Self-clears `inFlight` on settle so a rejected login * never sticks, and stamps the resulting session only while it's still the * live attempt (an `invalidate()` mid-flight cleared `inFlight` — a stale * resolution must not re-stamp the dropped session over the new state). */ private runLogin; /** * Drop the current session (and any in-flight login) so the next * {@link ensure} re-runs {@link CookieSessionManagerOptions.login}. Does NOT * clear a cached permanent config error — that only clears on a fresh process * (new config). Used to recover from a detected session expiry. */ invalidate(): void; /** * Run an authenticated `call` with the current session and reactive * expiry-replay. `call` receives the session and returns a `Response`. If * {@link CookieSessionManagerOptions.isExpired} flags the response, the * session is invalidated, a single-flight re-login runs, and `call` is * replayed EXACTLY once. A persistent expiry surfaces the (second) response * rather than looping. If the re-login itself fails, the original * (expired-looking) response is returned so the caller can surface a clean * sign-in error rather than the login failure. * * `call` resolves to `R` (the generic response type, default {@link Response}); * the manager passes it untouched to {@link CookieSessionManagerOptions.isExpired} * and returns it untouched, so a custom transport type flows through cleanly. */ withSession(call: (session: S) => Promise): Promise; } /** Construct a {@link CookieSessionManager}. */ export declare function createCookieSessionManager(opts: CookieSessionManagerOptions): CookieSessionManager; //# sourceMappingURL=index.d.ts.map