/*
RFC-0290: the JSON-RPC method router for the stateless MCP subset. Handles
exactly: server/discover, ping, tools/list, tools/call, resources/list,
resources/read. Everything else is method-not-found (batching is rejected
one level up, at the transport boundary in index.ts).
RFC-1113: the 2026-07-28 stateless era — every request except server/discover
must declare _meta["io.modelcontextprotocol/protocolVersion"] from
SUPPORTED_MCP_PROTOCOL_VERSIONS; the legacy initialize handshake returns
UnsupportedProtocolVersionError naming the supported list.
- Do not implement prompts/sampling/logging/notifications — out of the pinned subset.
- Do not enforce auth — only rate limits and size caps (RFC-0291).
- RFC-0291: add rate limiting, size cap, identity passthrough, freshness _meta.
- RFC-1112: idempotency via params._meta, shared executeAction core, problem-details in error.data.
- RFC-1113: server/discover + per-request protocol-version gate; initialize → UnsupportedProtocolVersionError.
- RFC-1113: align agent discovery surface with RFC 9727 and MCP 2026-07-28 stateless era
- RFC-1211: resources/list + resources/read serve committed ui:// artifacts; ui-capable action results embed a {type:"resource"} block.
RFC-0290
*/
import type { AgentSurfaceManifest } from "@warpgogol/werkstatt-shared/agent";
import type { CapabilityRecord } from "@warpgogol/werkstatt-shared/ontology";
import type { AgentGatePorts } from "../ports.ts";
import type { VerifiedAgent } from "@warpgogol/werkstatt-shared/agent";
import type { RateLimiter } from "../limits.ts";
import { validateAgainstCapabilitySchema } from "../actions.ts";
import {
buildPreviewFailure,
checkIdempotencyReplay,
executeAction,
mcpBodyHash,
rateLimitKey,
rateLimitMax,
MCP_IDEMPOTENCY_META_KEY,
} from "../action-pipeline.ts";
import { PRESENTATION_META_KEY } from "../presentation.ts";
import { buildAgentProblem, type AgentProblemDetails } from "@warpgogol/werkstatt-shared/agent";
import { buildToolsList } from "./tools.ts";
import { buildUiResourceList, resolveUiResourcePath, uiResourceContentBlock } from "./resources.ts";
import {
SUPPORTED_MCP_PROTOCOL_VERSIONS,
JSON_RPC_ERROR,
jsonRpcError,
jsonRpcSuccess,
readRequestProtocolVersion,
unsupportedProtocolVersion,
type JsonRpcRequest,
type JsonRpcResponse,
} from "./protocol.ts";
export interface McpHandlerContext {
manifest: AgentSurfaceManifest;
catalog: CapabilityRecord[];
ports: AgentGatePorts;
/** RFC-0291: identity headers extracted from the HTTP request (observe-only). */
agentIdentity?: Record;
/** RFC-0291: client IP for rate-limit keying. */
clientIp?: string;
/**
* RFC-1187: Bearer identity resolved at the transport boundary — selects
* the elevated per-agent bucket and stamps verifiedAgentId, same contract
* as the HTTP action route.
*/
verifiedAgent?: VerifiedAgent;
}
// RFC-0291: module-level limiter cache for the MCP path (mirrors index.ts).
const mcpLimiterCache = new Map();
function getMcpLimiter(
capabilityId: string,
maxPerWindow: number,
factory: (max: number) => RateLimiter,
): RateLimiter {
const cacheKey = `${capabilityId}:${maxPerWindow}`;
let limiter = mcpLimiterCache.get(cacheKey);
if (!limiter) {
limiter = factory(maxPerWindow);
mcpLimiterCache.set(cacheKey, limiter);
}
return limiter;
}
/** Reset the MCP limiter cache — test-only. */
export function __resetMcpLimiterCache(): void {
mcpLimiterCache.clear();
}
function absoluteUrl(baseUrl: string, path: string): string {
return `${baseUrl}${path}`;
}
async function handleToolsCall(
req: JsonRpcRequest,
ctx: McpHandlerContext,
): Promise {
const params = (req.params ?? {}) as { name?: unknown; arguments?: unknown };
const name = typeof params.name === "string" ? params.name : "";
const knowledgeMatch = /^knowledge\.(.+)\.get$/.exec(name);
if (knowledgeMatch) {
const domain = knowledgeMatch[1];
const ref = ctx.manifest.knowledge.find((k) => k.domain === domain);
if (!ref) {
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.INVALID_PARAMS, `Unknown tool "${name}".`);
}
const text = await ctx.ports.knowledge.read(ref.url);
if (text === null) {
return jsonRpcError(
req.id ?? null,
JSON_RPC_ERROR.SERVER_ERROR,
`Could not read "${ref.url}".`,
);
}
// RFC-0291: append freshness into MCP _meta where envelopes carry it.
let _meta: Record | undefined;
try {
const envelope = JSON.parse(text) as { freshness?: unknown };
if (envelope.freshness) {
_meta = { "gogol.dev/freshness": envelope.freshness };
}
} catch {
// malformed JSON — the text is passed through as-is; freshness is absent.
}
return jsonRpcSuccess(req.id ?? null, {
content: [{ type: "text", text }],
...(_meta ? { _meta } : {}),
});
}
const actionMatch = /^action\.(.+)$/.exec(name);
if (actionMatch) {
const capabilityId = actionMatch[1];
const capability = ctx.catalog.find((c) => c.id === capabilityId);
const ref = ctx.manifest.actions.find((a) => a.id === capabilityId);
if (!capability || !ref) {
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.INVALID_PARAMS, `Unknown tool "${name}".`);
}
// RFC-1211: ui-capable capabilities get a {type:"resource"} content block
// on every tool result — the committed /.well-known/ui/.html bytes when
// readable, else the bare uri+mimeType reference for resources/read.
const uiContentBlock = async () =>
capability.ui?.resource === true
? uiResourceContentBlock(
ctx.manifest.site,
capabilityId,
await ctx.ports.knowledge.read(`/.well-known/ui/${capabilityId}.html`),
)
: null;
// RFC-0291: payload size cap on MCP action arguments.
const argsBytes = new TextEncoder().encode(JSON.stringify(params.arguments ?? {})).length;
if (argsBytes > capability.limits.maxPayloadBytes) {
const problem = buildAgentProblem("payload-too-large", {
detail: `Arguments are ${argsBytes} bytes; the limit is ${capability.limits.maxPayloadBytes}.`,
});
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.SERVER_ERROR, problem.title, problem);
}
// RFC-1112: idempotency key travels in params._meta (MCP has no headers);
// the body hash is the canonical JSON hash of the arguments.
const meta = (params as { _meta?: Record })._meta;
const idempotencyKey =
typeof meta?.[MCP_IDEMPOTENCY_META_KEY] === "string"
? (meta[MCP_IDEMPOTENCY_META_KEY] as string)
: undefined;
const bodyHash = idempotencyKey ? mcpBodyHash(params.arguments) : undefined;
// RFC-1213: verifiable presentation rides params._meta (MCP has no headers).
const presentation = meta?.[PRESENTATION_META_KEY];
// RFC-1112: replay check runs before rate limiting — a replay must not
// consume budget.
const replay = await checkIdempotencyReplay({
ports: ctx.ports,
capability,
idempotencyKey,
bodyHash,
});
if (replay !== null) {
if ("receiptId" in replay) {
return jsonRpcSuccess(req.id ?? null, {
content: [{ type: "text", text: JSON.stringify(replay) }],
});
}
const problem = replay as AgentProblemDetails;
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.SERVER_ERROR, problem.title, problem);
}
// RFC-1187: elevated per-agent bucket when a Bearer token verified and the
// capability declares perMinutePerAgent — same helpers as the HTTP path.
if (ctx.ports.createRateLimiter) {
const subject = {
capabilityId,
capability,
clientIp: ctx.clientIp ?? "unknown",
verifiedAgent: ctx.verifiedAgent,
};
const limiter = getMcpLimiter(
capabilityId,
rateLimitMax(subject),
ctx.ports.createRateLimiter,
);
const result = limiter.check(rateLimitKey(subject));
if (!result.allowed) {
const problem = buildAgentProblem("rate-limited", {
retryAfterSeconds: result.retryAfterSeconds,
});
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.SERVER_ERROR, problem.title, problem);
}
}
const validated = validateAgainstCapabilitySchema(capability.input, params.arguments ?? {});
if (!validated.ok) {
// RFC-1169: preview capabilities answer validation failures with the
// readiness envelope — identical fields to the HTTP path.
if (capability.sideEffect === "none") {
const uiBlock = await uiContentBlock();
return jsonRpcSuccess(req.id ?? null, {
content: [
{ type: "text", text: JSON.stringify(buildPreviewFailure(validated.errors)) },
...(uiBlock ? [uiBlock] : []),
],
});
}
const problem = buildAgentProblem("schema-violation", {
schemaRef: `/.well-known/agent.openapi.json#/components/schemas/${capabilityId}-input`,
errors: validated.errors,
});
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.INVALID_PARAMS, problem.title, problem);
}
// RFC-1112: shared execution core — preview for sideEffect "none",
// dispatch + receipt otherwise.
const result = await executeAction({
capability,
manifest: ctx.manifest,
ports: ctx.ports,
validatedValue: validated.value,
agentIdentity: ctx.agentIdentity ?? {},
idempotencyKey,
bodyHash,
presentation,
});
switch (result.kind) {
case "receipt": {
const uiBlock = await uiContentBlock();
return jsonRpcSuccess(req.id ?? null, {
content: [
{ type: "text", text: JSON.stringify(result.receipt) },
...(uiBlock ? [uiBlock] : []),
],
});
}
case "preview": {
const uiBlock = await uiContentBlock();
return jsonRpcSuccess(req.id ?? null, {
content: [
{ type: "text", text: JSON.stringify(result.envelope) },
...(uiBlock ? [uiBlock] : []),
],
});
}
case "problem":
return jsonRpcError(
req.id ?? null,
JSON_RPC_ERROR.SERVER_ERROR,
result.problem.title,
result.problem,
);
}
}
return jsonRpcError(req.id ?? null, JSON_RPC_ERROR.INVALID_PARAMS, `Unknown tool "${name}".`);
}
async function handleResourcesRead(
req: JsonRpcRequest,
ctx: McpHandlerContext,
): Promise {
const params = (req.params ?? {}) as { uri?: unknown };
const uri = typeof params.uri === "string" ? params.uri : "";
// RFC-1211: ui:// URIs resolve to the committed /.well-known/ui/.html
// artifact through the same static-artifact port as knowledge files.
const uiPath = resolveUiResourcePath(uri, ctx.manifest.site, ctx.catalog);
if (uiPath !== null) {
const text = await ctx.ports.knowledge.read(uiPath);
if (text === null) {
return jsonRpcError(
req.id ?? null,
JSON_RPC_ERROR.SERVER_ERROR,
`Could not read "${uiPath}".`,
);
}
return jsonRpcSuccess(req.id ?? null, {
contents: [{ uri, mimeType: "text/html", text }],
});
}
const ref = ctx.manifest.knowledge.find((k) => absoluteUrl(ctx.manifest.baseUrl, k.url) === uri);
if (!ref) {
return jsonRpcError(
req.id ?? null,
JSON_RPC_ERROR.INVALID_PARAMS,
`Unknown resource "${uri}".`,
);
}
const text = await ctx.ports.knowledge.read(ref.url);
if (text === null) {
return jsonRpcError(
req.id ?? null,
JSON_RPC_ERROR.SERVER_ERROR,
`Could not read "${ref.url}".`,
);
}
// RFC-0291: append freshness into MCP _meta where envelopes carry it.
let _meta: Record | undefined;
try {
const envelope = JSON.parse(text) as { freshness?: unknown };
if (envelope.freshness) {
_meta = { "gogol.dev/freshness": envelope.freshness };
}
} catch {
// malformed JSON — freshness is absent.
}
return jsonRpcSuccess(req.id ?? null, {
contents: [{ uri, mimeType: "application/json", text }],
...(_meta ? { _meta } : {}),
});
}
/** Route one JSON-RPC request to its handler. The transport boundary (index.ts) owns batching/HTTP-method rejection and the MCP-Protocol-Version header consistency check. */
export async function handleJsonRpcRequest(
req: JsonRpcRequest,
ctx: McpHandlerContext,
): Promise {
const id = req.id ?? null;
// RFC-1113: server/discover is the version-negotiation recovery path — it
// answers unconditionally so a client holding an unsupported version can
// still learn SUPPORTED_MCP_PROTOCOL_VERSIONS (spec: servers MUST implement it).
if (req.method === "server/discover") {
return jsonRpcSuccess(id, {
resultType: "complete",
supportedVersions: [...SUPPORTED_MCP_PROTOCOL_VERSIONS],
capabilities: { tools: {}, resources: {} },
_meta: {
"io.modelcontextprotocol/serverInfo": {
name: `${ctx.manifest.site} agent surface`,
version: ctx.manifest.surfaceVersion,
},
},
});
}
// RFC-1113: initialize is the legacy-era handshake — a modern-only server
// answers with the spec-defined error naming the supported versions.
if (req.method === "initialize") {
const params = (req.params ?? {}) as { protocolVersion?: unknown };
return unsupportedProtocolVersion(
id,
params.protocolVersion ?? readRequestProtocolVersion(req),
);
}
// RFC-1113: per-request version gate — every other method must declare a
// supported version in params._meta["io.modelcontextprotocol/protocolVersion"].
// Absent or unsupported → UnsupportedProtocolVersionError (HTTP 400 upstream).
const requestedVersion = readRequestProtocolVersion(req);
if (
requestedVersion === undefined ||
!(SUPPORTED_MCP_PROTOCOL_VERSIONS as readonly string[]).includes(requestedVersion)
) {
return unsupportedProtocolVersion(id, requestedVersion);
}
switch (req.method) {
case "ping":
return jsonRpcSuccess(id, {});
case "tools/list":
return jsonRpcSuccess(id, { tools: buildToolsList(ctx.manifest, ctx.catalog) });
case "tools/call":
return handleToolsCall(req, ctx);
case "resources/list":
return jsonRpcSuccess(id, {
resources: [
...ctx.manifest.knowledge.map((ref) => ({
uri: absoluteUrl(ctx.manifest.baseUrl, ref.url),
name: ref.domain,
mimeType: "application/json",
})),
// RFC-1211: committed ui:// projections of ui-capable capabilities.
...buildUiResourceList(ctx.manifest.site, ctx.catalog),
],
});
case "resources/read":
return handleResourcesRead(req, ctx);
default:
return jsonRpcError(id, JSON_RPC_ERROR.METHOD_NOT_FOUND, `Method "${req.method}" not found.`);
}
}