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
JavaScript
/**
* 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