UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

151 lines • 6.66 kB
/** * mcpClientPool — a client per conversation, for one MCP server. * * const orders = mcpClientPool({ * name: 'orders', * transport: () => * agentCoreRuntimeMcpTransport({ agentRuntimeArn, region, session: 'reuse', client: aws }), * }); * const agent = Agent.create({ provider, model }).toolProvider(orders.toolProvider()).build(); * await agent.run({ message, identity: { tenant, principal, conversationId } }); * * Pattern: Facade over one state machine (`pool.ts`), its accounting (`work.ts`), * its definitions (`definitions.ts`) and its bound tools (`boundTool.ts`). * Role: `docs/design/2026-10-mcp-client-pool.md`. A `'reuse'` session costs a * cold start once per conversation instead of once per call — but only * if each conversation has a client of its own, opened once, bounded, * never closed while in use, ended remotely and said to be. This is * the one place that does all of it: * * - one client per authenticated conversation, keyed by the library's * identity encoding of the identity the CALLER named; * - one open per key, under the pool's own signal and `initTimeoutMs`; * - three budgets — open clients, endings, outstanding operations — each * charged until the work under it has SETTLED, and `PoolFullError` at once * when one is full: there is no queue; * - tool definitions per client incarnation by default, shared only under the * app's declared contract, every tool re-checked after each wait and at the * send gate; * - every ending through `terminate()`, with the remote outcome reported, and * `close()` still only local. * * What it does not do: repeat a call, share anything across processes, bound a * remote session it could not end, or make a server enforce per-user * permissions. */ import { fileListUse } from '../../tools/mcpClientUse.js'; import { TOOL_TEARDOWN_TIMEOUT_MS } from '../../tools/toolSessions.js'; import { noteRefusal } from './boundTool.js'; import { poolKeyOf, scopeMatcher } from './identityKey.js'; import { leaseHandle } from './lease.js'; import { listTools } from './listTools.js'; import { readPoolOptions } from './options.js'; import { Pool } from './pool.js'; import { UseFacts } from './record.js'; /** * Build a pool for one MCP server. * * @throws TypeError when an option is missing, mistyped or out of range, or when * `connection` is given — here, when the pool is built */ export function mcpClientPool(options) { const settings = readPoolOptions(options); const pool = new Pool(settings); const rules = { requirePrincipal: settings.requirePrincipal, requireTenant: settings.requireTenant, }; let closing; const provider = { id: `mcp-pool:${settings.name}`, list: async (ctx) => { const facts = new UseFacts(settings.name, 'list'); try { const key = poolKeyOf(ctx.callerIdentity, rules); if (typeof key === 'string') throw pool.refused(key); return await listTools(pool, key, ctx.signal, facts); } catch (error) { noteRefusal(facts, error); throw error; } finally { // Filed against the context the tools slot passed; the slot emits it. fileListUse(ctx, facts.toUse()); } }, }; return { name: settings.name, toolProvider: () => provider, async acquire(identity, acquireOptions) { const key = poolKeyOf(identity, rules); if (typeof key === 'string') throw pool.refused(key); pool.interact(); const operation = pool.admit('acquire', 'open', acquireOptions?.signal); try { const facts = new UseFacts(settings.name, 'list'); for (let round = 0; round < 3; round += 1) { const client = await pool.leaseClient(key, operation, facts); // After that wait, before anything is held: the pool may have closed, // the caller may have gone, the client may have been named by a // terminate. Refused, the operation's own lease ends with it below. if (pool.closed) throw pool.refused('closed'); operation.check(); if (client.serving) return leaseHandle(pool, key, pool.holdLease(client)); } throw pool.refused('init-failed'); } finally { operation.finish(); } }, async terminate(scope) { assertScope(scope, settings.name); pool.interact(); return pool.terminate(scopeMatcher(scope), 'terminated', TOOL_TEARDOWN_TIMEOUT_MS); }, close() { closing ??= pool.closeAll(TOOL_TEARDOWN_TIMEOUT_MS); return closing; }, settled: () => pool.settled(), permissionsChanged(scope) { assertFields(scope, settings.name, 'permissionsChanged'); pool.permissionsChanged(scope); }, toolsChanged: () => pool.toolsChanged(), sweep: () => pool.interact(), stats: () => pool.stats(), }; } /** * A `terminate` scope names a principal or a conversation: ending every * conversation of a whole tenant is `close()`'s job, and a mistyped scope must * not do it. */ function assertScope(scope, name) { assertFields(scope, name, 'terminate'); const named = (value) => value !== undefined && value !== ''; if (!named(scope.principal) && !named(scope.conversationId)) { throw new TypeError(`mcpClientPool[${name}].terminate: the scope must name a principal or a conversation ` + '({ tenant, principal } or { tenant, principal, conversationId }).'); } } /** Every field present is a string. Never quoted: a scope is an identity. */ function assertFields(scope, name, door) { if (scope === null || typeof scope !== 'object') { throw new TypeError(`mcpClientPool[${name}].${door}: the scope must be an object.`); } for (const field of ['tenant', 'principal', 'conversationId']) { const value = scope[field]; if (value !== undefined && typeof value !== 'string') { throw new TypeError(`mcpClientPool[${name}].${door}: \`${field}\` must be a string when present.`); } } } //# sourceMappingURL=mcpClientPool.js.map