UNPKG

@ai-sdk/mcp

Version:
220 lines (209 loc) • 6.67 kB
import { MCPClientError } from '../error/mcp-client-error'; import { createMCPEventSecret, decodeMCPEventSecret, } from './mcp-event-signature'; import { ListEventsResultSchema, SubscribeEventResultSchema, type MCPEventStore, type MCPEventsConfig, type MCPEvents, type MCPEventSubscription, } from './mcp-event-types'; import { ResultSchema, type Request, type RequestOptions } from './types'; import type { z } from 'zod/v4'; export const MCP_EVENT_CALLBACK_KEY = 'mcp_event_subscription'; export function createMCPEvents({ request, store, validateArguments, }: { request: <T extends z.ZodType<object>>(args: { request: Request; resultSchema: T; options?: RequestOptions; }) => Promise<z.infer<T>>; store?: MCPEventStore; validateArguments?: MCPEventsConfig['validateArguments']; }): MCPEvents { function getStore(): MCPEventStore { if (!store) { throw new MCPClientError({ message: 'Configure experimental_events.store to manage direct event subscriptions', }); } return store; } async function getSubscription({ id, key, }: Parameters<MCPEvents['refresh']>[0]): Promise<MCPEventSubscription> { const storage = getStore(); const subscription = id !== undefined ? await storage.getById(id) : await storage.get(key); if (!subscription) { throw new MCPClientError({ message: `Unknown MCP event subscription: ${id ?? key}`, }); } return subscription; } async function subscribe( subscription: MCPEventSubscription, options?: RequestOptions, ) { const result = await request({ request: { method: 'events/subscribe', params: { name: subscription.name, arguments: subscription.arguments, delivery: subscription.delivery, cursor: subscription.cursor, ...(subscription.ttlMs === undefined ? {} : { ttlMs: subscription.ttlMs }), ...(subscription.maxAgeMs === undefined ? {} : { maxAgeMs: subscription.maxAgeMs }), }, }, resultSchema: SubscribeEventResultSchema, options, }); await getStore().update(subscription.key, { id: result.id, refreshBefore: result.refreshBefore, cursor: result.cursor, truncated: result.truncated, status: 'active', }); return result; } const events: MCPEvents = { list({ params, options } = {}) { return request({ request: { method: 'events/list', params }, resultSchema: ListEventsResultSchema, options, }); }, async subscribe({ name, arguments: args = {}, delivery, cursor = null, ttlMs, maxAgeMs, options, }) { const storage = getStore(); const url = new URL(delivery.url); const localHttp = delivery.allowInsecureLocalhost === true && url.protocol === 'http:' && (url.hostname === '127.0.0.1' || url.hostname === '[::1]'); if ( (url.protocol !== 'https:' && !localHttp) || url.username || url.password || url.hash ) { throw new MCPClientError({ message: 'MCP event callback URLs must use HTTPS without credentials or fragments', }); } if (delivery.mode !== 'webhook') { throw new MCPClientError({ message: 'Only webhook event delivery is supported', }); } if ( (ttlMs != null && (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)) || (maxAgeMs != null && (!Number.isSafeInteger(maxAgeMs) || maxAgeMs < 0)) ) { throw new MCPClientError({ message: 'Invalid MCP event subscription lifetime or replay age', }); } let pageCursor: string | undefined; const visited = new Set<string>(); let definition; do { const page = await events.list({ params: { cursor: pageCursor }, options, }); definition = page.events.find(event => event.name === name); if (definition) break; pageCursor = page.nextCursor ?? undefined; if (pageCursor != null) { if (visited.has(pageCursor)) { throw new MCPClientError({ message: 'Repeated MCP events pagination cursor', }); } visited.add(pageCursor); } } while (pageCursor != null); if (!definition) { throw new MCPClientError({ message: `Unknown MCP event: ${name}` }); } if (!definition.delivery.includes('webhook')) { throw new MCPClientError({ message: `MCP event ${name} does not support webhook delivery`, }); } await validateArguments?.({ definition, arguments: args }); const secret = delivery.secret ?? createMCPEventSecret(); decodeMCPEventSecret(secret); // A routing key is available before the server-derived subscription ID. // Persist it and the secret before the server attempts callback verification. const key = crypto.randomUUID(); url.searchParams.set(MCP_EVENT_CALLBACK_KEY, key); const subscription: MCPEventSubscription = { key, name, arguments: structuredClone(args), definition, delivery: { mode: 'webhook', url: url.href, secret }, cursor, truncated: false, ttlMs, maxAgeMs, status: 'pending', }; await storage.set(subscription); // Retain pending state on errors: a timed-out request may have registered // the callback remotely. Recover or unsubscribe using the stored key. return subscribe(subscription, options); }, async refresh(args) { const subscription = await getSubscription(args); // Deliveries must retry until the renewed expiration is persisted. // Retain pending state on errors, as the server may have renewed already. await getStore().update(subscription.key, { status: 'pending' }); return subscribe(subscription, args.options); }, async unsubscribe(args) { const subscription = await getSubscription(args); await request({ request: { method: 'events/unsubscribe', params: { name: subscription.name, arguments: subscription.arguments, delivery: { mode: 'webhook', url: subscription.delivery.url }, }, }, resultSchema: ResultSchema, options: args.options, }); await getStore().delete(subscription.key); }, }; return events; }