UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

81 lines (80 loc) • 6.05 kB
/** * 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; }