A client per conversation
mcpClientPool() keeps one MCP client per authenticated conversation for one server — opened once, bounded, never closed while a call is in flight, and ended remotely with the outcome reported.
A
'reuse'session keeps a server warm for one conversation. That only works when every conversation has an MCP client of its own.mcpClientPool()builds, finds, bounds and ends those clients, so a host serving many people does not have to.
When to use it
Use a pool when an MCP server keeps state per session and you serve more than one
person: AgentCore Runtime with session: 'reuse', or any stateful Streamable HTTP
server. One mcpClient shared by everyone puts every user in one session. A
client per run pays the cold start on every turn and leaves a session behind
whenever a run throws.
For a stateless server, the default 'per-call' session and a plain mcpClient
are enough.
One pool per server
Pass a function of the identity as transport: each client then gets a transport,
and a session map, of its own.
const orders = mcpClientPool({ name: 'orders', // Called once per client, with the identity it is for: a transport (and a // session) of its own for every conversation. transport: () => ({ transport: 'http', url: 'https://orders.example/mcp', fetch: server.fetch, }), maxClients: 8, maxClientsPerPrincipal: 4, onEvent: (report) => reports.push(report),});An agent takes the pool's toolProvider(). Each iteration it lists that
conversation's tools, bound to it. A bound tool takes a lease on its
conversation's client for one call, opening the client first when the
conversation has none. Nothing is held between calls.
const agent = Agent.create({ provider: provider ?? mock({ chunkDelayMs: 0, respond: (req) => req.messages.some((m) => m.role === 'tool') ? { content: 'Order 7712 shipped yesterday.' } : { toolCalls: [{ id: 'call-1', name: 'order_status', args: { id: '7712' } }] }, }), model: 'mock', maxIterations: 4,}) .system('You look orders up with the tools you have.') .toolProvider(orders.toolProvider()) .build();const uses: string[] = [];agent.on('agentfootprint.tools.mcp_client', (event) => uses.push(`${event.payload.at}:${event.payload.client}`),);// Two principals, two conversations each: four clients, four sessions.for (const principal of ['alice', 'bob']) { for (const conversationId of ['c1', 'c2']) { await agent.run({ message: input, identity: { tenant: 'acme', principal, conversationId } }); }}The client is keyed by the identity the caller named in run({ identity }).
A run that named none is refused (no-identity), because an identity derived
from a session id is not an authenticated one. A missing principal is refused
unless you pass requirePrincipal: false; pass requireTenant: true on a
multitenant host. Verify the caller at your door before you name the identity.
Tool lists: per client, unless you declare a share
By default each conversation lists its own client's tools, and that list belongs to one client. A replacement client lists again, and a tool bound to the old client is sent only if the new list holds the same definition.
When you know that conversations see the same list, say so. The pool checks the transport's own configuration too, and never shares across tenants.
const shared = mcpClientPool({ name: 'orders', transport: () => ({ transport: 'http', url: 'https://orders.example/mcp', fetch: server.fetch, }), // You vouch that every analyst's list is the same on this deployment. definitions: { share: () => ({ configuration: 'orders-runtime@v7', authorization: 'analyst@policy-3' }), },});const before = server.count('tools/list');for (const conversationId of ['s1', 's2']) { const lease = await shared.acquire({ tenant: 'acme', principal: 'carol', conversationId }); await lease.tools(); lease.release();}Never share a server whose list changes as a session goes on. Every list stops
being served after pool.permissionsChanged({ tenant, principal? }),
pool.toolsChanged(), the server's own tools-changed notice, or
definitionTtlMs. A tool bound to it is then refused (stale-tools) before
anything is sent.
share is asked again at every listing and every call, so a version in the
authorization name works for conversations that already have a client too.
When the name changes, the next listing lists again under the new name. A tool
bound under the old name is refused (binding-changed) before it is sent, even
if nothing was listed since. Keep share synchronous and cheap.
A list is kept only under the names it was listed for. The pool asks again when the listing is sent and when its answer arrives. If the names changed in between, the answer is dropped and the pool lists again under the new names, so one role's tools are never kept, or served, under another's.
Full means refused, never queued
Three budgets apply: open clients (maxClients, and maxClientsPerPrincipal),
clients being ended (maxEnding), and operations outstanding on any client
(maxOperations). An operation stays charged until its work has settled, not
just until its caller timed out. When a budget is full, PoolFullError comes at
once, and its limit names the budget.
const small = mcpClientPool({ name: 'orders', transport: () => ({ transport: 'http', url: 'https://orders.example/mcp', fetch: server.fetch, }), maxClients: 2, maxEnding: 1,});const a = await small.acquire({ tenant: 'acme', principal: 'dave', conversationId: 'd1' });const b = await small.acquire({ tenant: 'acme', principal: 'erin', conversationId: 'e1' });// Both clients are in use: a third conversation is refused at once.const full = await small .acquire({ tenant: 'acme', principal: 'fay', conversationId: 'f1' }) .catch((error: unknown) => error);assert.ok(full instanceof PoolFullError && full.limit === 'pool');// One ending at a time (maxEnding: 1). Dave's idle client is evicted to make// room for Gus — and its DELETE is slow, so it holds the one ending slot.const releaseDeletes = server.holdDeletes();a.release();const gus = await small.acquire({ tenant: 'acme', principal: 'gus', conversationId: 'g1' });b.release();// Making room for Hal needs a second ending: refused at once, never queued.const refused = await small .acquire({ tenant: 'acme', principal: 'hal', conversationId: 'h1' }) .catch((error: unknown) => error);assert.ok(refused instanceof PoolFullError && refused.limit === 'ending');// Once Dave's ending has settled, its slot is free and Hal gets room.releaseDeletes();await small.settled();const hal = await small.acquire({ tenant: 'acme', principal: 'hal', conversationId: 'h1' });gus.release();hal.release();await small.close();A client in use is never evicted, closed for idleness or ended. That includes
the moment just after it opens, before the caller that opened it has its
lease. To keep a conversation's client through a whole turn, hold
pool.acquire(identity) around agent.run() and release() it after (it is
AsyncDisposable). An acquire still waiting when the pool closes is refused
(closed) and holds nothing.
Ending a conversation
pool.terminate({ tenant, principal, conversationId? }) ends the remote session
and reports what the server answered. close() on a client ends only the local
side; terminate() ends the remote session, and says whether it did.
// A conversation ended: its remote session is ended, and the answer reported.server.refuseDeletes(true); // this server does not end sessions on requestconst [notOffered] = await orders.terminate({ tenant: 'acme', principal: 'bob' });server.refuseDeletes(false);const [terminated] = await orders.terminate({ tenant: 'acme', principal: 'alice', conversationId: 'c1',});resolved is true only for 'terminated', 'already-gone' and 'no-session'.
After 'not-offered', 'failed', 'timed-out' or 'handle-lost', the remote
session lives until the server's own timeout; stats().unresolvedEndings counts
those. On AgentCore Runtime under 'reuse', an ending is StopRuntimeSession.
terminate() answers within its deadline even when a client's local close
hangs, and the deadline is elapsed time: a clock change does not stretch it.
The ending keeps its ending slot and its operation slot until that close
really finishes, and settled() waits for it.
What the record says
In a run, every listing and every call through the pool files one
agentfootprint.tools.mcp_client event: the server, at ('list' or
'call'), the client it touched (opened, joined, reused, not-needed,
refused, expired, handle-lost), why a replacement opened (previous), a
refusal's word, and the conversation as keyHash, a short digest. It never
carries an identity value, a session id, an argument or a result.
Outside a run, endings, deferred endings, revoked leases and work that settled
after its caller was answered reach your onEvent as an McpPoolReport
(kind, reason — an McpPoolEndReason — the remote outcome, durationMs).
stats() returns an McpPoolStats: counts only, including the work still
charged past its deadline (unsettled) and the endings never confirmed
(unresolvedEndings).
Nothing is sent unchecked
Every pooled request is checked against its tool's binding before it leaves:
when the SDK sends it, when the pool's fetch hands it on, before each retry,
after a credential arrives, and after AWS signing. The pool refuses what it
cannot check:
- A transport whose
sendcannot be watched is never opened. The listing or call fails withinit-failed, and nothing is sent. - A request that carries no guard is refused unsent (
request-unmatched). For example, an SDK that rebuilt a tool call's params without its marker, so the pool could not tie the call to its tool.
replay (who may send a call again, McpClientOptions.replay) is passed to
every client and works as on a standalone client. It never turns the check off.
Gotchas
- Two failures that look alike stay two. A server's 404 for the session it
issued is
SessionExpiredError. A handle this process lost isLocalSessionHandleLostError, and nothing was sent. Neither call is repeated; the next one opens a new client for the same conversation. The lost client's local close still counts: it holds an ending slot until it finishes. - The budgets bound client objects, endings and operations. They do not bound
response sizes or total memory. Put per-caller limits at your door
(
AdmissionPolicy). - The pool lives in one process. Send
permissionsChangedto every process, or put a version in theauthorizationname you declare. - No timer is installed. Idle clients are ended at the next pool call, or when
you call
pool.sweep(). SetidleMsbelow your runtime's idle timeout.
What the package exports
From agentfootprint/providers:
| export | what it is |
|---|---|
mcpClientPool(options) | Build a pool for one server. Returns an McpClientPool. |
McpClientPoolOptions | name, transport (or a function of the identity), the budgets, initTimeoutMs, idleMs, maxLeaseMs, definitionTtlMs, definitions, requirePrincipal, requireTenant, onEvent, and the connection options passed to every client (resultMode, clientInfo, retryOnThrottle, replay, sdk, jsonSchemaValidator). |
MCP_POOL_DEFAULTS | The defaults: 64 clients, 8 per principal, 8 endings, 256 operations, a 30 s open, 600 s idle, a 15-minute lease, a 600 s definitions limit. |
MAX_DEFINITION_ENTRIES | At most this many shared definition entries are kept (256). |
MAX_SHARED_TOOLS | A list longer than this (512 tools) is used by its own client and never shared. |
McpPoolDefinitionSharing / McpPoolShareContext | definitions: { share }, and the configuration and authorization names share returns. |
McpPoolIdentity | { tenant?, principal?, conversationId }, the conversation a client is for. |
McpPoolScope | What terminate and permissionsChanged name. |
McpPoolLease | What acquire returns: tools(), release(), and AsyncDisposable. |
McpPoolStats / McpPoolReport / McpPoolEndReason | Counts, the reports onEvent receives, and why a client ended. |
PoolFullError / McpPoolLimit | A full budget, refused at once: limit is 'pool', 'principal', 'ending' or 'operations'. |
McpPoolRefusedError / McpPoolRefusal | A refusal before the call was sent: reason, sent: false. |
canTerminate(client) / TerminableMcpClient | Can a client end its remote session? |
McpTerminateOutcome | What ending a session came to. |
McpTransportSettings | A transport's comparable settings as data (McpHttpTransport.settings). |
From agentfootprint: SessionExpiredError (a server ended the session) and
LocalSessionHandleLostError (this process lost the handle; nothing was sent).
Related
- Connect to an MCP server —
mcpClient, transports andterminate() - Tool providers — composing two pools' providers into one
Connect to an MCP server
mcpClient({ transport }) turns any MCP server's tools into agentfootprint Tool[]. Sign every request with your own fetch, read servers old enough to answer the 2024-10-07 shape, and govern by the server a tool came from.
Serve your tools over MCP
mcpServe(tools, opts) exposes agentfootprint Tool[] AS an MCP server — stdio or Streamable HTTP. Schemas map 1:1, the served tool is the same object you passed in, so the governance you wrapped around it still runs.
