agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,075 lines • 46.1 kB
JavaScript
"use strict";
/**
* pool — the state behind `mcpClientPool`: who holds which client, what is
* charged, and what ends when.
*
* Pattern: one state machine, mutated only between awaits.
* Role: §§ 5–7 of the design. Every mutation of the sets and the budget runs
* synchronously — a check and the move it allows are never split by
* an await — so two callers cannot both take the last slot.
*
* - ADMISSION: every listing, call, acquire and ending is admitted against
* `maxOperations` before anything is sent. At each interaction the lazy
* sweep (revoked leases, idle ends, deferred ends) runs FIRST, so it
* reserves its endings before the new operation is admitted.
* - ONE OPEN PER KEY, from a plan resolved at that moment, under the pool's own
* signal and `initTimeoutMs`, never a caller's. A caller that gives up
* detaches alone; the operation that started the open keeps its charge until
* the open's work settles. Past the deadline every waiter is refused at once
* (`init-timeout`), and until the open's work settles the key cannot open
* again. A failure reaches every current waiter and is not cached.
* - THE HANDOFF: every operation waiting for an open holds a RESERVATION on
* the client until it takes its lease, so between the open settling and
* that lease the client is busy — nobody can evict, idle-end or end it. The
* client's state is checked again after every wait, and the lease taken in
* the same step: a lease is never handed out on a client that is ending.
* - MAKING ROOM: the principal's, then the pool's, least recently used IDLE
* client moves to the ending set — or `PoolFullError` at once.
* - ENDING reserves an ending slot and an operation slot FIRST; when either is
* full the client stays in the open set, counted, reported deferred, and is
* ended when slots free. Every end goes through `terminate()` — but a client
* whose handle this process lost: nothing can be sent for it, so its ending
* is its local close alone, charged the same way until it settles.
* - SETTLEMENT, never an abort, returns a slot (`work.ts`).
* - NOTHING UNCHECKED: on a pooled client every request carries a guard — its
* call's, or the client's open, listing or ending — and a request with none
* is refused before it is sent (`request-unmatched`), at the watched `send`
* and at the pool's own fetch alike.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Pool = void 0;
const abort_js_1 = require("../../lib/abort.js");
const toolSessions_js_1 = require("../../tools/toolSessions.js");
const clientPort_js_1 = require("../clientPort.js");
const mcpClient_js_1 = require("../mcpClient.js");
const requestGuard_js_1 = require("../requestGuard.js");
const clientSet_js_1 = require("./clientSet.js");
const definitions_js_1 = require("./definitions.js");
const errors_js_1 = require("./errors.js");
const encode_js_1 = require("../../memory/identity/encode.js");
const identityKey_js_1 = require("./identityKey.js");
const options_js_1 = require("./options.js");
const plan_js_1 = require("./plan.js");
const pooledClient_js_1 = require("./pooledClient.js");
const record_js_1 = require("./record.js");
const work_js_1 = require("./work.js");
class Pool {
settings;
clients = new clientSet_js_1.ClientSet();
budget;
shared = new definitions_js_1.SharedDefinitions();
reporter;
/** The pool's own signal: what every open, listing and call runs under; aborted at `close()`. */
lifetime = new AbortController();
leases = new Set();
settleWaiters = [];
/** Waiting for some client to become idle (`terminate`, `close`). */
idleWaiters = new Set();
closed = false;
unresolvedEndings = 0;
constructor(settings) {
this.settings = settings;
this.budget = new work_js_1.OperationBudget(settings.maxOperations);
this.reporter = new record_js_1.Reporter(settings.name, settings.onEvent);
this.budget.whenReleased((operation) => {
if (operation.settledLate) {
this.reporter.report({
kind: 'settled-late',
work: operation.phase,
durationMs: Math.max(0, this.now() - operation.admittedAt),
});
}
this.retryDeferred();
this.notifySettled();
});
}
now() {
return this.settings.now();
}
get signal() {
return this.lifetime.signal;
}
// ─── Admission ─────────────────────────────────────────────────────
/**
* The lazy sweep, run at the start of every interaction: leases past
* `maxLeaseMs` revoked, idle clients ended, deferred endings retried — all of
* it before a new operation is admitted.
*/
interact() {
this.revokeExpiredLeases();
this.sweepIdle();
this.retryDeferred();
}
/**
* Admit one operation, or refuse at once: `closed`, or `PoolFullError`
* (`limit: 'operations'`) — before anything is sent.
*/
admit(kind, phase, callerSignal) {
if (this.closed)
throw this.refused('closed');
const operation = this.budget.admit(kind, phase, [this.lifetime.signal, callerSignal], this.now());
if (operation === undefined)
throw this.full('operations');
return operation;
}
refused(reason, cause) {
return new errors_js_1.McpPoolRefusedError(reason, this.settings.name, cause === undefined ? {} : { cause });
}
full(limit) {
const max = limit === 'pool'
? this.settings.maxClients
: limit === 'principal'
? this.settings.maxClientsPerPrincipal
: limit === 'ending'
? this.settings.maxEnding
: this.settings.maxOperations;
const busy = this.clients.openClients().filter((c) => c.busy).length;
return new errors_js_1.PoolFullError(this.settings.name, limit, max, busy, this.clients.endingCount, this.budget.count);
}
// ─── Clients and leases ────────────────────────────────────────────
/**
* A library lease on `key`'s client for `operation`, opening one when the key
* has none (inside the operation's slot). `plan` is the plan resolved for a
* new client; a key that already has one keeps its own. The lease is
* released when the operation SETTLES.
*/
async leaseClient(key, operation, facts, plan) {
facts.keyHash = key.keyHash;
for (let round = 0; round < 3; round += 1) {
const current = this.clients.currentFor(key.key);
if (current === undefined) {
const opened = this.openFor(key, operation, facts, plan);
const leased = await this.leaseOnceOpen(opened, operation, facts);
if (leased !== undefined)
return leased;
continue;
}
if (current.abandoned)
throw this.refused('init-timeout');
if (current.state === 'open') {
if (facts.client === 'refused')
facts.client = 'reused';
return this.takeLease(current, operation, facts);
}
// Opening: join it, inside this operation's own slot.
facts.client = 'joined';
facts.clientId = current.clientId;
facts.generation = current.generation;
const leased = await this.leaseOnceOpen(current, operation, facts);
if (leased !== undefined)
return leased;
}
throw this.refused('init-failed');
}
/**
* Wait for `client`'s open inside `operation`, holding a reservation on it
* all the while — between the open settling and this lease the client is
* busy, so nobody can evict, idle-end or end it — then check it again and
* lease it in the same step, with no wait between. `undefined` when it no
* longer serves: the caller's next round finds the key's next client.
*/
async leaseOnceOpen(client, operation, facts) {
client.reserved += 1;
try {
await this.waitOpen(client, operation);
}
catch (error) {
client.reserved -= 1;
this.becameIdle(client);
// The pool closed while it waited: that is the refusal, whatever the wait threw.
throw this.closed ? this.refused('closed') : error;
}
client.reserved -= 1;
if (!client.serving) {
this.becameIdle(client);
return undefined;
}
try {
return this.takeLease(client, operation, facts);
}
catch (error) {
this.becameIdle(client);
throw this.closed ? this.refused('closed') : error;
}
}
/** `client` may have just become idle: a deferred ending may run, and a waiter for idleness may go on. */
becameIdle(client) {
if (client.busy)
return;
this.retryDeferred();
this.notifyIdle();
}
takeLease(client, operation, facts) {
// The caller detached while it waited, or before it was admitted: nothing
// more is done for it. The cached path reaches here with no wait at all,
// so this is its only check.
operation.check();
client.leases += 1;
client.usedAt = this.now();
// Used again: an idle end or an eviction waiting for slots no longer applies.
if (client.deferredEnd === 'idle' || client.deferredEnd === 'evicted') {
client.deferredEnd = undefined;
}
facts.clientId = client.clientId;
facts.generation = client.generation;
if (client.initMs !== undefined && (facts.client === 'opened' || facts.client === 'joined')) {
facts.initMs = client.initMs;
}
operation.onSettled(() => this.releaseLease(client));
return client;
}
/** One lease ended: the client may now be idle, and a deferred ending may run. */
releaseLease(client) {
client.leases -= 1;
client.usedAt = this.now();
if (!client.busy && client.deferredEnd !== undefined) {
this.endClient(client, client.deferredEnd);
}
this.notifyIdle();
}
notifyIdle() {
for (const check of [...this.idleWaiters])
check();
}
/** An app lease: a hold on a client for as long as the app keeps it, at most `maxLeaseMs`. */
holdLease(client) {
client.leases += 1;
client.usedAt = this.now();
if (client.deferredEnd === 'idle' || client.deferredEnd === 'evicted') {
client.deferredEnd = undefined;
}
const lease = { client, takenAt: this.now(), revoked: false, released: false };
this.leases.add(lease);
return lease;
}
/** End an app lease — at most once, whether released or revoked. */
endLease(lease) {
if (!this.leases.delete(lease))
return;
this.releaseLease(lease.client);
}
revokeExpiredLeases() {
const now = this.now();
for (const lease of [...this.leases]) {
if (now - lease.takenAt < this.settings.maxLeaseMs)
continue;
lease.revoked = true;
this.endLease(lease);
this.reporter.report({
kind: 'lease-revoked',
clientId: lease.client.clientId,
keyHash: lease.client.key.keyHash,
durationMs: Math.max(0, now - lease.takenAt),
});
}
}
/** Every app lease is revoked: the pool is closing. */
revokeAllLeases() {
for (const lease of [...this.leases]) {
lease.revoked = true;
this.endLease(lease);
}
}
// ─── The open ──────────────────────────────────────────────────────
/**
* Open a client for `key`: make room, resolve its plan, admit it to the open
* set, and start the one open. The operation that started it tracks the open's
* settlement, so its charge stays until the open's work has settled.
*/
openFor(key, operation, facts, resolved) {
this.makeRoom(key);
let plan;
try {
plan = resolved ?? (0, plan_js_1.resolvePlan)(this.settings, key);
}
catch (cause) {
throw this.refused('init-failed', cause);
}
const { generation, previous } = this.clients.nextIncarnation(key.key);
const client = new pooledClient_js_1.PooledClient(key, plan, generation, this.now());
this.clients.admit(client);
facts.client = 'opened';
facts.clientId = client.clientId;
facts.generation = generation;
if (previous !== undefined)
facts.previous = previous;
const work = this.startOpen(client);
operation.phase = 'open';
operation.track(work.whenSettled());
return client;
}
/** The one open of `client`, under the pool's own signal and `initTimeoutMs`. */
startOpen(client) {
const work = new work_js_1.Work('open', [this.lifetime.signal]);
const startedAt = this.now();
let resolveReady;
let rejectReady;
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
ready.catch(() => undefined);
client.opening = { ready, work, startedAt };
client.phase = work;
let timedOut = false;
let opened;
let halfOpen;
const timer = setTimeout(() => {
timedOut = true;
client.abandoned = true;
work.abort(initTimeout());
rejectReady(this.refused('init-timeout'));
}, this.settings.initTimeoutMs);
const seam = this.seamFor(client, work.signal);
let pending;
try {
pending = (this.settings.open ?? mcpClient_js_1.openPooledClient)(this.clientOptions(client), seam);
}
catch (error) {
pending = Promise.reject(error);
}
work.track(pending);
pending.then((result) => {
clearTimeout(timer);
opened = result;
client.client = result.client;
client.port = result.port;
if (!timedOut && !this.closed) {
client.state = 'open';
client.initMs = Math.max(0, this.now() - startedAt);
client.opening = undefined;
if (client.phase === work)
client.phase = undefined;
resolveReady();
this.notifyIdle();
}
else if (!timedOut) {
// The pool closed while it opened: nobody gets it; it is ended below.
timedOut = true;
rejectReady(this.refused('closed'));
}
work.finish();
}, (error) => {
clearTimeout(timer);
halfOpen = (0, clientPort_js_1.halfOpenOf)(error);
if (!timedOut) {
rejectReady(this.closed ? this.refused('closed') : this.refused('init-failed', error));
}
work.finish();
});
// Once the open's own work has settled: an abandoned open that opened a
// session is ended under the ending rule; a failed one frees its slot.
// Either way the client has stopped opening — a wait for idle clients
// (`terminate`, `close`) is told once its new state is in place.
work.onSettled(() => {
if (!timedOut && opened !== undefined) {
this.notifyIdle();
return;
}
client.opening = undefined;
if (client.phase === work)
client.phase = undefined;
// A failed open whose server opened a session all the same has a session
// to end: so does an abandoned open that succeeded.
const session = opened?.port ?? (halfOpen?.hasSession() === true ? halfOpen : undefined);
if (session === undefined) {
this.dropFailedOpen(client);
}
else {
if (opened === undefined)
client.port = endingOnly(session);
client.state = 'open';
client.closedToNew = true;
this.clients.retire(client, 'terminated');
this.endClient(client, 'abandoned-open');
}
this.notifyIdle();
});
return work;
}
dropFailedOpen(client) {
client.opening = undefined;
client.state = 'ended';
client.phase = undefined;
this.clients.drop(client);
this.retryDeferred();
this.notifySettled();
}
/** Wait for `client`'s open inside `operation`: the caller's signal detaches the caller only. */
async waitOpen(client, operation) {
const opening = client.opening;
if (opening === undefined) {
if (client.state === 'open')
return;
throw this.refused(client.abandoned ? 'init-timeout' : 'init-failed');
}
await (0, abort_js_1.untilAborted)(opening.ready, operation.signal);
}
/** What `mcpClient` is opened with: the connection-shaping options, the pool's signal. */
clientOptions(client) {
return {
name: this.settings.name,
transport: client.plan.transport,
signal: this.lifetime.signal,
...this.settings.client,
};
}
/** What only a pool asks of the client it opens (`clientPort.ts`). */
seamFor(client, connectSignal) {
return {
connectSignal,
wrapFetch: (inner, claim) => this.pooledFetch(client, inner, claim),
guardFor: (message) => this.guardOf(client, message),
onToolsChanged: () => this.toolsChangedOn(client),
};
}
/**
* The guard for a message on `client` that carries none of its own: a tool
* call always brings its call's guard, so one without it is refused; any
* other request runs under the client's open, listing or ending — and with
* none under way, it is refused too. Nothing pooled is sent unchecked.
*/
guardOf(client, message) {
if (isToolCall(message))
return this.unmatched;
return client.phase ?? this.unmatched;
}
/** The guard of a request nothing covers: it refuses, so the request is never sent. */
unmatched = {
check: () => {
throw this.refused('request-unmatched');
},
track: () => undefined,
};
/**
* The pool's own fetch, outermost on every pooled transport: a request is
* checked against its guard (the pooled call's, else the client's phase) and
* counted under it; the guard's signal is linked in; and the answer is read
* for the two pieces of evidence the pool classifies on — a 404 the MCP server
* itself sent to a request that carried its session id, and a handle this
* process lost. The client's own standalone stream (a GET), a notification and
* an answer to the server's own request are the client's, not an operation's:
* they pass untouched. Any other request with no guard is refused unsent.
*/
pooledFetch(client, inner, claim) {
return async (input, init) => {
// The client's own stream, a notification, an answer to the server —
// never refused, never cut by an operation; a cancellation is counted
// under its call at `send`.
if ((init?.method ?? 'GET').toUpperCase() === 'GET' || passesUnguarded(init?.body)) {
return inner(input, init);
}
const guard = claim(init?.body) ?? client.phase;
if (guard === undefined)
throw this.refused('request-unmatched');
guard.check();
const carriesSession = new Headers(init?.headers).has('mcp-session-id');
const signal = (0, abort_js_1.eitherSignal)(init?.signal ?? undefined, guard.signal);
const sent = inner(input, {
...(0, requestGuard_js_1.withGuard)(init, guard),
...(signal !== undefined && { signal }),
});
guard.track(sent);
try {
const response = await sent;
if (response.status === 404 && carriesSession && guard instanceof work_js_1.Work) {
if (await isServerAnswer(response))
guard.note('server-404');
}
return response;
}
catch (error) {
if ((0, toolSessions_js_1.isLocalSessionHandleLost)(error) && guard instanceof work_js_1.Work)
guard.note('handle-lost');
throw error;
}
};
}
// ─── Making room, and ending ───────────────────────────────────────
/**
* Make room for a new client of `key`: the principal's least recently used
* idle client, then the pool's, moves to the ending set — or `PoolFullError`
* at once, naming the budget. There is no queue.
*/
makeRoom(key) {
if (this.clients.principalCount(key.principal) >= this.settings.maxClientsPerPrincipal) {
const victim = this.clients.leastRecentlyUsedIdle((c) => c.key.principal === key.principal);
if (victim === undefined)
throw this.full('principal');
this.evict(victim);
}
if (this.clients.openCount >= this.settings.maxClients) {
const victim = this.clients.leastRecentlyUsedIdle(() => true);
if (victim === undefined)
throw this.full('pool');
this.evict(victim);
}
}
/**
* Move `victim` to the ending set to make room. When the slots cannot be
* reserved, the arrival is refused at once, and the victim stays in the open
* set, counted, reported deferred — ended once slots free.
*/
evict(victim) {
const reserved = this.reserveEnding();
if (typeof reserved === 'string') {
this.defer(victim, 'evicted');
throw this.full(reserved);
}
// A victim already waiting to end for a stronger reason ends for that one.
const waiting = victim.deferredEnd;
const reason = waiting !== undefined && waiting !== 'idle' ? waiting : 'evicted';
this.startEnding(victim, reason, reserved);
}
/** An ending slot and an operation slot, both, or which one is full. */
reserveEnding() {
if (this.clients.endingCount >= this.settings.maxEnding)
return 'ending';
return this.budget.admit('ending', 'ending', [], this.now()) ?? 'operations';
}
/**
* End `client` for `reason` — when it is idle and both slots can be reserved.
* Otherwise it stays in the open set, still counted, and is reported deferred
* (once); it is ended when its last lease ends or slots free.
*/
endClient(client, reason) {
if (client.state !== 'open' || client.busy || client.port === undefined) {
this.defer(client, reason);
return;
}
const reserved = this.reserveEnding();
if (typeof reserved === 'string') {
this.defer(client, reason);
return;
}
this.startEnding(client, reason, reserved);
}
defer(client, reason) {
const first = client.deferredEnd === undefined;
// A stronger reason replaces a weaker one: terminated, closed or expired
// outrank an idle end or an eviction.
if (first || client.deferredEnd === 'idle' || client.deferredEnd === 'evicted') {
client.deferredEnd = reason;
}
if (!first || client.state === 'ending' || client.state === 'ended')
return;
this.reporter.report({
kind: 'deferred',
clientId: client.clientId,
keyHash: client.key.keyHash,
reason,
durationMs: 0,
});
}
/**
* Move `client` into the ending set under `operation`, and end it through
* `terminate()` — or, for a lost handle, through its local close alone.
*/
startEnding(client, reason, operation) {
client.deferredEnd = undefined;
client.state = 'ending';
this.clients.moveToEnding(client, (0, clientSet_js_1.previousOf)(reason));
client.phase = operation;
const ending = client.lost === 'handle-lost'
? closeLocally(client)
: client.port.end();
client.endingOutcome = ending.outcome;
operation.track(ending.settled);
void ending.outcome.then((outcome) => {
this.noteEnded(client, reason, outcome);
operation.finish();
});
operation.onSettled(() => {
this.clients.ended(client);
client.state = 'ended';
client.phase = undefined;
// The ending slot is free only now: deferred endings may run, and a
// `settled()` waiting on the ending set may resolve.
this.retryDeferred();
this.notifySettled();
});
}
noteEnded(client, reason, outcome) {
if (!outcome.resolved)
this.unresolvedEndings += 1;
this.reporter.report({
kind: 'ended',
clientId: client.clientId,
keyHash: client.key.keyHash,
reason,
remote: outcome.remote,
resolved: outcome.resolved,
...(outcome.status !== undefined && { status: outcome.status }),
...(outcome.errorClass !== undefined && { errorClass: outcome.errorClass }),
durationMs: outcome.durationMs,
});
}
/**
* A client whose handle this process lost: nothing can be sent for it, so it
* leaves its key at once — the next use opens the key's next client — and is
* ended under the ending rule like any other: its ending is its LOCAL close,
* charged an ending slot and an operation slot until that close settles, its
* remote outcome recorded unresolved. Busy, or with no slots free, it waits —
* still counted in the open set, reported deferred.
*/
lostHandle(client) {
if (client.lost !== undefined || client.state !== 'open')
return;
client.lost = 'handle-lost';
client.closedToNew = true;
this.clients.retire(client, 'handle-lost');
this.endClient(client, 'handle-lost');
}
/** A client whose session the server ended: no new lease; ended (through `terminate()`) when idle. */
expired(client) {
if (client.lost !== undefined)
return;
client.lost = 'expired';
client.closedToNew = true;
this.clients.retire(client, 'expired');
this.endClient(client, 'expired');
}
sweepIdle() {
const cutoff = this.now() - this.settings.idleMs;
for (const client of this.clients.openClients()) {
if (client.state !== 'open' || client.busy || client.deferredEnd !== undefined)
continue;
// Retired only once its ending starts (`startEnding`): while it waits
// for slots, its own conversation may still use it.
if (client.usedAt <= cutoff)
this.endClient(client, 'idle');
}
}
/** Retry every deferred ending whose client is now idle. */
retryDeferred() {
for (const client of this.clients.openClients()) {
if (client.deferredEnd === undefined || client.busy || client.state !== 'open')
continue;
this.endClient(client, client.deferredEnd);
}
}
// ─── Lists ─────────────────────────────────────────────────────────
/** Where `client`'s definitions are served from: its shared entry, or its own list. */
servedListOf(client) {
const { context, shared } = client.definitions;
if (shared) {
return { list: this.shared.entryFor(context, client.key.tenant).list, shared: true, context };
}
return { list: client.list, shared: false, context };
}
/**
* The definitions context `client` serves under NOW: the app's `share` asked
* again over the client's own configuration. A changed answer moves the
* client to the new context and stops serving its own list, so every tool
* bound under the old one is refused before it is sent.
*
* @throws `binding-changed`, `share`'s error as its cause, when the answer
* cannot be had — never the old context
*/
refreshContext(client) {
if (this.settings.share === undefined)
return client.definitions;
let now;
try {
now = (0, plan_js_1.contextFor)(this.settings, client.key, client.plan.configHash);
}
catch (cause) {
throw this.refused('binding-changed', cause);
}
if (now.context !== client.definitions.context) {
client.definitions = now;
client.list.invalidate();
}
return now;
}
/** Does `context` still name the definitions context `client` serves under? False when it cannot be had. */
contextHolds(client, context) {
try {
return this.refreshContext(client).context === context;
}
catch {
return false;
}
}
/**
* The list `client` serves under NOW, listed again on it when it cannot be
* served as it stands — one listing at a time per list; a second caller joins
* it inside its own slot. Every attempt asks the app's names again
* (`refreshContext`), so a listing that a moved context discarded or refused
* at its send is listed once more under the context of now, never written
* into the one it began under. A listing raced by an invalidation is
* discarded too. Refused (`stale-tools`) when three attempts serve nothing.
* What it returns was checked against the context with no wait since.
*/
async ensureListed(client, operation) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const served = this.servableNow(client);
if (served.ready !== undefined)
return served.ready;
const { list } = served.wanted;
if (list.listing === undefined)
this.startListing(client, served.wanted, operation);
operation.phase = 'listing';
try {
await (0, abort_js_1.untilAborted)(list.listing, operation.signal);
}
catch (error) {
operation.check();
// Refused at its send because the context moved: asked again next round.
if (!isContextMoved(error))
throw error;
}
operation.check();
}
const served = this.servableNow(client);
if (served.ready !== undefined)
return served.ready;
throw this.refused('stale-tools');
}
/**
* What `client` can serve under the context of now — asked of the app's names
* here — or, when nothing can, the list it should list. Too long to share:
* the conversation's own client keeps that list, under the same context.
*/
servableNow(client) {
this.refreshContext(client);
const wanted = this.servedListOf(client);
const now = this.now();
const ttl = this.settings.definitionTtlMs;
if (wanted.list.servable(now, ttl))
return { ready: wanted, wanted };
// A context change invalidates the client's own list, so a servable one
// was written under the context of now.
if (wanted.shared && client.list.servable(now, ttl)) {
return { ready: { list: client.list, shared: false, context: wanted.context }, wanted };
}
return { wanted };
}
/**
* One listing on `client` for `served`'s context. Its requests are sent only
* while that context holds — asked at every boundary they cross — and its
* answer is written only if the context still holds when it arrives and the
* list's generation held: an answer the app's names moved under belongs to
* no context, and is dropped.
*/
startListing(client, served, operation) {
const { list } = served;
const began = list.generation;
const beganOwn = client.list.generation;
const work = new work_js_1.Work('listing', [this.lifetime.signal]);
work.guardWith(() => {
if (!this.contextHolds(client, served.context))
throw this.refused('binding-changed');
});
operation.track(work.whenSettled());
const port = client.port;
if (port === undefined)
throw this.refused('init-failed');
client.phase = work;
const pending = port.listing(work.signal);
work.track(pending);
list.listing = pending
.then((listing) => {
// Asked again before anything is kept: listed under one context,
// answered under another, it is no one's.
if (!this.contextHolds(client, served.context))
return;
const definitions = listing.listed
.map((listed) => (0, definitions_js_1.definitionOf)(listed))
.filter((d) => d !== undefined);
if (served.shared && definitions.length > options_js_1.MAX_SHARED_TOOLS) {
// Used by its own client, never shared.
client.list.write(beganOwn, definitions, listing.tools, this.now());
return;
}
list.write(began, definitions, served.shared ? undefined : listing.tools, this.now());
})
.catch((error) => {
this.classifyFailure(client, work, error);
throw error;
})
.finally(() => {
list.listing = undefined;
if (client.phase === work)
client.phase = undefined;
work.finish();
});
list.listing.catch(() => undefined);
}
/**
* A request failed: when the pool's fetch read the server's own 404 for the
* session, or this process lost the handle, the client leaves its key — the
* next use opens the next client under the SAME key — and the error says so.
*/
classifyFailure(client, work, error) {
if ((0, toolSessions_js_1.isLocalSessionHandleLost)(error) || work.evidence === 'handle-lost') {
this.lostHandle(client);
return 'handle-lost';
}
if (work.evidence === 'server-404') {
this.expired(client);
return 'expired';
}
return undefined;
}
// ─── Invalidation ──────────────────────────────────────────────────
/** The server said a client's tool list changed: that client's list and its shared entry. */
toolsChangedOn(client) {
client.list.invalidate();
const { context, shared } = client.definitions;
if (shared)
this.shared.peek(context)?.list.invalidate();
}
/** The app's permission signal: every list in the scope. */
permissionsChanged(scope) {
const matches = (0, identityKey_js_1.scopeMatcher)({
...(scope.tenant !== undefined && { tenant: scope.tenant }),
...(scope.principal !== undefined && { principal: scope.principal }),
});
for (const client of this.clients.openClients()) {
if (matches(client.key))
client.list.invalidate();
}
// A shared entry is used across the tenant's principals: any of them changing invalidates it.
const tenant = (0, encode_js_1.encodeIdentityField)(scope.tenant);
this.shared.invalidate((entry) => entry.tenant === tenant);
}
/** The server changed: every list. */
toolsChanged() {
for (const client of this.clients.openClients())
client.list.invalidate();
this.shared.invalidate(() => true);
}
// ─── Terminate, close, settle ──────────────────────────────────────
/**
* Stop new leases on every client `scope` names, at once; wait for their
* in-flight work up to `waitMs`; then end each one whose slots can be
* reserved. The rest are reported deferred and ended when they can be.
*/
async terminate(matches, reason, waitMs) {
const named = this.clients.openClients().filter((c) => matches(c.key));
for (const client of named) {
client.closedToNew = true;
this.clients.retire(client, (0, clientSet_js_1.previousOf)(reason) ?? 'terminated');
}
await this.waitIdle(named, waitMs);
return this.endNow(named, reason);
}
/** End each of `clients` whose slots can be reserved; the outcomes of those that started. */
endNow(clients, reason) {
const outcomes = [];
for (const client of clients) {
if (client.state !== 'open')
continue;
this.endClient(client, reason);
if (client.state === 'ending' && client.endingOutcome !== undefined) {
outcomes.push(client.endingOutcome);
}
}
return Promise.all(outcomes);
}
/** Wait until every client in `clients` is idle, or `waitMs` passed. */
waitIdle(clients, waitMs) {
if (!clients.some((c) => c.busy))
return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
this.idleWaiters.delete(check);
resolve();
};
const check = () => {
if (!clients.some((c) => c.busy))
done();
};
const timer = setTimeout(done, waitMs);
timer.unref?.();
this.idleWaiters.add(check);
});
}
/**
* Close: refuse new work, revoke app leases, abort in-flight work through the
* pool's own signal, then end every client as slots can be reserved — at most
* `maxEnding` at a time — in rounds, each bounded by `waitMs` (and each
* ending's answer by its own deadline). What could not be ended yet is
* reported deferred and ended when slots free; work still unsettled stays
* charged until it settles.
*/
async closeAll(waitMs) {
this.shutDown();
const all = this.clients.openClients();
for (const client of all) {
client.closedToNew = true;
this.clients.retire(client, 'terminated');
}
await this.waitIdle(all, waitMs);
const outcomes = [];
for (;;) {
const started = await this.endNow(all, 'closed');
if (started.length === 0)
break;
outcomes.push(...started);
if (!all.some((c) => c.state === 'open'))
break;
await this.waitSlots(waitMs);
}
for (const client of all) {
if (client.state === 'open' || client.state === 'opening')
this.defer(client, 'closed');
}
return outcomes;
}
/** Wait until an ending slot and an operation slot are free, or `waitMs` passed. */
waitSlots(waitMs) {
const free = () => this.clients.endingCount < this.settings.maxEnding && this.budget.hasRoom;
if (free())
return Promise.resolve();
return new Promise((resolve) => {
const done = () => {
clearTimeout(timer);
unsubscribe();
resolve();
};
const timer = setTimeout(done, waitMs);
timer.unref?.();
const unsubscribe = this.budget.whenReleased(() => {
if (free())
done();
});
});
}
/** Refuse new work, revoke app leases, abort in-flight work through the pool's signal. */
shutDown() {
this.closed = true;
this.revokeAllLeases();
this.lifetime.abort(poolClosed());
}
/** Resolve once every charged operation and every ending has settled. */
settled() {
if (this.quiet())
return Promise.resolve();
return new Promise((resolve) => this.settleWaiters.push(resolve));
}
quiet() {
return (this.budget.count === 0 &&
this.clients.endingCount === 0 &&
!this.clients.openClients().some((c) => c.state === 'opening'));
}
notifySettled() {
if (!this.quiet())
return;
for (const resolve of this.settleWaiters.splice(0))
resolve();
}
stats() {
const open = this.clients.openClients();
const busy = open.filter((c) => c.busy).length;
return {
open: open.length,
busy,
idle: open.length - busy,
opening: open.filter((c) => c.state === 'opening').length,
ending: this.clients.endingCount,
operations: this.budget.count,
entries: this.shared.size,
unsettled: this.budget.unsettled(),
unresolvedEndings: this.unresolvedEndings,
};
}
/** The bound shape of every definition in `served`, for a client of `key`. */
definitionsOf(served) {
return served.list.all();
}
/** The tools a list's client-own entries carry, by name (per-client lists only). */
ownTool(served, name) {
return served.shared ? undefined : served.list.tool(name);
}
}
exports.Pool = Pool;
/** A port that can only end: what a half-opened session leaves the pool. */
function endingOnly(halfOpen) {
return {
listing: () => Promise.reject(new Error('This client never opened.')),
bind: () => {
throw new Error('This client never opened.');
},
end: () => halfOpen.end(),
};
}
/** The reason an open is aborted at its deadline — a fixed sentence. */
function initTimeout() {
return Object.assign(new Error('The pooled client did not open within its deadline.'), {
name: 'TimeoutError',
});
}
/** The reason in-flight work is aborted when the pool closes — a fixed sentence. */
function poolClosed() {
return Object.assign(new Error('The MCP client pool was closed.'), { name: 'AbortError' });
}
/**
* A body the pool's fetch passes with no guard: a JSON-RPC notification (a
* method, no id) or an answer to the server's own request (an id, a result or
* an error, no method). Never refused, never counted here. Anything else — a
* request, a batch, a body that is not JSON-RPC — needs a guard.
*/
function passesUnguarded(body) {
if (typeof body !== 'string')
return false;
let message;
try {
message = JSON.parse(body);
}
catch {
return false;
}
if (message === null || typeof message !== 'object' || Array.isArray(message))
return false;
const fields = message;
if (typeof fields.method === 'string')
return fields.id === undefined;
return fields.method === undefined && ('result' in fields || 'error' in fields);
}
/** A listing refused at its send because the app's names moved: listed again under the new context. */
function isContextMoved(error) {
return error instanceof errors_js_1.McpPoolRefusedError && error.reason === 'binding-changed';
}
/** A `tools/call` request: a pooled one always brings its own call's guard. */
function isToolCall(message) {
return (message !== null &&
typeof message === 'object' &&
message.method === 'tools/call');
}
/**
* A lost handle's ending: nothing can be sent for it, so the remote outcome is
* known at once — `handle-lost`, unresolved — and the work is the local close
* alone, which the ending stays charged for until it settles.
*/
function closeLocally(client) {
const target = client.client;
const settled = target === undefined
? Promise.resolve()
: Promise.resolve()
.then(() => target.close())
.then(() => undefined, () => undefined);
return {
outcome: Promise.resolve({ remote: 'handle-lost', resolved: false, durationMs: 0 }),
settled,
};
}
/**
* Is a 404 the MCP server's own answer — a JSON-RPC error response — rather than
* an intermediary's (a platform's "not found" for a missing endpoint, a proxy's
* page)? Read from a copy of the body, which the SDK reads too.
*/
async function isServerAnswer(response) {
try {
const text = await response.clone().text();
const parsed = JSON.parse(text);
const error = parsed?.error;
return parsed?.jsonrpc === '2.0' && typeof error?.code === 'number';
}
catch {
return false;
}
}
//# sourceMappingURL=pool.js.map