UNPKG

agentfootprint

Version:

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

409 lines • 17.9 kB
/** * safeAnswer — what the MCP client may read of an answer: read ONCE, checked, * and handed on as the canonical form of exactly what was checked. * * ── Why an allow-list ──────────────────────────────────────────────────────── * Whatever an answer's body says becomes the MCP client's error text, and a * failed tool call hands that text to the model, the run's history and * `tool_end`. Service text can carry anything the request did: AWS answers a * signature mismatch with the whole signed request (the session token among * it), its validation errors quote the value they refused, a proxy may echo a * header. So a failure is RE-WRITTEN from parts that cannot carry a secret: * the status; the AWS error type when it is a plain name; `Retry-After` when * it is seconds or a date; and for a JSON-RPC error its integer `code`, the * client's own `id`, and its `message` ONLY when it is one of the fixed * sentences the MCP SDK and the AgentCore Runtime contract use * ({@link KNOWN_ERROR_MESSAGES}). * * ── Why one reading ────────────────────────────────────────────────────────── * A check that reads a body one way and a client that reads the same bytes * another is a parser differential: a crafted answer passes the check and is * used as something else. So a 2xx answer is never handed on as its raw bytes. * It is classified by ONE rule (its media type, exactly `application/json` or * `text/event-stream`, UTF-8), decoded strictly, parsed once * (`strictJson.ts` — a duplicated key, trailing data or a byte-order mark is * refused), checked, and RE-SERIALIZED: the client reads the value that was * checked, never the bytes it came from. One message per body or event — a * batch is refused. An answer that cannot be read unambiguously is refused by * name, as a 502 whose text is this library's. * * Pattern: pure functions + one TransformStream. Role: outer ring, the * transport's one rule for what the client reads. */ import { awsErrorType } from './errorType.js'; import { AmbiguousJsonError, parseStrictJson } from './strictJson.js'; /** * JSON-RPC error messages a reply may keep word for word — fixed strings that * carry nothing of the request: the JSON-RPC 2.0 standard errors, the MCP * Streamable HTTP session error, and the AgentCore Runtime MCP contract's * mappings of its exceptions. */ export const KNOWN_ERROR_MESSAGES = new Set([ 'Parse error', 'Invalid Request', 'Method not found', 'Invalid params', 'Internal error', 'Session not found', 'Session operation in progress, please retry', 'Resource conflict - Resource already exists', 'Rate limit exceeded - Too many requests', 'Authentication error - Invalid credentials', 'Authorization error - Insufficient permissions', 'Tool execution error', ]); /** * AWS error types that mean "who you are, or what you may do" — whatever * status they arrive with (`IncompleteSignatureException` is a 400). */ const AUTH_FAILURE_TYPES = new Set([ 'AccessDeniedException', 'ExpiredTokenException', 'IncompleteSignatureException', 'InvalidClientTokenId', 'InvalidSignatureException', 'MissingAuthenticationTokenException', 'SignatureDoesNotMatch', 'UnauthorizedException', 'UnrecognizedClientException', ]); /** An error type that is a plain name — anything else is not repeated. */ const PLAIN_TYPE = /^[A-Za-z][A-Za-z0-9]{0,99}$/; /** A token the client may read back as it is: a request id, an event id or type, a session header. */ const PLAIN_TOKEN = /^[A-Za-z0-9_.:-]{1,128}$/; /** `Retry-After`: delay-seconds, or an HTTP date. */ const RETRY_AFTER = /^(?:\d{1,10}|[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT)$/; /** The response headers a 2xx answer keeps (besides its canonical content type). */ const KEPT_HEADERS = ['mcp-session-id', 'mcp-protocol-version']; /** An answer this transport will not hand on, and why — in its own words. */ class Unreadable extends Error { reason; constructor(reason) { super(reason); this.reason = reason; } } /** Is this answer an authentication or authorization failure? */ export function isAuthFailure(status, type) { if (status === 401 || status === 403) return true; return type !== undefined && AUTH_FAILURE_TYPES.has(type); } /** * The answer the MCP client may read: a failure rewritten from safe parts; a * 2xx read once and re-serialized; an answer that cannot be read * unambiguously refused (502). */ export async function safeAnswer(response) { if (!response.ok) return safeFailure(response); try { return await safeSuccess(response); } catch (err) { if (err instanceof Unreadable) return refusal(response.status, err.reason); throw err; } } // ── 2xx ──────────────────────────────────────────────────────────────── async function safeSuccess(response) { const kind = mediaKind(response.headers.get('content-type')); if (kind === 'event-stream') return safeStream(response); const bytes = response.body === null ? new Uint8Array() : await bytesOf(response); if (bytes.length === 0) { // An empty answer (a 202 to a notification): nothing to read, nothing handed on. const headers = keptHeaders(response.headers, undefined); return new Response(null, { status: response.status, headers }); } if (kind !== 'json') { throw new Unreadable(kind === 'none' ? 'a body with no content type' : 'a body whose content type is neither application/json nor text/event-stream'); } const message = oneMessage(parseOnce(decodeUtf8(bytes))); return new Response(JSON.stringify(canonicalMessage(message)), { status: response.status, headers: keptHeaders(response.headers, 'application/json'), }); } /** The ONE media type the transport reads a 2xx body as, by one rule. */ function mediaKind(contentType) { if (contentType === null || contentType.trim() === '') return 'none'; const [type, ...params] = contentType.split(';').map((part) => part.trim().toLowerCase()); // Every parameter must be `charset=utf-8`: another charset is another reading. const utf8 = params.every((p) => /^charset="?utf-8"?$/.test(p)); if (!utf8) return 'other'; if (type === 'application/json') return 'json'; if (type === 'text/event-stream') return 'event-stream'; return 'other'; } async function bytesOf(response) { return new Uint8Array(await response.arrayBuffer()); } /** UTF-8, strictly: an invalid sequence or a byte-order mark is refused. */ function decodeUtf8(bytes) { try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { throw new Unreadable('a body that is not UTF-8'); } } function parseOnce(text) { try { return parseStrictJson(text); } catch (err) { if (err instanceof AmbiguousJsonError) throw new Unreadable(err.reason); throw err; } } /** ONE JSON-RPC message — an object; a batch, or anything else, is refused. */ function oneMessage(value) { if (Array.isArray(value)) throw new Unreadable('more than one message in one body'); if (value === null || typeof value !== 'object') { throw new Unreadable('a body that is not a JSON-RPC message'); } return value; } /** The message as the client may read it: an error rewritten, anything else as checked. */ function canonicalMessage(message) { return 'error' in message ? safeErrorReply(message) : message; } /** The response headers a 2xx answer keeps, and its canonical content type. */ function keptHeaders(from, contentType) { const headers = new Headers(contentType === undefined ? {} : { 'content-type': contentType }); for (const name of KEPT_HEADERS) { const value = from.get(name); if (value !== null && PLAIN_TOKEN.test(value)) headers.set(name, value); } return headers; } /** A 2xx answer the transport cannot read unambiguously — refused in its own words. */ function refusal(status, reason) { return new Response(JSON.stringify({ message: `Unreadable answer (HTTP ${status}): ${reason}. It was not passed on, because a ` + 'reading the transport could not check is a reading the model must not get.', }), { status: 502, headers: { 'content-type': 'application/json' } }); } // ── failures ─────────────────────────────────────────────────────────── /** A failure, rewritten from its status, its type and — for a JSON-RPC error — its code. */ async function safeFailure(response) { const type = plainType(awsErrorType(response.headers)); const reply = await failureReplyOf(response); const body = reply !== undefined ? safeErrorReply(reply) : { message: failureSentence(response.status, type) }; const headers = new Headers({ 'content-type': 'application/json' }); if (type !== undefined) headers.set('x-amzn-errortype', type); const retryAfter = response.headers.get('retry-after')?.trim(); if (retryAfter !== undefined && RETRY_AFTER.test(retryAfter)) { headers.set('retry-after', retryAfter); } return new Response(JSON.stringify(body), { status: response.status, headers }); } /** The JSON-RPC error message a failure's body is — read once, strictly — or `undefined`. */ async function failureReplyOf(response) { if (response.body === null || mediaKind(response.headers.get('content-type')) !== 'json') { await response.body?.cancel().catch(() => undefined); return undefined; } try { const message = oneMessage(parseOnce(decodeUtf8(await bytesOf(response)))); return 'error' in message ? message : undefined; } catch { return undefined; } } /** The fixed sentence a failure with no JSON-RPC reply is answered with. */ export function failureSentence(status, type) { if (isAuthFailure(status, type)) { return (`${type ?? 'Request refused'} (HTTP ${status}). The service's message for this request ` + 'was not passed on, because AWS can quote the signed request in it, credentials ' + 'included. Check the caller’s credentials and its permission to invoke this runtime.'); } return (`${type ?? 'Request failed'} (HTTP ${status}). The service's message for this request ` + 'was not passed on, because it can quote what the request carried.'); } /** A JSON-RPC error reply rebuilt from safe parts only. */ function safeErrorReply(message) { const error = message['error']; const fields = error !== null && typeof error === 'object' && !Array.isArray(error) ? error : {}; const rawCode = fields['code']; const code = typeof rawCode === 'number' && Number.isInteger(rawCode) ? rawCode : -32603; const rawId = message['id']; const id = typeof rawId === 'number' && Number.isFinite(rawId) ? rawId : typeof rawId === 'string' && PLAIN_TOKEN.test(rawId) ? rawId : null; const rawMessage = fields['message']; const text = typeof rawMessage === 'string' && KNOWN_ERROR_MESSAGES.has(rawMessage) ? rawMessage : `Server error (JSON-RPC ${code}). Its message was not passed on, because it can quote ` + 'what the request carried.'; return { jsonrpc: '2.0', id, error: { code, message: text } }; } function plainType(type) { return type !== undefined && PLAIN_TYPE.test(type) ? type : undefined; } // ── event streams ────────────────────────────────────────────────────── /** * A 2xx event stream, read once as it passes: each event parsed by the SSE * rules, its data parsed strictly as ONE JSON-RPC message, and re-emitted in * canonical form (`event`, `id` when plain, one `data` line) — an error * rewritten. Events still reach the client as they arrive. An event that * cannot be read unambiguously ends the stream with a refusal in this * library's words. */ function safeStream(response) { const body = response.body; if (body === null) { return new Response(null, { status: response.status, headers: keptHeaders(response.headers, undefined), }); } const decoder = new TextDecoder('utf-8', { fatal: true }); const encoder = new TextEncoder(); const parser = sseParser((event) => encoder.encode(canonicalEvent(event))); const stream = body.pipeThrough(new TransformStream({ transform(chunk, controller) { let text; try { text = decoder.decode(chunk, { stream: true }); } catch { controller.error(new Error(streamRefusal('an event stream that is not UTF-8'))); return; } try { for (const out of parser.push(text)) controller.enqueue(out); } catch (err) { controller.error(new Error(streamRefusal(err instanceof Unreadable ? err.reason : 'an unreadable event'))); } }, flush(controller) { let tail; try { tail = decoder.decode(); } catch { controller.error(new Error(streamRefusal('an event stream that is not UTF-8'))); return; } try { for (const out of [...parser.push(tail), ...parser.finish()]) controller.enqueue(out); } catch (err) { controller.error(new Error(streamRefusal(err instanceof Unreadable ? err.reason : 'an unreadable event'))); } }, })); return new Response(stream, { status: response.status, headers: keptHeaders(response.headers, 'text/event-stream'), }); } function streamRefusal(reason) { return (`agentCoreRuntimeMcpTransport: the event stream was cut: ${reason}. The rest was not ` + 'passed on, because a reading the transport could not check is a reading the model must ' + 'not get.'); } /** * The SSE line rules (WHATWG `text/event-stream`): lines end in CRLF, LF or CR; * a blank line dispatches; `:` starts a comment; `data` lines join with `\n`; * one leading space after the colon is dropped; an event with no data is not * dispatched. */ function sseParser(emit) { let pending = ''; let data = []; let type; let id; const line = (raw, out) => { if (raw === '') { if (data.length > 0) { out.push(emit({ ...(type !== undefined && { type }), ...(id !== undefined && { id }), data: data.join('\n'), })); } data = []; type = undefined; return; } if (raw.startsWith(':')) return; const colon = raw.indexOf(':'); const field = colon === -1 ? raw : raw.slice(0, colon); const value = colon === -1 ? '' : raw.slice(colon + 1).replace(/^ /, ''); if (field === 'data') data.push(value); else if (field === 'event') type = value; else if (field === 'id' && !value.includes('\0')) id = value; }; return { push(text) { pending += text; const out = []; for (;;) { const match = /\r\n|\n|\r/.exec(pending); if (match === null) break; // A CR at the very end may be the first half of a CRLF still to come. if (match[0] === '\r' && match.index === pending.length - 1) break; line(pending.slice(0, match.index), out); pending = pending.slice(match.index + match[0].length); } return out; }, finish() { // The stream ended: a CR held back for a CRLF that never came ends its // line. Text with no line end is an unfinished line, and an event with // no blank line after it is not dispatched (the SSE rule). const out = []; if (pending.endsWith('\r')) line(pending.slice(0, -1), out); pending = ''; return out; }, }; } /** One event in canonical form — its data the ONE message it held, read strictly. */ function canonicalEvent(event) { // An event type that is not a plain token cannot be re-emitted as it was, // and re-emitting it as a default `message` would make the client act on an // event it would have ignored — so it is dropped. if (event.type !== undefined && !PLAIN_TOKEN.test(event.type)) return ''; const message = oneMessage(parseOnce(event.data)); const lines = []; if (event.type !== undefined) lines.push(`event: ${event.type}`); if (event.id !== undefined && PLAIN_TOKEN.test(event.id)) lines.push(`id: ${event.id}`); lines.push(`data: ${JSON.stringify(canonicalMessage(message))}`); return `${lines.join('\n')}\n\n`; } //# sourceMappingURL=safeAnswer.js.map