UNPKG

agentfootprint

Version:

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

167 lines • 8.56 kB
/** * gatewayTransport — an MCP transport whose auth headers are vended per * request, not per connection. * * const gateway = await mcpClient({ * name: 'gateway', * transport: gatewayTransport({ url, credentials, service: 'gateway' }), * }); * * ── Why this exists ────────────────────────────────────────────────────────── * The `http` transport takes headers once and reuses them for the life of the * connection. That is right for a static API key and wrong for anything with an * expiry: a standing agent outlives its bearer token, and the failure mode is a * burst of 401s an hour into a session that worked perfectly when you tested * it. Managed gateways — AgentCore's among them — hand out exactly that kind of * token, so "get a fresh one before each request" is not an optimisation, it is * the only way the connection stays up. * * Nothing here is vendor-specific. It is the {@link CredentialProvider} port on * one side and Streamable HTTP on the other; who vends is your choice. * * ── The secrecy invariant, and how it is kept ──────────────────────────────── * **A vended token is used once and dropped.** It is not cached between * requests, not stored on the transport object, not put in an event payload, * not written to a log, and not included in any error this module throws — * including the errors it throws WHILE holding one. A test asserts that: a * hostile logger subscribed to everything the library can say never sees the * header value. * * That is why the vend happens inside `fetch` rather than at construction. A * token resolved at construction has to live somewhere for the connection to * use it later, and "somewhere" is what leaks. * * Pattern: Adapter + Decorator over `fetch`. Role: Layer-3 tool integration. */ import { isCredentialIssued } from '../identity/types.js'; import { throwIfAborted, untilAborted } from '../lib/abort.js'; import { guardOf, trackedBy } from './requestGuard.js'; const DEFAULT_SERVICE = 'gateway'; /** * Describe an MCP connection whose auth headers are vended per request. * * @example A managed gateway with a token vault behind it * import { agentCoreIdentity } from 'agentfootprint/security'; * import { gatewayTransport, mcpClient } from 'agentfootprint/providers'; * * const gateway = await mcpClient({ * name: 'gateway', * transport: gatewayTransport({ * url: process.env.GATEWAY_MCP_URL!, * credentials: agentCoreIdentity({ region: 'us-west-2' }), * service: 'gateway', * }), * }); * const agent = Agent.create({ provider, model }).tools(await gateway.tools()).build(); */ export function gatewayTransport(options) { if (!options.url) throw new Error(`gatewayTransport requires 'url'.`); if (!options.credentials) throw new Error(`gatewayTransport requires 'credentials'.`); return { transport: 'gateway', url: options.url, credentials: options.credentials, service: options.service ?? DEFAULT_SERVICE, ...(options.scopes !== undefined && { scopes: options.scopes }), ...(options.mode !== undefined && { mode: options.mode }), ...(options.headers !== undefined && { headers: options.headers }), ...(options.fetch !== undefined && { fetch: options.fetch }), }; } /** * Raised when the provider needs a human to authorize before it can vend. * * The URL is carried so the caller can surface it (pause the run, show a * link); **the token is not, because there is not one yet** — that is the whole * meaning of this error. */ export class GatewayAuthorizationRequiredError extends Error { code = 'ERR_GATEWAY_AUTHORIZATION_REQUIRED'; /** Send the user here, then retry. */ authorizationUrl; constructor(service, authorizationUrl) { super(`[mcp] the credential provider needs a person to authorize '${service}' before it can ` + `vend a token for this gateway. A transport cannot run a consent flow mid-request — ` + `surface the authorization URL to the user (pause the run), then call the tool again ` + `once they have authorized.`); this.name = 'GatewayAuthorizationRequiredError'; this.authorizationUrl = authorizationUrl; } } /** * The signal `fetch(input, init)` would honour — fetch's own rule, as * `new Request(input, init)` applies it: an `init` that names a `signal` * replaces the Request's, and `null` there means no signal at all; otherwise a * Request input keeps its own. */ function effectiveSignal(input, init) { if (init?.signal !== undefined) return init.signal ?? undefined; return typeof Request !== 'undefined' && input instanceof Request ? input.signal : undefined; } /** * Build the `fetch` the MCP SDK will use: one that vends a credential, applies * it to this one request, and keeps nothing. * * The vend is a wait, and it honours the request's abort signal — resolved as * fetch resolves it (`init.signal`, else a Request input's own): the provider * is handed it (`CredentialRequest.signal`), the wait ends at the abort with the * abort, and the signal is checked again once the credential arrives. So an * aborted or timed-out request is never sent: the gateway stops waiting and * never uses a late credential. A provider that ignores cancellation may still * finish its own work. * * Exported for tests — the secrecy invariant is asserted against this function * directly, because that is where a leak would happen and driving a whole SDK * to reach it would test less while looking like it tested more. * * @internal */ export function createVendingFetch(transport, baseFetch = globalThis.fetch.bind(globalThis)) { return async (input, init) => { // The request's own signal, as fetch itself resolves it: the transport's, // plus — for a dispatched call or under a deadline — the cancellation the // retry series links in; or a Request input's own. const signal = effectiveSignal(input, init); // An aborted request asks no provider and sends nothing (`lib/abort.ts`). throwIfAborted(signal); // The provider gets the signal, to stop its own work. The wait is raced // against it, so a provider that ignores it cannot hold the request open. const vend = Promise.resolve(transport.credentials.getCredential({ service: transport.service, ...(transport.scopes !== undefined && { scopes: transport.scopes }), ...(transport.mode !== undefined && { mode: transport.mode }), ...(signal !== undefined && { signal }), })); // A pooled request counts the vend itself, not the raced wait: a provider // that ignores the abort is still work under the operation until it // settles (`requestGuard.ts`). const guard = guardOf(init); guard?.track(vend); const result = await untilAborted(vend, signal); // Checked again once the credential is here, before the request is handed // on: a credential that lands after the abort is never used, so an aborted // or timed-out request is never sent, however late the provider answers. // A pooled request's send gate is asked here too: a binding that went // stale while the credential was vended sends nothing. throwIfAborted(signal); guard?.check(); if (!isCredentialIssued(result)) { throw new GatewayAuthorizationRequiredError(transport.service, result.authorizationUrl); } // Static headers first, vended ones last: a vended auth header always wins, // so a stale static one can never quietly shadow the live credential. const headers = new Headers(init?.headers ?? {}); for (const [key, value] of Object.entries(transport.headers ?? {})) headers.set(key, value); for (const [key, value] of Object.entries(result.credential.toHeaders())) { headers.set(key, value); } // The vended value exists only inside this call, on this Headers object, // for the duration of this request. Nothing above closes over it. return trackedBy(init, baseFetch(input, { ...init, headers })); }; } //# sourceMappingURL=gatewayTransport.js.map