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
JavaScript
/**
* 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