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.

328 lines (306 loc) • 9.53 kB
import { MCPEventConfigError } from '../errors' import { MCPEventWebhookError } from './types' import type { MCPEventSubscription, MCPEventSubscriptionInfo, MCPEventWebhookOptions, MCPEventWebhookStage, } from './types' import { readCallbackKey } from './callback-url' import { verifyMCPEventSignature } from './signature' import { classifyWebhookBody } from './wire' const DEFAULT_TOLERANCE_SECONDS = 300 const DEFAULT_MAX_BODY_BYTES = 262144 function isJsonMediaType(value: string | null): boolean { if (value === null) return false const media = value.split(';', 1)[0]?.trim().toLowerCase() return media === 'application/json' } function empty(status: number, extra?: HeadersInit): Response { return new Response(null, { status, headers: extra }) } function toInfo(record: MCPEventSubscription): MCPEventSubscriptionInfo { 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: MCPEventSubscription, nowMs: number): boolean { if (typeof record.refreshBefore !== 'string') return false const expires = Date.parse(record.refreshBefore) return !Number.isNaN(expires) && expires < nowMs } function isPending(record: MCPEventSubscription): boolean { return record.status === 'pending' || record.id === undefined } function header(request: Request, name: string): string | null { const value = request.headers.get(name) return value === null || value.trim() === '' ? null : value } function report( onError: MCPEventWebhookOptions['onError'], error: MCPEventWebhookError, ) { try { onError?.(error) } catch { // Receiver errors must not throw out of the handler. } } class BodyTooLargeError extends Error { constructor() { super('Webhook body exceeds the maximum size') this.name = 'BodyTooLargeError' } } async function readBoundedBody( request: Request, maxBytes: number, ): Promise<Uint8Array> { const reader = request.body?.getReader() if (reader === undefined) return new Uint8Array() const chunks: Array<Uint8Array> = [] let total = 0 for (;;) { const { done, value } = await reader.read() if (done) break total += value.byteLength if (total > maxBytes) { await reader.cancel().catch(() => undefined) 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) * ``` */ export function createMCPEventWebhook( options: MCPEventWebhookOptions, ): (request: Request) => Promise<Response> { 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: number, stage: MCPEventWebhookStage, message: string, cause?: unknown, ) => { 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 === undefined) { return fail(401, 'routing', 'Missing subscription key') } let record: MCPEventSubscription | undefined try { record = await options.store.get(key) } catch (cause) { return fail(503, 'store', 'Subscription lookup failed', cause) } if (record === undefined) { return fail(401, 'routing', 'Unknown subscription key') } if (record.id !== undefined && 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() / 1000) - timestamp) > tolerance) { return fail( 401, 'timestamp', 'Webhook timestamp is outside the tolerance window', ) } let body: Uint8Array 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: boolean try { verified = await verifyMCPEventSignature({ secret: record.delivery.secret, messageId: webhookId, timestamp, body, header: webhookSignature, }) } catch (cause) { // A bad stored secret is our fault, not the sender's: ask for a retry. 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: unknown 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 !== undefined) { 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) } }