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.

447 lines (419 loc) • 10.4 kB
import type { JSONObject, StandardSchemaV1 } from '@modelcontextprotocol/client' export type { JSONObject, JSONValue } from '@modelcontextprotocol/client' /** * Delivery modes an event type may advertise. * * @experimental */ export type MCPEventDeliveryMode = 'poll' | 'push' | 'webhook' /** * One event type from `events/list`. * * @experimental * * @example * ```ts * const events = await client.experimental_events.list() * const incident = events.find((event) => event.name === 'incident.created') * ``` */ export interface MCPEventDefinition { name: string description?: string delivery: Array<MCPEventDeliveryMode> inputSchema: unknown payloadSchema: unknown _meta?: JSONObject } /** * Server-reported webhook delivery health on subscribe/refresh. * * @experimental */ export interface MCPEventDeliveryStatus { active?: boolean lastDeliveryAt?: string lastError?: string | null failedSince?: string throttled?: boolean retryAfterMs?: number } /** * Result of `events/subscribe` after the client stores a local key. * * @experimental */ export interface MCPEventSubscribeResult { key: string id: string refreshBefore: string | null cursor: string | null truncated: boolean deliveryStatus?: MCPEventDeliveryStatus } /** * One delivered event occurrence. * * @experimental */ export interface MCPEventOccurrence { eventId: string name: string timestamp: string data: JSONObject cursor?: string | null } /** * Stored subscription. Must stay server-side: it holds the HMAC secret. * * @experimental */ export interface MCPEventSubscription { key: string id?: string name: string arguments: JSONObject definition: MCPEventDefinition delivery: { mode: 'webhook'; url: string; secret: string } refreshBefore?: string | null cursor: string | null truncated: boolean ttlMs?: number | null maxAgeMs?: number status: 'pending' | 'active' } /** * Handler-safe view of a subscription. No secret. * * @experimental */ export interface MCPEventSubscriptionInfo { key: string id: string name: string arguments: JSONObject delivery: { mode: 'webhook'; url: string } refreshBefore: string | null } /** * Client store for webhook subscriptions. * * `update` must merge only the supplied fields. * `list` is optional so a cron job can refresh every subscription. * * @experimental * * @example * ```ts * import { inMemoryMCPEventStore } from '@tanstack/ai-mcp/events' * * const store = inMemoryMCPEventStore() * const mcp = await createMCPClient({ * transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, * experimental_events: { store }, * }) * ``` */ export interface MCPEventStore { get: (key: string) => Promise<MCPEventSubscription | undefined> getById: (id: string) => Promise<MCPEventSubscription | undefined> set: (subscription: MCPEventSubscription) => Promise<void> update: ( key: string, patch: Partial<Omit<MCPEventSubscription, 'key'>>, ) => Promise<void> delete: (key: string) => Promise<void> list?: () => AsyncIterable<MCPEventSubscription> } /** * Direct mode: the client talks to the MCP server and this store. * * @experimental */ export interface DirectMCPEventsOptions { store: MCPEventStore adapter?: never /** * Called on each `subscribe()` call, not on `refresh()`. Throw to reject. * The SDK does not validate against `inputSchema`. * * @example * ```ts * experimental_events: { * store, * validateArguments: ({ arguments: args }) => { * if (typeof args.repo !== 'string') throw new Error('repo required') * }, * } * ``` */ validateArguments?: (args: { definition: MCPEventDefinition arguments: JSONObject }) => void | PromiseLike<void> } /** * Transport context passed to a managed events adapter. * * @experimental */ export type MCPEventAdapterTransport = | { type: 'http'; url: string } | { type: 'sse'; url: string } | { type: 'custom' } /** * Status values used by managed adapters such as Vercel Connect. * * @experimental */ export type ManagedMCPSubscriptionStatus = | 'pending' | 'active' | 'needs_auth' | 'stopped' | 'expired' | 'failed' /** * One managed subscription record. * * @experimental */ export interface ManagedMCPSubscription { id: string name: string arguments: JSONObject status: ManagedMCPSubscriptionStatus expiresAt: string | null idempotencyKey?: string context?: JSONObject } /** * Input for a managed adapter `subscribe` call. * * @experimental */ export interface ManagedMCPSubscribeInput { name: string arguments: JSONObject context?: JSONObject idempotencyKey: string ttlMs: number | null options?: { signal?: AbortSignal; timeout?: number } } /** * Operations returned by `adapter.createAdapter`. * * @experimental */ export interface MCPEventOperations { subscribe: ( input: ManagedMCPSubscribeInput, ) => Promise<ManagedMCPSubscription> getSubscription: (id: string) => Promise<ManagedMCPSubscription | undefined> listSubscriptions: () => Promise<Array<ManagedMCPSubscription>> unsubscribe: (id: string) => Promise<void> } /** * Hosted-backend adapter. `createAdapter` is synchronous and called once. * * @experimental * * @example * ```ts * const mcp = await createMCPClient({ * transport: { type: 'http', url: 'https://mcp.example.com/mcp' }, * experimental_events: { adapter }, * }) * await mcp.experimental_events.subscribe({ * name: 'incident.created', * arguments: { repo: 'acme/app' }, * idempotencyKey: 'incident:acme/app', * ttlMs: 3_600_000, * }) * ``` */ export interface MCPEventAdapter { createAdapter: (context: { transport: MCPEventAdapterTransport }) => MCPEventOperations } /** * Managed mode: subscribe goes through the adapter. `list` still uses MCP. * * @experimental */ export interface ManagedMCPEventsOptions { adapter: MCPEventAdapter store?: never validateArguments?: never } /** * `createMCPClient({ experimental_events })` option. * Omit it for discovery-only (`list()`). * * @experimental */ export type MCPEventsOptions = DirectMCPEventsOptions | ManagedMCPEventsOptions /** * Lookup key for refresh and unsubscribe in direct mode. * * @experimental */ export type MCPEventSubscriptionRef = ( | { key: string; id?: never } | { id: string; key?: never } ) & { signal?: AbortSignal } /** * Options for a direct-mode subscribe call. * * @experimental * * @example * ```ts * const sub = await client.experimental_events.subscribe({ * name: 'incident.created', * arguments: { repo: 'acme/app' }, * delivery: { * mode: 'webhook', * url: 'https://app.example.com/api/mcp-events', * }, * }) * ``` */ export interface SubscribeMCPEventOptions { name: string arguments?: JSONObject delivery: { mode: 'webhook' url: string secret?: string allowInsecureLocalhost?: boolean } cursor?: string | null ttlMs?: number | null maxAgeMs?: number signal?: AbortSignal } /** * Direct and discovery events API on `client.experimental_events`. * * @experimental */ export interface MCPEvents { list: (options?: { signal?: AbortSignal }) => Promise<Array<MCPEventDefinition>> subscribe: ( options: SubscribeMCPEventOptions, ) => Promise<MCPEventSubscribeResult> refresh: ( options: MCPEventSubscriptionRef, ) => Promise<MCPEventSubscribeResult> unsubscribe: (options: MCPEventSubscriptionRef) => Promise<void> } /** * Managed events API: MCP `list()` plus adapter operations. No `refresh`. * * @experimental */ export interface ManagedMCPEvents extends MCPEventOperations { list: (options?: { signal?: AbortSignal }) => Promise<Array<MCPEventDefinition>> } /** * What `client.ts` hands to the `events/*` modules. Internal. */ export interface MCPEventsContext { /** Sends one `events/*` request. The SDK validates the result with `schema`. */ request: <T>( method: 'events/list' | 'events/subscribe' | 'events/unsubscribe', params: Record<string, unknown>, schema: StandardSchemaV1<unknown, T>, signal?: AbortSignal, ) => Promise<T> assertOpen: () => void readonly supportsEvents: boolean readonly transport: MCPEventAdapterTransport } /** * Receiver options for `createMCPEventWebhook`. * * @experimental * * @example * ```ts * import { createMCPEventWebhook } from '@tanstack/ai-mcp/events' * * const webhook = createMCPEventWebhook({ * store, * onEvent: async ({ event }) => { * await queue.send({ eventId: event.eventId, data: event.data }) * }, * }) * ``` */ export interface MCPEventWebhookOptions { store: MCPEventStore onEvent: (args: { subscription: MCPEventSubscriptionInfo event: MCPEventOccurrence messageId: string }) => Promise<void> | void onGap?: (args: { subscription: MCPEventSubscriptionInfo messageId: string gap: { cursor: string; truncated: true } }) => Promise<void> | void onTerminated?: (args: { subscription: MCPEventSubscriptionInfo messageId: string termination: { code: number; message: string; data?: unknown } }) => Promise<void> | void /** * Throw to reject the event with HTTP 400. */ validatePayload?: (args: { definition: MCPEventDefinition data: JSONObject }) => void | PromiseLike<void> /** * Called on failed checks. Never receives secrets. */ onError?: (error: MCPEventWebhookError) => void toleranceSeconds?: number maxBodyBytes?: number now?: () => number } /** * Receiver error passed to `onError`. Messages never include the secret. * * @experimental */ export class MCPEventWebhookError extends Error { readonly stage: | 'headers' | 'routing' | 'signature' | 'timestamp' | 'body' | 'envelope' | 'payload' | 'handler' | 'store' readonly subscriptionKey?: string constructor( stage: MCPEventWebhookError['stage'], message: string, options?: { subscriptionKey?: string; cause?: unknown }, ) { super( message, options?.cause === undefined ? undefined : { cause: options.cause }, ) this.name = 'MCPEventWebhookError' this.stage = stage this.subscriptionKey = options?.subscriptionKey } } export type MCPEventWebhookStage = MCPEventWebhookError['stage']