UNPKG

agentfootprint

Version:

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

326 lines • 18.1 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.agentCoreRuntimeUrl = exports.agentCoreRuntimeMcpTransport = exports.SESSION_BUSY_RETRY_DEFAULTS = void 0; const gatewayTransport_js_1 = require("../../../mcp/gatewayTransport.js"); const throttleRetry_js_1 = require("../../../mcp/throttleRetry.js"); const requestGuard_js_1 = require("../../../mcp/requestGuard.js"); const invoke_js_1 = require("./invoke.js"); const safeAnswer_js_1 = require("./safeAnswer.js"); const session_js_1 = require("./session.js"); const sessionBusy_js_1 = require("./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. */ exports.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. */ function agentCoreRuntimeMcpTransport(options) { const url = agentCoreRuntimeUrl(options); const send = authStrategy(options, url); const busy = (0, throttleRetry_js_1.retryRule)('session-busy', sessionBusy_js_1.sessionBusy, options.retry, exports.SESSION_BUSY_RETRY_DEFAULTS, 'agentCoreRuntimeMcpTransport: `retry`', // Retried as the adapter's own policy, on a recorded assumption (`sessionBusy.ts`). sessionBusy_js_1.SESSION_BUSY_ASSUMES); const session = (0, session_js_1.sessionStrategy)(options.session ?? 'per-call'); return { transport: 'http', url, fetch: sessionedFetch(session, send, { rules: busy === undefined ? [] : [busy] }, stopUrl(options)), settings: settingsOf(options), }; } exports.agentCoreRuntimeMcpTransport = agentCoreRuntimeMcpTransport; /** * 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 = (0, throttleRetry_js_1.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 (0, safeAnswer_js_1.safeAnswer)(response)); }; return (0, throttleRetry_js_1.joinableRetry)(sessioned, (join) => sessionedFetch(session, send, (0, throttleRetry_js_1.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', [session_js_1.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 }), [invoke_js_1.STOP_RUNTIME_SESSION]: runtimeSessionId, }; const guard = (0, requestGuard_js_1.guardOf)(init); const response = await retried(stopAt, guard === undefined ? stop : (0, requestGuard_js_1.withGuard)(stop, guard)); if (response.ok) { await response.body?.cancel().catch(() => undefined); return new Response(null, { status: 204, statusText: 'No Content' }); } return (0, safeAnswer_js_1.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). */ 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}`; } exports.agentCoreRuntimeUrl = agentCoreRuntimeUrl; // ── 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 } = (0, invoke_js_1.invokeSdk)({ region: options.region, ...(options.client !== undefined && { client: options.client }), ...(sdk !== undefined && { sdk }), }); return (0, invoke_js_1.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 (0, gatewayTransport_js_1.createVendingFetch)((0, gatewayTransport_js_1.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