UNPKG

agentfootprint

Version:

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

375 lines • 19.1 kB
/** * 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. */ import { lazyRequire } from '../../../lib/lazyRequire.js'; import { guardOf } from '../../../mcp/requestGuard.js'; import { sdkLoadFailure } from '../../../mcp/sdkLoadFailure.js'; import { AGENTCORE_RUNTIME_SESSION_HEADER, MCP_SESSION_HEADER } from './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. */ export const 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. */ export 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, }; } function loadSdk() { try { return lazyRequire('@aws-sdk/client-bedrock-agentcore'); } catch (err) { throw new Error(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. */ export 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(MCP_SESSION_HEADER)), ...field('mcpProtocolVersion', headers.get(MCP_PROTOCOL_VERSION_HEADER)), ...field('runtimeSessionId', headers.get(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); }; } /** The runtime session a stop request names (`STOP_RUNTIME_SESSION`), or `undefined`. */ function stopOf(init) { const value = init?.[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 = 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(MCP_SESSION_HEADER, answer.mcpSessionId); if (answer.mcpProtocolVersion) headers.set(MCP_PROTOCOL_VERSION_HEADER, answer.mcpProtocolVersion); if (answer.runtimeSessionId) { headers.set(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