agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
380 lines • 19.4 kB
JavaScript
;
/**
* invoke — the SigV4 auth strategy: Streamable HTTP, carried by the AWS SDK's
* `InvokeAgentRuntime`.
*
* The MCP client speaks HTTP — a POST, a few headers, a JSON body — and the SDK
* operation carries exactly that, field for field. This file is the mapping and
* nothing else. Every name in it is the SDK's own (`InvokeAgentRuntimeRequest`
* / `InvokeAgentRuntimeResponse` in `@aws-sdk/client-bedrock-agentcore`), read
* off an installed package rather than recalled:
*
* request InvokeAgentRuntimeRequest
* ───────────────────────────────────────────── ────────────────────────
* body payload (bytes)
* content-type contentType
* accept accept
* mcp-session-id mcpSessionId
* mcp-protocol-version mcpProtocolVersion
* x-amzn-bedrock-agentcore-runtime-session-id runtimeSessionId
* (the runtime, from options) agentRuntimeArn, qualifier, accountId
*
* InvokeAgentRuntimeResponse response
* ───────────────────────────────────────────── ────────────────────────
* statusCode status
* contentType content-type
* mcpSessionId mcp-session-id
* mcpProtocolVersion mcp-protocol-version
* runtimeSessionId x-amzn-bedrock-agentcore-runtime-session-id
* response (a stream) body
*
* The signature is the SDK's: `send` signs every attempt with the client's own
* credentials, so no credential passes through this file.
*
* ── The SDK's own retry is OFF ───────────────────────────────────────────────
* The client built here has `maxAttempts: 1`. The SDK's standard policy would
* re-send a `tools/call` after a 5xx or a dropped connection — a call that may
* already have run, in the same pinned session — which a retry here must never
* do on its own. Every retry goes through the transport's single loop instead,
* which repeats only what the replay rule permits (`mcp/replay.ts`): the
* refusals its retry policy recognises — the session-busy reply and a 429, each
* assumed, not proven, to have run nothing — and a dropped connection only when
* the app declared that for the tool. A `client` you pass keeps ITS policy:
* build it with `maxAttempts: 1` for the same guarantee.
*
* ── A service exception is an ANSWER ─────────────────────────────────────────
* When the service answers with a non-2xx status, the SDK throws instead of
* returning — `RetryableConflictException` (409), `ThrottlingException` (429),
* or a hosted server's own 404 as `Unknown`. Each is handed on as the `Response`
* an HTTPS caller would have read: its status, its error type in
* `x-amzn-errortype`, `retry-after` when the service sent one, and as the body
* the server's own JSON-RPC error when it sent one (the SDK spreads a body it
* cannot model onto the exception) — never the exception's message, which can
* quote the signed request. What the MCP client may read of it is decided
* once, by `safeAnswer.ts`. So the transport's retry decision sees the 409,
* `mcpClient`'s 429 retry sees the 429, and the MCP client reports the
* status. A failure with no status — the
* network, an abort — never reached the service as an answer, and is thrown as
* it is.
*
* ── What the operation cannot carry ──────────────────────────────────────────
* Any method but POST. A GET (the optional server-message stream) and a DELETE
* are answered HERE with 405, which the MCP Streamable HTTP spec defines as "not
* offered" for both, so the MCP client carries on. And any header the operation
* does not model — it has no field to put one in. A `'reuse'` connection's
* DELETE never reaches this far: the session strategy turns it into a stop
* (`STOP_RUNTIME_SESSION`), sent as `StopRuntimeSessionCommand`.
*
* ── The send gate runs after signing ─────────────────────────────────────────
* A request the MCP client pool sent carries its send gate (`mcp/requestGuard.ts`).
* Signing is asynchronous work inside the AWS SDK's `send`, so the gate is a
* step on each COMMAND's own middleware stack, after the request is signed and
* before the request handler sends it — never on the client's stack, so an AWS
* client the app passed is not changed. A command with no stack (a double) has
* the gate asked just before `send`, as every command also does.
*
* Pattern: Adapter (fetch ↔ command). Role: outer ring, lazy AWS peer dep.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.invokeFetch = exports.invokeSdk = exports.STOP_RUNTIME_SESSION = void 0;
const lazyRequire_js_1 = require("../../../lib/lazyRequire.js");
const requestGuard_js_1 = require("../../../mcp/requestGuard.js");
const sdkLoadFailure_js_1 = require("../../../mcp/sdkLoadFailure.js");
const session_js_1 = require("./session.js");
const MCP_PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version';
const SDK_PACKAGE = '@aws-sdk/client-bedrock-agentcore';
/**
* @internal The `init` key a `'reuse'` connection's stop rides under: the
* runtime session id to stop (`transport.ts` · `stopSession`). A symbol, so no
* request the MCP client makes can ever claim it.
*/
exports.STOP_RUNTIME_SESSION = Symbol('agentcore.stop-runtime-session');
/**
* The client and the command constructor this strategy sends with.
*
* Loaded when the transport is BUILT, not at the first call, so a missing peer
* dependency is refused where the transport is declared rather than mid-run.
*
* @throws when the SDK cannot be loaded, or when it lacks what is needed.
*/
function invokeSdk(options) {
const mod = options.sdk ?? loadSdk();
// Whose module it is decides the advice: an injected one may simply be the
// wrong import, a loaded one an old install.
const source = options.sdk === undefined
? `\`${SDK_PACKAGE}\` is installed but has no`
: `the \`sdk\` you passed (it should hold \`BedrockAgentCoreClient\` and \`InvokeAgentRuntimeCommand\` from \`${SDK_PACKAGE}\`) has no`;
const advice = options.sdk === undefined ? 'Upgrade the SDK.' : 'Check the import.';
const Command = mod.InvokeAgentRuntimeCommand;
if (typeof Command !== 'function') {
throw new Error(`agentCoreRuntimeMcpTransport: ${source} \`InvokeAgentRuntimeCommand\`. ${advice}`);
}
// Ending a `'reuse'` client stops its runtime session with this command. An
// SDK module without it still invokes; that ending is then 'failed' (`stopFetch`).
const Stop = mod.StopRuntimeSessionCommand;
const stop = typeof Stop === 'function' ? { StopRuntimeSessionCommand: Stop } : {};
if (options.client)
return { client: options.client, InvokeAgentRuntimeCommand: Command, ...stop };
if (typeof mod.BedrockAgentCoreClient !== 'function') {
throw new Error(`agentCoreRuntimeMcpTransport: ${source} \`BedrockAgentCoreClient\`. ${advice}`);
}
return {
// The SDK's own retry OFF: it would re-send work that may have run.
client: new mod.BedrockAgentCoreClient({ region: options.region, maxAttempts: 1 }),
InvokeAgentRuntimeCommand: Command,
...stop,
};
}
exports.invokeSdk = invokeSdk;
function loadSdk() {
try {
return (0, lazyRequire_js_1.lazyRequire)('@aws-sdk/client-bedrock-agentcore');
}
catch (err) {
throw new Error((0, sdkLoadFailure_js_1.sdkLoadFailure)(err, {
notInstalled: `agentCoreRuntimeMcpTransport (auth: 'sigv4') requires the \`${SDK_PACKAGE}\` peer ` +
'dependency.\n' +
` Install: npm install ${SDK_PACKAGE}\n` +
' Installed, and your app is bundled (esbuild, webpack)? The bundler cannot follow\n' +
' this lazy load: import the SDK yourself and pass it as `sdk`\n' +
` (sdk: { BedrockAgentCoreClient, InvokeAgentRuntimeCommand } from '${SDK_PACKAGE}').\n` +
' Or use `auth: { bearer }` for a runtime with OAuth inbound auth — that path is plain\n' +
' HTTPS and loads no AWS SDK.',
caller: 'agentCoreRuntimeMcpTransport',
specifier: SDK_PACKAGE,
instead: 'Pass the SDK module you imported as `sdk`. SigV4 signs with AWS credentials, which ' +
'belong on a server: from a browser, use `auth: { bearer }` — plain HTTPS, no AWS SDK.',
}));
}
}
/** Statuses whose response may not carry a body, by the Fetch standard. */
const NULL_BODY_STATUS = new Set([204, 205, 304]);
/**
* A `fetch` that sends each request as one `InvokeAgentRuntimeCommand`.
*
* The URL the MCP client passes is ignored on purpose: it names the endpoint for
* reports and errors, while the invocation goes to `target` — the SDK resolves
* the endpoint itself, from the client's region.
*/
function invokeFetch(target, client, InvokeAgentRuntimeCommand, StopRuntimeSessionCommand) {
return async (_input, init) => {
const stopping = stopOf(init);
if (stopping !== undefined) {
return stopFetch(target, client, StopRuntimeSessionCommand, stopping, init);
}
const method = (init?.method ?? 'GET').toUpperCase();
if (method !== 'POST') {
return new Response(null, { status: 405, statusText: 'Method Not Allowed' });
}
const headers = new Headers(init?.headers);
const command = new InvokeAgentRuntimeCommand({
agentRuntimeArn: target.agentRuntimeArn,
...(target.qualifier !== undefined && { qualifier: target.qualifier }),
...(target.accountId !== undefined && { accountId: target.accountId }),
payload: payloadOf(init?.body),
...field('contentType', headers.get('content-type')),
...field('accept', headers.get('accept')),
...field('mcpSessionId', headers.get(session_js_1.MCP_SESSION_HEADER)),
...field('mcpProtocolVersion', headers.get(MCP_PROTOCOL_VERSION_HEADER)),
...field('runtimeSessionId', headers.get(session_js_1.AGENTCORE_RUNTIME_SESSION_HEADER)),
});
let answer;
try {
answer = ((await sendGated(client, command, init)) ?? {});
}
catch (err) {
const answered = serviceAnswerOf(err);
if (answered === undefined)
throw err;
return answered;
}
return responseOf(answer);
};
}
exports.invokeFetch = invokeFetch;
/** The runtime session a stop request names (`STOP_RUNTIME_SESSION`), or `undefined`. */
function stopOf(init) {
const value = init?.[exports.STOP_RUNTIME_SESSION];
return typeof value === 'string' && value !== '' ? value : undefined;
}
/**
* `StopRuntimeSession` for one runtime session, as the `Response` an HTTPS
* caller would read: 200 when the platform stopped it, a service exception as
* its status (`ResourceNotFoundException` 404, `RetryableConflictException` 409
* — retried by the series — `AccessDeniedException` 403, …).
*/
async function stopFetch(target, client, StopRuntimeSessionCommand, runtimeSessionId, init) {
if (StopRuntimeSessionCommand === undefined) {
throw new Error('agentCoreRuntimeMcpTransport: the AWS SDK module has no `StopRuntimeSessionCommand`, ' +
"so a 'reuse' client's runtime session cannot be stopped. Upgrade the SDK.");
}
const command = new StopRuntimeSessionCommand({
runtimeSessionId,
agentRuntimeArn: target.agentRuntimeArn,
...(target.qualifier !== undefined && { qualifier: target.qualifier }),
clientToken: clientTokenOf(init?.body),
});
try {
await sendGated(client, command, init);
}
catch (err) {
const answered = serviceAnswerOf(err);
if (answered === undefined)
throw err;
return answered;
}
return new Response(null, { status: 200 });
}
/** The stop's idempotency token, as its body carries it — one per stop, across attempts. */
function clientTokenOf(body) {
if (typeof body !== 'string')
return undefined;
try {
const token = JSON.parse(body).clientToken;
return typeof token === 'string' ? token : undefined;
}
catch {
return undefined;
}
}
/**
* `client.send(command)`, with a pooled request's send gate asked before it and
* — on a command with its own middleware stack — again after signing, and the
* send counted under the request until it settles (`mcp/requestGuard.ts`).
*/
function sendGated(client, command, init) {
const guard = (0, requestGuard_js_1.guardOf)(init);
if (guard !== undefined) {
installGate(command, guard);
guard.check();
}
const sent = client.send(command, init?.signal ? { abortSignal: init.signal } : undefined);
guard?.track(sent);
return sent;
}
/** The gate's name on a command's middleware stack. */
const GATE_STEP = 'agentfootprintSendGate';
/**
* Put `guard` on `command`'s OWN middleware stack, after signing and before the
* request handler: the AWS SDK signs at the `finalizeRequest` step (its signer
* sits right after its retry middleware, at high priority), so a low-priority
* `finalizeRequest` step runs after the signature and before the deserializer
* hands the request to the handler. The client's stack is never touched.
* A command without a stack is left as it is.
*/
function installGate(command, guard) {
const stack = command
?.middlewareStack;
if (typeof stack?.add !== 'function')
return;
stack.add.call(stack, (next) => async (args) => {
guard.check();
return next(args);
}, { step: 'finalizeRequest', priority: 'low', name: GATE_STEP, override: true });
}
/**
* An SDK service exception as the `Response` the service sent: its status, its
* error type, its `retry-after`. `undefined` for anything that carries no
* status — that failure never came back from the service as an answer.
*/
function serviceAnswerOf(err) {
if (err === null || typeof err !== 'object')
return undefined;
const failure = err;
const status = failure.$metadata?.httpStatusCode;
if (typeof status !== 'number' || !Number.isInteger(status) || status < 400 || status > 599) {
return undefined;
}
const headers = new Headers({ 'content-type': 'application/json' });
if (typeof failure.name === 'string' && failure.name !== '') {
headers.set('x-amzn-errortype', failure.name);
}
const retryAfter = failure.$response?.headers?.['retry-after'];
if (typeof retryAfter === 'string')
headers.set('retry-after', retryAfter);
// The server's own JSON-RPC error when it sent one (a stateful server's 404
// "Session not found"), for its code. The exception's message is never put
// in the answer: it can quote the signed request. What the client reads of
// either is decided by `safeAnswer.ts`, outside the retry.
const error = failure.error;
const body = error !== null && typeof error === 'object' && !Array.isArray(error)
? { jsonrpc: failure.jsonrpc ?? '2.0', id: failure.id ?? null, error }
: {};
return new Response(JSON.stringify(body), { status, headers });
}
/** An optional request field: present only when the header was. */
function field(name, value) {
return value === null || value === '' ? {} : { [name]: value };
}
/**
* The request body as the bytes `payload` takes. The MCP client always sends a
* JSON string; the other forms are accepted because they are bytes already.
* A stream or a form is refused by name — re-reading one is not this file's to
* do, and dropping it would send an empty request.
*/
function payloadOf(body) {
if (body === undefined || body === null)
return new Uint8Array();
if (typeof body === 'string')
return new TextEncoder().encode(body);
if (body instanceof Uint8Array)
return body;
if (body instanceof ArrayBuffer)
return new Uint8Array(body);
if (ArrayBuffer.isView(body)) {
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
}
throw new TypeError('agentCoreRuntimeMcpTransport: a request body must be a string or bytes to travel as ' +
"InvokeAgentRuntime's `payload`; got a stream or form body, which this transport does " +
'not read.');
}
/** The SDK's answer as the `Response` the MCP client reads. */
async function responseOf(answer) {
const status = answer.statusCode ?? 200;
const headers = new Headers();
if (answer.contentType)
headers.set('content-type', answer.contentType);
if (answer.mcpSessionId)
headers.set(session_js_1.MCP_SESSION_HEADER, answer.mcpSessionId);
if (answer.mcpProtocolVersion)
headers.set(MCP_PROTOCOL_VERSION_HEADER, answer.mcpProtocolVersion);
if (answer.runtimeSessionId) {
headers.set(session_js_1.AGENTCORE_RUNTIME_SESSION_HEADER, answer.runtimeSessionId);
}
if (NULL_BODY_STATUS.has(status)) {
await release(answer.response);
return new Response(null, { status, headers });
}
return new Response(await bodyOf(answer.response), { status, headers });
}
/**
* The SDK's streaming body, kept streaming where it can be: an SSE reply then
* reaches the MCP client event by event, as it would over plain HTTP.
*/
async function bodyOf(stream) {
if (stream === undefined || stream === null)
return null;
if (typeof stream === 'string' || stream instanceof Uint8Array)
return stream;
const sdkStream = stream;
if (typeof sdkStream.transformToWebStream === 'function') {
return sdkStream.transformToWebStream();
}
if (typeof sdkStream.transformToByteArray === 'function') {
return sdkStream.transformToByteArray();
}
throw new TypeError('agentCoreRuntimeMcpTransport: InvokeAgentRuntime answered with a `response` that is ' +
'neither bytes nor an SDK stream (no transformToWebStream / transformToByteArray). ' +
'Upgrade @aws-sdk/client-bedrock-agentcore.');
}
/** Drain a body nobody will read, so the SDK's socket is released. Never throws. */
async function release(stream) {
try {
const sdkStream = stream;
await sdkStream?.transformToByteArray?.();
}
catch {
// A body that will not drain is not worth failing a delivered reply over.
}
}
//# sourceMappingURL=invoke.js.map