UNPKG

@tanstack/ai-mcp

Version:

Host-side Model Context Protocol client for TanStack AI: discover and run MCP server tools, resources, and prompts in any adapter's chat() loop, with generated end-to-end types.

236 lines (235 loc) • 8 kB
import { MCPEventConfigError } from "../errors.js"; import { readCallbackKey } from "./callback-url.js"; import { classifyWebhookBody } from "./wire.js"; import { MCPEventWebhookError } from "./types.js"; import { verifyMCPEventSignature } from "./signature.js"; //#region src/events/webhook.ts var DEFAULT_TOLERANCE_SECONDS = 300; var DEFAULT_MAX_BODY_BYTES = 262144; function isJsonMediaType(value) { if (value === null) return false; return value.split(";", 1)[0]?.trim().toLowerCase() === "application/json"; } function empty(status, extra) { return new Response(null, { status, headers: extra }); } function toInfo(record) { return { key: record.key, id: record.id ?? "", name: record.name, arguments: record.arguments, delivery: { mode: "webhook", url: record.delivery.url }, refreshBefore: record.refreshBefore ?? null }; } function isExpired(record, nowMs) { if (typeof record.refreshBefore !== "string") return false; const expires = Date.parse(record.refreshBefore); return !Number.isNaN(expires) && expires < nowMs; } function isPending(record) { return record.status === "pending" || record.id === void 0; } function header(request, name) { const value = request.headers.get(name); return value === null || value.trim() === "" ? null : value; } function report(onError, error) { try { onError?.(error); } catch {} } var BodyTooLargeError = class extends Error { constructor() { super("Webhook body exceeds the maximum size"); this.name = "BodyTooLargeError"; } }; async function readBoundedBody(request, maxBytes) { const reader = request.body?.getReader(); if (reader === void 0) return /* @__PURE__ */ new Uint8Array(); const chunks = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > maxBytes) { await reader.cancel().catch(() => void 0); throw new BodyTooLargeError(); } chunks.push(value); } const out = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.byteLength; } return out; } /** * Framework-agnostic MCP Events webhook receiver. * * Pass a Web `Request`. It verifies the Standard Webhooks signature on the * raw body, then calls `onEvent` / `onGap` / `onTerminated`. * * @experimental * * @example * ```ts * import { createMCPEventWebhook, inMemoryMCPEventStore } from '@tanstack/ai-mcp/events' * * const store = inMemoryMCPEventStore() * const webhook = createMCPEventWebhook({ * store, * onEvent: async ({ event }) => { * await queue.send({ eventId: event.eventId, data: event.data }) * }, * }) * * export const POST = async ({ request }: { request: Request }) => webhook(request) * ``` */ function createMCPEventWebhook(options) { const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; const now = options.now ?? Date.now; return async (request) => { const key = readCallbackKey(request.url); const fail = (status, stage, message, cause) => { report(options.onError, new MCPEventWebhookError(stage, message, { subscriptionKey: key, cause })); return empty(status); }; if (request.method !== "POST") return empty(405, { Allow: "POST" }); if (!isJsonMediaType(request.headers.get("content-type"))) return fail(415, "headers", "Unsupported media type"); const contentLength = Number(request.headers.get("content-length")); if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) return fail(413, "body", "Webhook body is too large"); const webhookId = header(request, "webhook-id"); const webhookTimestamp = header(request, "webhook-timestamp"); const webhookSignature = header(request, "webhook-signature"); const subscriptionId = header(request, "x-mcp-subscription-id"); if (webhookId === null || webhookTimestamp === null || webhookSignature === null || subscriptionId === null) return fail(401, "headers", "Missing webhook headers"); if (key === void 0) return fail(401, "routing", "Missing subscription key"); let record; try { record = await options.store.get(key); } catch (cause) { return fail(503, "store", "Subscription lookup failed", cause); } if (record === void 0) return fail(401, "routing", "Unknown subscription key"); if (record.id !== void 0 && record.id !== subscriptionId) return fail(401, "routing", "Subscription id mismatch"); if (!/^-?\d+$/.test(webhookTimestamp)) return fail(401, "timestamp", "Invalid webhook timestamp"); const timestamp = Number(webhookTimestamp); if (Math.abs(Math.floor(now() / 1e3) - timestamp) > tolerance) return fail(401, "timestamp", "Webhook timestamp is outside the tolerance window"); let body; try { body = await readBoundedBody(request, maxBodyBytes); } catch (cause) { return cause instanceof BodyTooLargeError ? fail(413, "body", "Webhook body is too large", cause) : fail(503, "body", "Failed to read webhook body", cause); } let verified; try { verified = await verifyMCPEventSignature({ secret: record.delivery.secret, messageId: webhookId, timestamp, body, header: webhookSignature }); } catch (cause) { if (cause instanceof MCPEventConfigError) return fail(503, "store", "Stored webhook secret is invalid", cause); return fail(401, "signature", "Webhook signature verification failed", cause); } if (!verified) return fail(401, "signature", "Webhook signature verification failed"); let json; try { json = JSON.parse(new TextDecoder().decode(body)); } catch (cause) { return fail(400, "body", "Webhook body is not JSON", cause); } const classified = classifyWebhookBody(json); if (classified.kind === "verification") return Response.json({ challenge: classified.challenge }); if (classified.kind === "invalid") return fail(400, "envelope", classified.issue); const info = toInfo(record); if (classified.kind === "terminated") { try { await options.onTerminated?.({ subscription: info, messageId: webhookId, termination: classified.error }); } catch (cause) { return fail(503, "handler", "onTerminated failed", cause); } try { await options.store.delete(key); } catch (cause) { return fail(503, "store", "Failed to delete terminated subscription", cause); } return empty(204); } if (isPending(record)) return fail(503, "routing", "Subscription is still pending"); if (classified.kind === "gap") { try { await options.onGap?.({ subscription: info, messageId: webhookId, gap: { cursor: classified.cursor, truncated: true } }); } catch (cause) { return fail(503, "handler", "onGap failed", cause); } try { await options.store.update(key, { cursor: classified.cursor, truncated: true }); } catch (cause) { return fail(503, "store", "Failed to persist gap cursor", cause); } return empty(204); } if (isExpired(record, now())) return fail(410, "routing", "Subscription lease expired"); if (classified.event.name !== record.name || classified.event.eventId !== webhookId) return fail(400, "envelope", "Event does not match the subscription"); try { await options.validatePayload?.({ definition: record.definition, data: classified.event.data }); } catch (cause) { return fail(400, "payload", "Event payload was rejected", cause); } try { await options.onEvent({ subscription: info, event: classified.event, messageId: webhookId }); } catch (cause) { return fail(503, "handler", "onEvent failed", cause); } if (classified.event.cursor !== void 0) try { await options.store.update(key, { cursor: classified.event.cursor }); } catch (cause) { return fail(503, "store", "Failed to persist event cursor", cause); } return empty(204); }; } //#endregion export { createMCPEventWebhook }; //# sourceMappingURL=webhook.js.map