agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
321 lines • 17.6 kB
JavaScript
/**
* agentCoreRuntimeMcpTransport — an MCP server hosted on AWS Bedrock
* **AgentCore Runtime**, reached as an ordinary MCP transport.
*
* const orders = await mcpClient({
* name: 'orders',
* transport: agentCoreRuntimeMcpTransport({
* agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/orders_mcp-a1b2c3',
* region: 'us-west-2',
* }),
* });
*
* ── Why this exists ──────────────────────────────────────────────────────────
* `agentCoreGatewayTransport` reaches a Gateway; `agentCoreRuntimeHost` hosts
* OUR agent on Runtime. Neither calls an MCP server somebody deployed TO
* Runtime, and that server is not reachable as a plain URL: every request goes
* through `InvokeAgentRuntime`, it lands in a runtime SESSION that has to be
* carried from call to call, and the platform answers a request that arrives
* while that session is still coming up with an HTTP 200 whose body says
* "Session operation in progress, please retry" — which no MCP client retries.
*
* ── What it is: one port, three strategies ───────────────────────────────────
* The port is the existing one: this returns an `http` member of
* `McpTransport`, so `mcpClient` builds it like any other Streamable HTTP
* server and knows nothing about AgentCore. Everything AgentCore-specific sits
* in the `fetch` it carries, composed from three choices, one file each:
*
* session (`session.ts`) 'per-call' (the default: a session per request),
* or 'reuse' (one session per MCP client, keyed by
* a handle the transport issues)
* retry (`sessionBusy.ts`) the session-busy refusal, retried with bounded
* backoff by the SAME loop `mcpClient` uses for a
* 429 (`mcp/throttleRetry.ts` · `retryingFetchFor`,
* one footprintjs `attempt()` series per request)
* auth (`invoke.ts`) 'sigv4' through the AWS SDK's InvokeAgentRuntime,
* or a bearer token over the documented HTTPS
* endpoint — the SDK has no bearer scheme, and the
* API reference says an OAuth caller must use HTTPS
*
* in that order, outermost first: the session is applied once per request, so
* every attempt of it lands in the same runtime session, and every attempt is
* signed (or vended) afresh, the ordering `mcp/throttleRetry.ts` requires.
* What the MCP client reads of a failure is rewritten from the library's own
* fixed sentences and safe ids (`safeAnswer.ts`), never the service's text.
*
* `mcpClient`'s own 429 retry does not wrap this fetch: the fetch is joinable
* (`joinableRetry`), so the 429 rule joins the transport's series INSIDE the
* session and the rewrite. One request is one series — one attempt count, one
* waiting total, one deadline — so the two retries can never multiply requests,
* and every attempt, a 429's included, lands in the same runtime session. The
* client's request ledger joins the same way, so a call the agent dispatched
* gets this series' attempts in its run (`mcp/requestLedger.ts`).
*
* ── What it does NOT do ──────────────────────────────────────────────────────
* Deploy or manage a runtime (a control-plane job), or host one (that is
* `agentCoreRuntimeHost`). This is the CLIENT side of a server somebody already
* deployed with `--protocol MCP`.
*
* Pattern: Facade over three Strategies. Role: outer ring, vendor adapter.
*/
import { createVendingFetch, gatewayTransport, } from '../../../mcp/gatewayTransport.js';
import { joinableRetry, joinedSpec, retryingFetchFor, retryRule, } from '../../../mcp/throttleRetry.js';
import { guardOf, withGuard } from '../../../mcp/requestGuard.js';
import { invokeFetch, invokeSdk, STOP_RUNTIME_SESSION, } from './invoke.js';
import { safeAnswer } from './safeAnswer.js';
import { AGENTCORE_RUNTIME_SESSION_HEADER, sessionStrategy, } from './session.js';
import { SESSION_BUSY_ASSUMES, sessionBusy } from './sessionBusy.js';
/**
* The session-busy retry's defaults, chosen by their MINIMUM total wait.
* AgentCore can take about 2.5 s, and up to about 3.4 s, to bring a runtime
* session up. 500 ms doubling with equal jitter over 5 attempts waits
* 250 + 500 + 1000 + 2000 = 3750 ms at the shortest draw and
* 500 + 1000 + 2000 + 4000 = 7500 ms at the longest: the slowest start is
* covered on every draw, and the longest stays inside the 10 s ceiling on
* waiting, so the ceiling never cuts the schedule short.
*/
export const SESSION_BUSY_RETRY_DEFAULTS = Object.freeze({
maxAttempts: 5,
maxWaitMs: 10_000,
maxElapsedMs: Infinity,
baseDelayMs: 500,
multiplier: 2,
jitter: 'equal',
maxDelayMs: Infinity,
});
/** What a bearer token is vended for, unless the caller names another service. */
const DEFAULT_BEARER_SERVICE = 'runtime';
/** A region is a DNS label run, nothing more — it is spliced into a hostname. */
const REGION_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* An MCP transport for a server hosted on AgentCore Runtime.
*
* @throws when the runtime or region is missing, when a strategy is unknown,
* when `client` or `sdk` is given beside `{ bearer }`, or — for `'sigv4'` —
* when the AWS SDK peer dependency cannot be loaded and no `sdk` was passed.
* All of these are thrown here, when the transport is built, never at the
* first call.
*/
export function agentCoreRuntimeMcpTransport(options) {
const url = agentCoreRuntimeUrl(options);
const send = authStrategy(options, url);
const busy = retryRule('session-busy', sessionBusy, options.retry, SESSION_BUSY_RETRY_DEFAULTS, 'agentCoreRuntimeMcpTransport: `retry`',
// Retried as the adapter's own policy, on a recorded assumption (`sessionBusy.ts`).
SESSION_BUSY_ASSUMES);
const session = sessionStrategy(options.session ?? 'per-call');
return {
transport: 'http',
url,
fetch: sessionedFetch(session, send, { rules: busy === undefined ? [] : [busy] }, stopUrl(options)),
settings: settingsOf(options),
};
}
/**
* What this transport keeps inside its `fetch`, stated as data
* (`McpHttpTransport.settings`): its auth strategy and session strategy, and on
* the bearer path the service, scopes, mode and credential provider id a token
* is vended for. Names and modes only — never a credential.
*/
function settingsOf(options) {
const auth = options.auth ?? 'sigv4';
const session = options.session ?? 'per-call';
if (auth === 'sigv4')
return { auth: 'sigv4', session };
return {
auth: 'bearer',
session,
service: auth.service ?? DEFAULT_BEARER_SERVICE,
...(auth.scopes !== undefined && { scopes: [...auth.scopes] }),
mode: auth.mode ?? 'machine',
credentials: auth.bearer.id,
};
}
/**
* Where `StopRuntimeSession` goes over HTTPS:
* `POST https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{url-encoded ARN}/stopruntimesession`,
* with `qualifier` as a query parameter when given — the operation's request
* line. The SigV4 path sends the AWS SDK's command instead, and ignores it.
*/
function stopUrl(options) {
const query = new URLSearchParams();
if (options.qualifier)
query.set('qualifier', options.qualifier);
const base = `https://bedrock-agentcore.${options.region}.amazonaws.com/runtimes/` +
`${encodeURIComponent(options.agentRuntimeArn)}/stopruntimesession`;
const search = query.toString();
return search === '' ? base : `${base}?${search}`;
}
/**
* The transport's fetch: the session around ONE retry series around the auth.
*
* The session is applied ONCE per request the MCP client makes, outside the
* series: every attempt of one request goes to the same runtime session (a busy
* refusal is about THAT session coming up), and only the final answer binds
* anything. Joinable: a rule `retryingFetch` offers (`mcpClient`'s 429 retry) is
* rebuilt into this same series, inside the session and the rewrite — never
* wrapped around them as a second loop — and so is the client's request ledger.
*/
function sessionedFetch(session, send, spec, stopAt) {
const retried = retryingFetchFor(send, spec);
const sessioned = async (input, init) => {
const headers = new Headers(init?.headers);
// A handle this transport does not hold throws here, before anything is
// sent (`session.ts` · `LocalSessionHandleLostError`).
const step = session.request(headers, (init?.method ?? 'GET').toUpperCase());
if ('answer' in step)
return step.answer;
if ('stop' in step)
return stopSession(retried, stopAt, step.stop, init);
const response = await retried(input, { ...init, headers });
// What the MCP client may read of a failure is rewritten from safe parts —
// ONE rule for both auth paths, after the retry has read the original.
return step.settle(await safeAnswer(response));
};
return joinableRetry(sessioned, (join) => sessionedFetch(session, send, joinedSpec(spec, join), stopAt));
}
/**
* A `'reuse'` connection's `DELETE`, as `StopRuntimeSession` for its runtime
* session — sent through the SAME series as every other request, so the
* platform's 409 while it starts or stops the session is retried like the
* session-busy refusal, signed (or vended) afresh each attempt. Answered as a
* server answers the `DELETE`: 204 once stopped; 404 when the platform no longer
* holds the session ("not found or already terminated"); otherwise the safe
* failure (`safeAnswer.ts`), its status kept.
*
* Carries the `DELETE`'s signal and, for a pooled client, its send gate
* (`mcp/requestGuard.ts`), so a deadline or a stale binding sends nothing.
*/
async function stopSession(retried, stopAt, runtimeSessionId, init) {
const stop = {
method: 'POST',
headers: {
'content-type': 'application/json',
[AGENTCORE_RUNTIME_SESSION_HEADER]: runtimeSessionId,
},
// The operation's idempotency token: one per stop, so an attempt the
// series repeats is the same stop.
body: JSON.stringify({ clientToken: globalThis.crypto.randomUUID() }),
...(init?.signal !== undefined && init.signal !== null && { signal: init.signal }),
[STOP_RUNTIME_SESSION]: runtimeSessionId,
};
const guard = guardOf(init);
const response = await retried(stopAt, guard === undefined ? stop : withGuard(stop, guard));
if (response.ok) {
await response.body?.cancel().catch(() => undefined);
return new Response(null, { status: 204, statusText: 'No Content' });
}
return safeAnswer(response);
}
/**
* The HTTPS endpoint of a runtime's MCP server:
* `https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{url-encoded ARN}/invocations`,
* with `qualifier` / `accountId` as query parameters when given — the request
* line of `InvokeAgentRuntime`, and the URL AgentCore's own MCP examples dial.
*
* @throws when `agentRuntimeArn` or `region` is missing, or the region is not
* shaped like one (it becomes part of a hostname).
*/
export function agentCoreRuntimeUrl(options) {
const { agentRuntimeArn, region, qualifier, accountId } = options;
if (!agentRuntimeArn || !region) {
throw new TypeError('agentCoreRuntimeUrl: both `agentRuntimeArn` and `region` are required — the runtime ' +
'is named by the first and reached through the second.');
}
if (!REGION_SHAPE.test(region)) {
throw new TypeError(`agentCoreRuntimeUrl: \`region\` ${JSON.stringify(region)} is not a region name ` +
"(lowercase letters, digits and '-', e.g. 'us-west-2'). It becomes part of the " +
'hostname, so it is refused rather than spliced in.');
}
const query = new URLSearchParams();
if (qualifier)
query.set('qualifier', qualifier);
if (accountId)
query.set('accountId', accountId);
const base = `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/` +
`${encodeURIComponent(agentRuntimeArn)}/invocations`;
const search = query.toString();
return search === '' ? base : `${base}?${search}`;
}
// ── The auth strategy ─────────────────────────────────────────────────
/** The innermost `fetch`: the one that authenticates and sends. */
function authStrategy(options, url) {
const auth = options.auth ?? 'sigv4';
const sdk = injectedSdk(options);
if (auth === 'sigv4') {
const { client, InvokeAgentRuntimeCommand, StopRuntimeSessionCommand } = invokeSdk({
region: options.region,
...(options.client !== undefined && { client: options.client }),
...(sdk !== undefined && { sdk }),
});
return invokeFetch({
agentRuntimeArn: options.agentRuntimeArn,
...(options.qualifier !== undefined && { qualifier: options.qualifier }),
...(options.accountId !== undefined && { accountId: options.accountId }),
}, client, InvokeAgentRuntimeCommand, StopRuntimeSessionCommand);
}
if (auth !== null && typeof auth === 'object' && auth.bearer !== undefined) {
if (typeof auth.bearer.getCredential !== 'function') {
throw new TypeError('agentCoreRuntimeMcpTransport: `auth.bearer` must be a CredentialProvider (an object ' +
'with getCredential — agentCoreIdentity(), staticTokens(), your own), not the token ' +
'itself: a token is vended per request so it can rotate, and is never held here.');
}
if (sdk !== undefined) {
const given = options.sdk !== undefined ? 'sdk' : '_sdk';
throw new TypeError(`agentCoreRuntimeMcpTransport: \`${given}\` is the SigV4 strategy’s AWS SDK module, and ` +
'`auth: { bearer }` sends no AWS SDK request — this path is plain HTTPS and loads no ' +
`AWS SDK. Remove \`${given}\`, or use \`auth: "sigv4"\`.`);
}
if (options.client !== undefined) {
throw new TypeError('agentCoreRuntimeMcpTransport: `client` is the SigV4 strategy’s AWS SDK client, and ' +
'`auth: { bearer }` sends no AWS SDK request — the SDK cannot carry a bearer token, ' +
'so this path is plain HTTPS. Remove `client`, or use `auth: "sigv4"`.');
}
if (auth.mode !== undefined && auth.mode !== 'machine') {
// The vending request names no user: it carries `service`, `scopes` and
// `mode`, never an identity or the user's own token. A user-delegated
// token asked for that way belongs to nobody in particular — and would be
// sent for whoever calls through the client.
throw new TypeError(`agentCoreRuntimeMcpTransport: \`auth.mode\` must be 'machine' — got ${describe(auth.mode)}. ` +
'This transport asks its provider for a token without naming any user, so a ' +
'user-delegated token would be sent for whoever calls through the client. Leave ' +
'`mode` unset for the client credential; for a token per user, build a transport per ' +
'user whose `bearer` provider vends that user’s token.');
}
// The Gateway's vending fetch, reused as is: the token is vended per
// request, applied to that one request, and dropped.
return createVendingFetch(gatewayTransport({
url,
credentials: auth.bearer,
service: auth.service ?? DEFAULT_BEARER_SERVICE,
...(auth.scopes !== undefined && { scopes: auth.scopes }),
...(auth.mode !== undefined && { mode: auth.mode }),
}), auth.fetch);
}
throw new TypeError(`agentCoreRuntimeMcpTransport: \`auth\` must be 'sigv4' or { bearer: <CredentialProvider> } ` +
`— got ${describe(auth)}. Which one is decided by the runtime's inbound auth: IAM is ` +
"'sigv4', an OAuth (JWT) authorizer is a bearer token.");
}
/** The injected SDK module: `sdk`, or the older `_sdk` spelling of it. */
function injectedSdk(options) {
const { sdk, _sdk: legacy } = options;
if (sdk !== undefined && legacy !== undefined && sdk !== legacy) {
throw new TypeError('agentCoreRuntimeMcpTransport: `sdk` and `_sdk` name two different AWS SDK modules. ' +
'`_sdk` is the older spelling of `sdk`; pass only `sdk`.');
}
return sdk ?? legacy;
}
/**
* A refused value, described by SHAPE — never echoed. A mistyped `auth` can be
* the token itself (`auth: process.env.RUNTIME_TOKEN`), and an error message is
* the last place it should land.
*/
function describe(value) {
if (value === null)
return 'null';
if (typeof value === 'string')
return `a string of ${value.length} characters`;
if (typeof value === 'object')
return `an object with keys [${Object.keys(value).join(', ')}]`;
return typeof value;
}
//# sourceMappingURL=transport.js.map