@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
text/typescript
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']