import type { TokenStore } from '../token-store.js'; import type { RateLimiter } from '../rate-limit.js'; import type { AuditSink } from '../audit.js'; export type McpRouterOptions = { /** Path prefix for the MCP endpoint. Default: '/agent/mcp'. */ path?: string; /** MCP server name shown in Claude Desktop. Default: 'agent'. */ serverName?: string; /** MCP server version string. Default: '1'. */ serverVersion?: string; /** Description for the connect_session tool. */ connectDescription?: string; /** * Hard ceiling on concurrently-retained MCP sessions. Each session * costs a transport plus a fully-registered `McpServer` (16 tools with * their Zod schemas — ~113 KB measured), so this is the number that * bounds the endpoint's memory. An `initialize` that cannot free a * slot is refused with 503 rather than served a smaller allocation. * Default: 64. */ maxSessions?: number; /** * How many of `maxSessions` may be held by sessions that arrived * WITHOUT a bearer and have not yet completed `connect_session`. The * endpoint is deliberately reachable without a bearer — that is where * `connect_session` happens — so this is the quota an anonymous caller * can reach: within it a new `initialize` evicts the least-recently- * used provisional session, and it can never displace an * authenticated one. Default: 16. */ maxUnauthenticatedSessions?: number; /** * How many sessions ONE identity (one `tid`) may hold at a time. * Reaching it evicts that identity's own least-recently-used session * rather than refusing the new one. * * This is the anonymous quota's dual, and it matters for the same * reason: an authenticated session is deliberately never evicted for * an anonymous caller, so without this cap a single VALID bearer * presented `maxSessions` times fills the endpoint with sessions * nothing can reclaim, and every later caller — including that * bearer's own owner — gets a 503 until an idle TTL lapses. It needs * no attacker: a client that crash-reconnects that often inside * `idleTtlMs` reaches it by accident. Sessions count against their * identity whether the bearer arrived at `initialize` or was bound * later by `connect_session`. Default: 8. */ maxSessionsPerIdentity?: number; /** * ABSOLUTE lifetime, in ms, of a provisional session (no bearer, no * `connect_session`) — measured from its `initialize`, never from its * last request. This is the ONLY clock a provisional session runs on. * * There used to be an idle TTL beside it (`unauthenticatedTtlMs`, 60 * s), and it was the wrong shape twice over. It bounded nothing: the * memory bound is `maxUnauthenticatedSessions`, which caps how many * provisional sessions exist at all, and the sweep ran on every route * call regardless of pressure — so it reclaimed sessions on an empty * endpoint, buying no memory. Meanwhile it broke the pairing flow the * docs describe, which is HUMAN-paced: the client initializes at * startup and the session then idles while a person opens the app, * clicks "Connect with Claude", copies the snippet and pastes it. The * MCP SDK client does not re-initialize on a 404, so that reclaim * surfaced as a thrown error, not a reconnect. An idle provisional * session is now reclaimed only when the quota is CONTENDED, by the * LRU eviction in `reserveSlot` — which is the pressure the bound * exists for. * * What this clock is for is the case the quota does not cover: * traffic. An idle window is refreshable for the price of an empty * POST, so pinging under it held a quota slot forever. Nothing * refreshes this one, so a session that has not authenticated by the * deadline is closed however much traffic it carries. An * authenticated session is NOT capped this way; it moves to * `idleTtlMs`. * * Default: 30 min — the pairing window has to fit a person, and a * provisional session holds no bearer, so the only thing this bounds * is one slot of a quota that is already bounded. */ unauthenticatedMaxLifetimeMs?: number; /** * Idle window, in ms, after which an AUTHENTICATED session is closed * and swept. Bounds a session whose client crashed or dropped without * sending the DELETE the protocol relies on, and is what releases that * session's plaintext bearer from `McpSessionMap`. Default: 30 min. */ idleTtlMs?: number; /** * How many dropped session IDs are remembered as RESURRECTABLE — the * bound on the tombstone FIFO behind session resurrection (#149). * * The endpoint's memory bound is LRU over provisional sessions, and a * pairing session is the LRU provisional BY CONSTRUCTION: it is idle * precisely because a human is reading the pairing panel. So an * unrelated anonymous burst evicts it, `connect_session` 404s, and * the MCP SDK cannot recover — 1.29.0 has no 404 case on the POST * path and clears `_sessionId` only in `terminateSession()`. Rather * than make the pairing un-evictable (which turns a full quota into a * denial window — the #102 defect), a dropped id is remembered and * the session REBUILT under it on the next request. * * A tombstone is a string and a timestamp, but it is still state a * caller can cause, so it is a FIFO with a hard ceiling: past it the * oldest ids are forgotten and answer 404 again. Default: * `maxSessions * 4`. */ maxResurrectableSessions?: number; }; export type McpRouterDeps = { coreRouter: (req: Request) => Promise; tokenStore: TokenStore; lapBasePath: string; /** * Rate limiter for the session-ALLOCATING path (a POST with no * `mcp-session-id`). Required, not optional: this router runs ahead of * `core.router`, so a limiter it forgets to consult is a limiter that * never sees the endpoint at all — which is exactly how the MCP * surface stayed unthrottled while every LAP route was covered. * Requests on an ESTABLISHED session are not checked here; their tool * handlers reach LAP through `coreRouter`, which gates them on the * per-token bucket. */ rateLimiter: RateLimiter; /** * Bucket key for the caller of a session-allocating request. Defaults * to `clientIpOf` with NO proxy trust — one shared bucket for every * caller whose address this process cannot establish. It deliberately * does NOT read `X-Forwarded-For` by default: that is a caller-written * header on a direct-to-origin deployment, and one bucket per * caller-chosen value throttles nobody. `createLluiAgentCore` builds * the real resolver from `trustProxy`/`clientAddress` and both hosts * pass it in, so this surface and `/agent/mint` key identically. */ clientIp?: (req: Request) => string; /** * Audit sink for REFUSALS on the session-allocating path — the 401, * the 429 and the capacity 503. `/agent/mint` audits the same class * (`mint.ts`), and this surface is the one an unauthenticated caller * can reach, so leaving its refusals untraced is the wrong asymmetry. * Successful traffic is audited downstream by the LAP gate, which * every tool handler goes through. * * A capacity refusal is written as `rate-limited` with * `detail.reason = 'session-capacity'`: it IS a resource refusal, and * `AuditEvent` lives in `protocol.ts`, which this change deliberately * does not touch. */ auditSink?: AuditSink; /** Sliding (inactivity) TTL in ms; folded into the connect verify. */ slidingTtlMs?: number; /** Wall clock in ms; injectable for tests. */ now?: () => number; }; /** * The MCP router plus the two read-only diagnostics that make its * resource bound assertable from outside. Assignable anywhere a plain * `(req) => Promise` is expected. */ export type McpRouter = ((req: Request) => Promise) & { /** * Sessions currently occupying a slot: retained (transport + * `McpServer` pair) PLUS reserved-but-not-yet-registered ones. The * in-flight half has to be in this number or it is not the number the * quota is enforced on — a concurrent burst is exactly the case where * the two differ. */ liveSessionCount(): number; /** * True while `mcpSessionId` is RETAINED — its transport and * `McpServer` still allocated. * * Since session resurrection (#149) a 404 probe no longer answers * this: a dropped id the server still remembers is REBUILT on the * next request, which is the whole point. So "was this session * evicted" has to be asked directly, and the eviction/quota tests are * where it matters — a bound that is only checked through a probe * that silently repairs what it is probing checks nothing. */ hasLiveSession(mcpSessionId: string): boolean; /** * True when `mcpSessionId` still holds a bearer token bound by * `connect_session`. Goes false the moment the session is reclaimed — * not retaining those plaintext tokens past a session's usefulness is * half of what the session bound is for. */ hasBoundToken(mcpSessionId: string): boolean; /** * How many dropped ids are currently REMEMBERED as resurrectable — * the occupancy of the tombstone FIFO, bounded by * `maxResurrectableSessions`. * * A tombstone is cheap but it is still caller-caused state, so it is * bounded, and a bound that cannot be read from outside cannot be * asserted. It is also the only window onto the INCREMENTAL * reclamation (#188): whether an expired tombstone has been reclaimed * yet is invisible through the request surface by design — * `resurrectable()` re-checks the deadline at lookup, so an unswept * entry answers exactly like a swept one. * * Counts entries, not live ones: an entry past its deadline that this * request's slice has not reached yet is still occupying memory and * still occupying a FIFO slot, which is precisely what a caller of * this wants to know. */ retainedTombstoneCount(): number; }; /** * Build a WHATWG-compatible MCP router that mounts at `opts.path`. * Integrates into the agent core's fetch-style router by prepending * this function's result in the request chain. * * Uses `WebStandardStreamableHTTPServerTransport` (WHATWG, runtime- * neutral) rather than the Node-only `StreamableHTTPServerTransport`. * * ── RESOURCE DISCIPLINE ──────────────────────────────────────────── * A POST without an `mcp-session-id` allocates a transport AND a full * `McpServer`. That is deliberately reachable without a bearer — * `connect_session` is where this protocol authenticates — so the * ALLOCATION is what has to be bounded, in four parts that all have to * hold together: * * 1. Every allocation goes through the rate limiter, keyed by client * IP (this router runs BEFORE `core.router`, so nothing else will). * 2. A bearer that IS presented must verify, or the request is refused * 401 having allocated nothing. Fail closed: the no-bearer path * stays open, the invalid-bearer path does not. * 3. Sessions are swept — an AUTHENTICATED one on an idle timestamp * (which is what releases its plaintext bearer), a PROVISIONAL one * on an absolute lifetime from `initialize`. Either way a client * that vanished without the DELETE the protocol assumes is * reclaimed with no help from it. * 4. Provisional sessions have their own quota inside `maxSessions`, * LRU-evicted, so anonymous churn can never displace an * authenticated session. With no slot to free, `initialize` 503s. * * (3) is the bound on a session's LIFETIME; (4) is the bound on the * endpoint's MEMORY, and it is the one that has to be tight. An idle * provisional session is therefore left alone until the quota is * actually contended — reclaiming it below the quota costs the * human-paced pairing flow and buys nothing back. * * ── RESURRECTION ─────────────────────────────────────────────────── * (4) still costs a real user their pairing (#149): under contention * the LRU provisional session IS the pairing, because it is idle * precisely while a human reads the panel — and the LRU key is * caller-refreshable, so an adversary pinging its own sessions makes * the victim SELECTABLE rather than incidental. The MCP SDK cannot * recover from the resulting 404. So the fix is recoverability, not * prevention: an id this server ISSUED and has since dropped is * remembered (bounded FIFO, same 30-minute clock) and its session * REBUILT under the same id, with the triggering request replayed. The * client never learns anything happened. * * A resurrect is an ALLOCATION and is treated as one — same rate * limiter, same fail-closed bearer check, same `reserveSlot`, same * 401/429/503 refusals — and what it rebuilds is PROVISIONAL: the * bearer binding went with the session, so nothing is escalated (a * VALID bearer buys admission on `initialize` and nothing at all here). * A DELETE is the one teardown that is NOT remembered: an explicit * termination has to stay terminated. What this trades is a hard bound * on how many * sessions may EXIST (unchanged) for a softer bound on allocation * CHURN: replaying N tombstoned ids forces N rate-limited, * quota-bounded re-allocations. Under a SUSTAINED full quota a * resurrect still 503s; this recovers a burst, not a siege. */ export declare function createMcpRouter(deps: McpRouterDeps, opts?: McpRouterOptions): McpRouter; //# sourceMappingURL=router.d.ts.map