@sema-agent/core
Version:
Stateless, task-oriented AI agent core
81 lines (80 loc) • 6.05 kB
TypeScript
/**
* The MCP connection pool of ONE run tree: the legs of a tree — the root run and every delegated child it spawns, background
* and kept ones included — that declare the same server lease ONE connection (one client, one stdio process, one authorization,
* one lock holder) instead of dialling a copy each.
*
* WHAT IS SHARED AND WHAT IS NOT. Only the connection is: the SDK client over its transport. Everything a leg binds to
* itself stays the leg's own — the tool objects it mints around the client (their reminder-disclosure counters, their
* revocation probe, their runId), its liveness record, its close announcer. A shared tool object would write into the account of a
* parent leg that ended; a shared connection cannot.
*
* LIFETIME. A lease is released when its leg's materialization is disposed (the leg ended, failed, was cancelled or parked); the
* last holder's release closes the connection. The pool itself holds no connection between leases and dies with the run that
* created it: after `close()` a tree member that asks for a connection dials one of its own (a kept child resumed hours later,
* a revived one), so the DECLARATION stays the one source of truth — no handle is ever recorded anywhere.
*
* THE KEY is the server's declaration as the dial reads it (name and transport) and the principal the task runs under: a
* different principal is a different identity at the server. A server that opted in to elicitation never enters the pool — the
* answer to its questions belongs to the leg that is waiting on it.
*/
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { McpServerSpec } from "./mcp-server-spec.js";
/** The slice of a listed tool the SDK client caches per connection (its output validator, its task-support flags). */
export interface McpToolMeta {
readonly name: string;
readonly outputSchema?: unknown;
readonly execution?: unknown;
}
/** Records one `tools/list` result on a pooled connection (see {@link McpLease.openListing}): the tools the leg mounted from it, of which
* `retained` names the ones the listing did not return (an incomplete walk keeps them mounted) — those join the membership only. */
export type McpListingWriter = (tools: ReadonlyArray<McpToolMeta>, apply: (all: McpToolMeta[]) => void, retained?: ReadonlySet<string>) => void;
/** One leg's hold on a pooled connection. */
export interface McpLease {
readonly client: Client;
/**
* Open a `tools/list` round trip on the connection — call it BEFORE the request. The returned writer records the result twice over:
* as the connection's metadata for each listed tool, at this listing's version (its place in the order listings were opened on the
* connection: a listing opened earlier never overwrites what a later one wrote, whichever answer arrives first; an entry the listing
* did not return — `retained` — writes no record), and as THIS leg's membership (the names it mounted). It then rewrites the client's per-connection tool cache with the current metadata of every name
* some holder mounted: the SDK clears and refills that cache on each write, so a leg that mounted a subset must not erase what its
* siblings' tools need. A release changes the membership only and rewrites the cache from the same metadata.
*/
openListing(): McpListingWriter;
/** Run `fn` when the connection closes under the lease (the server died, the transport dropped). Never called for this lease's own release. */
onClose(fn: () => void): void;
/** Give the hold back; the last holder's release closes the connection. Idempotent. */
release(): Promise<void>;
}
export declare class McpConnectionPool {
private readonly entries;
private readonly dialing;
/** Bumped by each {@link detach}: a dial that finishes after its key was detached serves the legs already waiting on it and publishes nothing. */
private readonly generation;
private shut;
/**
* The key a declaration leases under. `kind` is the dial's own single reading of the transport kind (the dial reads it once and threads it, so a
* transport object that answers differently to two readers cannot split the dial from its key); every other transport member is read here.
* The declaration's mount identity (`fingerprint`) is part of the key when declared: the revocation probe is asked with it, so two mounts the
* host tells apart never share one connection (an undeclared fingerprint leaves the key as it always was).
* So is the startup bound the dial runs under (`startupMs`, the dial's one reading of {@link mcpStartupTimeoutMs}, threaded like `kind`):
* a connection is dialled under the declaration of the mount that dials it, so a mount never waits on, or shares, a dial bounded by
* another mount's clock. No bound in force (none declared, no environment seat) leaves the key as it always was.
*/
static keyOf(spec: McpServerSpec, principal: string | undefined, kind: string | undefined, startupMs: number | undefined): string;
/**
* Lease the connection for `key`, dialling it with `dial` when none is live. `fresh` replaces the pool's current connection with a new
* dial (a re-dial): the connection it replaces lives on until its other holders release it. Resolves `undefined` once the pool is
* closed — the caller dials for itself. A dial that rejects rejects every waiter of that dial, and the pool keeps nothing of it.
*/
acquire(key: string, dial: () => Promise<Client>, opts?: {
fresh?: boolean;
}): Promise<McpLease | undefined>;
/** Make the next lease of `key` dial a new connection. */
detach(key: string): void;
/** No lease is granted after this; the connections still held stay open until their holders release them. */
close(): void;
/** How many connections the pool holds open right now (for the lifecycle pins). */
get live(): number;
private dialNew;
private lease;
}