/*
RFC-0290: the Agent Gate — a framework-agnostic runtime that turns a site's
Agent Surface Manifest + active capability records into (a) a stateless MCP
endpoint and (b) direct HTTP action handlers. Instantiated with data (the
manifest), never with per-site code (AS-1). astro.ts is the only
astro-aware caller.
- Do not read files, secrets, or the network directly — ports do that.
- RFC-1114: minimal real A2A endpoint + honest agent card
- RFC-1114: review findings — schema-violation for malformed message, drop unreachable fallback
- RFC-1172: closed agent.traffic telemetry — ports.telemetry sink, TrafficRecorder + boundary emit via withTrafficEmit
- RFC-1172: fo-review — extractAgentIdentity on all routes, honest mcpProtocolVersion, top-level import, test header
- RFC-1187: phases 1-3 — contracts, Redis registry, AS core + Bearer pipeline
RFC-0290, RFC-0291, RFC-1112, RFC-1113, RFC-1114
*/
import type { AgentSurfaceManifest } from "@warpgogol/werkstatt-shared/agent";
import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology";
import type { AgentGatePorts } from "./ports.ts";
import type { RateLimiter } from "./limits.ts";
import { handleJsonRpcRequest } from "./mcp/handler.ts";
import { handleA2aRequest } from "./a2a/handler.ts";
import {
A2A_METHOD_SEND_MESSAGE,
A2A_VERSION_HEADER,
a2aVersionNotSupported,
isSupportedA2aVersionHeader,
} from "./a2a/protocol.ts";
import {
isJsonRpcRequest,
jsonRpcError,
readRequestProtocolVersion,
JSON_RPC_ERROR,
MCP_PROTOCOL_VERSION_HEADER,
type JsonRpcResponse,
} from "./mcp/protocol.ts";
import type { TrafficRecorder } from "./telemetry.ts";
import { extractHttpPresentation } from "./presentation.ts";
import {
type ActionContext,
resolveCapability,
checkPayloadSize,
checkIdempotency,
checkRateLimit,
rateLimitMax,
resolveAgentAuth,
parseJsonBody,
validateSchema,
dispatchOrPreview,
deprecationHeaders,
extractIdempotencyKey,
httpBodyHash,
problemResponse,
} from "./action-pipeline.ts";
import { buildAgentProblem } from "@warpgogol/werkstatt-shared/agent";
import { resolveBearerAgent } from "./agent-auth.ts";
export type { AgentGatePorts } from "./ports.ts";
export * from "./agent-auth.ts";
export * from "./actions.ts";
export * from "./mcp/protocol.ts";
export * from "./mcp/tools.ts";
export * from "./limits.ts";
export type { McpHandlerContext } from "./mcp/handler.ts";
export type { A2aHandlerContext } from "./a2a/handler.ts";
export * from "./a2a/protocol.ts";
export type { ActionContext, StepResult } from "./action-pipeline.ts";
/**
* RFC-0291: in-isolate limiter store. Keyed by `${capabilityId}:${maxPerWindow}`
* so different capabilities get separate limiters. Extracted from the monolithic
* handler to enable testing and future swap-out (e.g. Durable Objects).
*/
class LimitStore {
private cache = new Map();
get(capabilityId: string, maxPerWindow: number, ports: AgentGatePorts): RateLimiter | null {
if (!ports.createRateLimiter) return null;
const cacheKey = `${capabilityId}:${maxPerWindow}`;
let limiter = this.cache.get(cacheKey);
if (!limiter) {
limiter = ports.createRateLimiter(maxPerWindow);
this.cache.set(cacheKey, limiter);
}
return limiter;
}
clear(): void {
this.cache.clear();
}
}
const limitStore = new LimitStore();
/** Reset the limiter cache — test-only. */
export function __resetLimiterCache(): void {
limitStore.clear();
}
function extractClientIp(request: Request): string {
return request.headers.get("CF-Connecting-IP") ?? "unknown";
}
const IDENTITY_HEADERS = ["Signature-Agent", "Signature", "User-Agent"] as const;
const IDENTITY_MAX_BYTES = 1024;
/** RFC-0291/1172: observed identity headers (bounded) — exported for adapter routes. */
export function extractAgentIdentity(request: Request): Record {
const identity: Record = {};
let totalBytes = 0;
for (const header of IDENTITY_HEADERS) {
const value = request.headers.get(header);
if (!value) continue;
const remaining = IDENTITY_MAX_BYTES - totalBytes;
if (remaining <= 0) break;
const truncated = value.slice(0, remaining);
identity[header] = truncated;
totalBytes += new TextEncoder().encode(truncated).length;
}
return identity;
}
function jsonResponse(
body: unknown,
status = 200,
extraHeaders?: Record,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...extraHeaders },
});
}
export interface AgentGate {
handleMcp(request: Request, traffic?: TrafficRecorder): Promise;
handleAction(
capabilityId: string,
request: Request,
traffic?: TrafficRecorder,
): Promise;
handleA2a(request: Request, traffic?: TrafficRecorder): Promise;
}
/** Construct the gate for one site from its manifest + active capability records + ports. */
export function createAgentGate(
manifest: AgentSurfaceManifest,
catalog: CapabilityRecord[],
ports: AgentGatePorts,
): AgentGate {
return {
async handleMcp(request: Request, traffic?: TrafficRecorder): Promise {
if (request.method !== "POST") {
if (traffic) traffic.operation = "other";
return new Response(null, { status: 405, headers: { Allow: "POST" } });
}
let body: unknown;
try {
body = await request.json();
} catch {
if (traffic) {
traffic.operation = "other";
traffic.statusOverride = "rejected";
}
return jsonResponse(jsonRpcError(null, JSON_RPC_ERROR.PARSE_ERROR, "Invalid JSON."));
}
if (Array.isArray(body)) {
if (traffic) {
traffic.operation = "other";
traffic.statusOverride = "rejected";
}
return jsonResponse(
jsonRpcError(null, JSON_RPC_ERROR.INVALID_REQUEST, "Batch requests are not supported."),
);
}
if (!isJsonRpcRequest(body)) {
if (traffic) {
traffic.operation = "other";
traffic.statusOverride = "rejected";
}
return jsonResponse(
jsonRpcError(null, JSON_RPC_ERROR.INVALID_REQUEST, "Invalid JSON-RPC 2.0 request."),
);
}
// RFC-1172: request-shaped fields — method picks the operation, capability
// carries the raw method (tool name for tools/call).
if (traffic) {
traffic.observedIdentity = extractAgentIdentity(request);
// RFC-1172: the declared version — equal to negotiated on success (the
// gate enforces header==meta), absent on initialize/no-version paths
// where nothing was negotiated.
traffic.mcpProtocolVersion =
readRequestProtocolVersion(body) ??
request.headers.get(MCP_PROTOCOL_VERSION_HEADER) ??
undefined;
const method = body.method;
const toolName =
method === "tools/call" && typeof body.params === "object" && body.params !== null
? (body.params as { name?: unknown }).name
: undefined;
traffic.operation = method === "tools/list" || method === "tools/call" ? method : "other";
traffic.capability =
method === "tools/call" && typeof toolName === "string" ? toolName : method;
}
// RFC-1113 / MCP 2026-07-28: on Streamable HTTP the MCP-Protocol-Version
// header MUST match _meta["io.modelcontextprotocol/protocolVersion"];
// a mismatch is a 400 Bad Request.
const headerVersion = request.headers.get(MCP_PROTOCOL_VERSION_HEADER);
const metaVersion = readRequestProtocolVersion(body);
if (headerVersion !== null && metaVersion !== undefined && headerVersion !== metaVersion) {
if (traffic) traffic.statusOverride = "failure";
return jsonResponse(
jsonRpcError(
body.id ?? null,
JSON_RPC_ERROR.INVALID_REQUEST,
`${MCP_PROTOCOL_VERSION_HEADER} header does not match the _meta protocol version.`,
),
400,
);
}
// RFC-1187: a verified Bearer upgrades tools/call to the registered tier
// — the same contract as the HTTP action route (degrades to anonymous).
const verifiedAgent = ports.auth ? await resolveBearerAgent(request, ports.auth) : null;
const agentIdentity = extractAgentIdentity(request);
if (verifiedAgent) agentIdentity.verifiedAgentId = verifiedAgent.agentId;
if (traffic?.observedIdentity === undefined) {
if (traffic) traffic.observedIdentity = agentIdentity;
}
const result: JsonRpcResponse = await handleJsonRpcRequest(body, {
manifest,
catalog,
ports,
agentIdentity,
clientIp: extractClientIp(request),
...(verifiedAgent ? { verifiedAgent } : {}),
});
// RFC-1172: JSON-RPC errors ride HTTP 200 — the status code cannot
// express them, so record the semantic outcome + problem token.
if (traffic && "error" in result) {
traffic.statusOverride =
result.error.code === JSON_RPC_ERROR.METHOD_NOT_FOUND ? "rejected" : "failure";
traffic.problemKind = `json-rpc:${result.error.code}`;
}
// RFC-1113: the 2026-07-28 schema requires UnsupportedProtocolVersionError
// to be served with HTTP 400 on the HTTP transport.
const status =
"error" in result && result.error.code === JSON_RPC_ERROR.UNSUPPORTED_PROTOCOL_VERSION
? 400
: 200;
return jsonResponse(result, status);
},
async handleAction(
capabilityId: string,
request: Request,
traffic?: TrafficRecorder,
): Promise {
const raw = await request.text();
const clientIp = extractClientIp(request);
const agentIdentity = extractAgentIdentity(request);
if (traffic) {
traffic.operation = "action.call";
traffic.capability = capabilityId;
traffic.observedIdentity = agentIdentity;
}
// RFC-1112: the idempotency key + body hash are extracted before the
// pipeline so the replay check can run ahead of rate limiting.
const idempotencyKey = extractIdempotencyKey(request);
const bodyHash = idempotencyKey ? httpBodyHash(raw) : undefined;
const ctx: ActionContext = {
capabilityId,
request,
manifest,
catalog,
ports,
rawBody: raw,
clientIp,
agentIdentity,
idempotencyKey,
bodyHash,
// RFC-1213: verifiable presentation rides the dedicated header — the
// body schema stays closed per capability.
presentation: extractHttpPresentation(request),
...(traffic ? { traffic } : {}),
};
const steps: (() => Response | Promise | void)[] = [
() => resolveCapability(ctx),
() => checkPayloadSize(ctx),
() => checkIdempotency(ctx),
// RFC-1187: Bearer resolution precedes the limiter — a verified agent
// selects the elevated per-agent bucket (rateLimitMax/Key read ctx).
() => resolveAgentAuth(ctx),
() =>
checkRateLimit(
ctx,
limitStore.get(`${capabilityId}:${rateLimitMax(ctx)}`, rateLimitMax(ctx), ports),
),
() => parseJsonBody(ctx),
() => validateSchema(ctx),
() => dispatchOrPreview(ctx),
];
let response: Response | undefined;
for (const step of steps) {
const result = await step();
if (result instanceof Response) {
response = result;
break;
}
}
// Unreachable in practice — dispatchOrPreview always returns a Response.
response ??= problemResponse(
buildAgentProblem("dispatch-failed", { detail: "Pipeline completed without a result." }),
);
// RFC-1112: mark responses when no receipt store is configured.
if (!ports.idempotency) response.headers.set("X-Agent-Idempotency", "disabled");
// RFC-1171: deprecated alias paths answer with RFC 9745/8594 headers;
// canonical paths stay clean.
const depHeaders = deprecationHeaders(manifest, capabilityId, new URL(request.url).pathname);
if (depHeaders) {
for (const [name, value] of Object.entries(depHeaders)) {
response.headers.set(name, value);
}
}
return response;
},
async handleA2a(request: Request, traffic?: TrafficRecorder): Promise {
if (request.method !== "POST") {
if (traffic) traffic.operation = "other";
return new Response(null, { status: 405, headers: { Allow: "POST" } });
}
let body: unknown;
try {
body = await request.json();
} catch {
if (traffic) {
traffic.operation = "other";
traffic.statusOverride = "rejected";
}
return jsonResponse(jsonRpcError(null, JSON_RPC_ERROR.PARSE_ERROR, "Invalid JSON."));
}
if (Array.isArray(body)) {
if (traffic) {
traffic.operation = "other";
traffic.statusOverride = "rejected";
}
return jsonResponse(
jsonRpcError(null, JSON_RPC_ERROR.INVALID_REQUEST, "Batch requests are not supported."),
);
}
if (!isJsonRpcRequest(body)) {
if (traffic) {
traffic.operation = "other";
traffic.statusOverride = "rejected";
}
return jsonResponse(
jsonRpcError(null, JSON_RPC_ERROR.INVALID_REQUEST, "Invalid JSON-RPC 2.0 request."),
);
}
// RFC-1172: SendMessage dispatches an action; anything else is `other`
// with the raw method in capability.
if (traffic) {
traffic.observedIdentity = extractAgentIdentity(request);
traffic.operation = body.method === A2A_METHOD_SEND_MESSAGE ? "action.call" : "other";
traffic.capability = body.method;
}
// RFC-1114: A2A-Version header gate — a present header must match 1.x;
// absent is served as 1.0 (documented deviation: no 0.3 semantics exist).
const a2aVersion = request.headers.get(A2A_VERSION_HEADER);
if (!isSupportedA2aVersionHeader(a2aVersion)) {
// Non-null here: isSupportedA2aVersionHeader only rejects a present header.
return jsonResponse(a2aVersionNotSupported(body.id ?? null, a2aVersion!), 400);
}
// RFC-1187: Bearer upgrades SendMessage to the registered tier — same
// contract as the HTTP action route and MCP tools/call.
const verifiedAgent = ports.auth ? await resolveBearerAgent(request, ports.auth) : null;
const agentIdentity = extractAgentIdentity(request);
if (verifiedAgent) agentIdentity.verifiedAgentId = verifiedAgent.agentId;
if (traffic) traffic.observedIdentity = agentIdentity;
const result: JsonRpcResponse = await handleA2aRequest(body, {
manifest,
catalog,
ports,
agentIdentity,
clientIp: extractClientIp(request),
idempotencyKey: extractIdempotencyKey(request),
presentation: extractHttpPresentation(request),
...(verifiedAgent ? { verifiedAgent } : {}),
});
// RFC-1172: JSON-RPC errors ride HTTP 200 — record the semantic outcome.
if (traffic && "error" in result) {
traffic.statusOverride =
result.error.code === JSON_RPC_ERROR.METHOD_NOT_FOUND ? "rejected" : "failure";
traffic.problemKind = `json-rpc:${result.error.code}`;
}
const response = jsonResponse(result);
// RFC-1112: mark responses when no receipt store is configured.
if (!ports.idempotency) response.headers.set("X-Agent-Idempotency", "disabled");
return response;
},
};
}
export { createAgentReflectRoute } from "./reflect-route.ts";
export type { CreateAgentReflectRouteOptions } from "./reflect-route.ts";