- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
MCP Events
An MCP server can push work to you: a new incident, a finished job, a ticket change. You want that push to hit your app as a signed webhook, not as a poll loop.
@tanstack/ai-mcp implements the MCP Events extension (SEP-3415) for webhook delivery. You list event types, subscribe with a callback URL, and handle deliveries with createMCPEventWebhook.
Experimental. The wire format follows SEP-3415 draft revision 2ec39b9. Method names and error codes can still change.
A TanStack Start app that subscribes and receives a signed webhook is in Subscribe to MCP Events.
Install#
Install the client package. The events API is a subpath of that package.
npm i @tanstack/ai-mcp @modelcontextprotocol/clientpnpm add @tanstack/ai-mcp @modelcontextprotocol/clientyarn add @tanstack/ai-mcp @modelcontextprotocol/clientbun add @tanstack/ai-mcp @modelcontextprotocol/clientImport the client from @tanstack/ai-mcp. Import the webhook and the store from @tanstack/ai-mcp/events. Keep the store on the server. It holds HMAC secrets.
Pick a mode#
| Mode | Option | What you get |
|---|---|---|
| Discovery | omit experimental_events | list() only |
| Direct | { store } | list, subscribe, refresh, unsubscribe |
| Managed | { adapter } | list over MCP, subscribe through a host such as Vercel Connect |
The client option is experimental_events. The ./events exports have no experimental_ prefix. They are tagged @experimental.
List events#
import { createMCPClient } from '@tanstack/ai-mcp'
const mcp = await createMCPClient({
transport: {
type: 'http',
url: 'https://mcp.example.com/mcp',
},
})
const events = await mcp.experimental_events.list()list() works without a store. subscribe throws until you pass { store } or { adapter }.
The client sends events/* only when the server declares capabilities.extensions["io.modelcontextprotocol/events"] (SEP-3415). Otherwise it throws MCPEventsUnsupportedError.
Subscribe (direct)#
import { createMCPClient } from '@tanstack/ai-mcp'
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 },
})
const sub = await mcp.experimental_events.subscribe({
name: 'incident.created',
arguments: { repo: 'acme/app' },
delivery: {
mode: 'webhook',
url: 'https://app.example.com/api/mcp-events',
},
})inMemoryMCPEventStore is for development and tests. Put a durable store in production.
What subscribe does:
- Calls events/list and throws MCPUnknownEventError if the event is missing or has no webhook delivery.
- Writes a pending record before it calls events/subscribe.
- Appends mcp_event_subscription=<key> to the callback URL.
- Generates a whsec_ secret if you did not pass one.
- Stores id, refreshBefore, cursor, and truncated when the server answers. If the server call fails, it deletes the pending record.
Pass allowInsecureLocalhost: true only for http://127.0.0.1 or http://[::1] in local tests.
Refresh is a second subscribe with the stored secret and cursor:
await mcp.experimental_events.refresh({ key: sub.key })Unsubscribe sends { mode: 'webhook', url } and deletes the local record. If the server returns NotFound, the local record is still deleted.
MCPEventRequestError.kind is callback-endpoint for SEP -32027. A code the SEP does not define gives kind: 'unknown'.
Receive webhooks#
Handle the raw body. Do not parse JSON and stringify it again. The signature is HMAC-SHA256 over the exact bytes.
TanStack Start#
import { createFileRoute } from '@tanstack/react-router'
import { createMCPEventWebhook, inMemoryMCPEventStore } from '@tanstack/ai-mcp/events'
import { queue } from './queue'
const store = inMemoryMCPEventStore()
const webhook = createMCPEventWebhook({
store,
onEvent: async ({ event, messageId }) => {
await queue.send({ messageId, eventId: event.eventId, data: event.data })
},
})
export const Route = createFileRoute('/api/mcp-events')({
server: {
handlers: {
POST: ({ request }) => webhook(request),
},
},
})Next.js App Router#
import { createMCPEventWebhook, inMemoryMCPEventStore } from '@tanstack/ai-mcp/events'
import { queue } from './queue'
const store = inMemoryMCPEventStore()
const webhook = createMCPEventWebhook({
store,
onEvent: async ({ event }) => {
await queue.send(event)
},
})
export async function POST(request: Request) {
return webhook(request)
}Express#
Read the raw buffer. Skip express.json() on this route.
import express from 'express'
import { createMCPEventWebhook, inMemoryMCPEventStore } from '@tanstack/ai-mcp/events'
import { queue } from './queue'
const app = express()
const store = inMemoryMCPEventStore()
const webhook = createMCPEventWebhook({
store,
onEvent: async ({ event }) => {
await queue.send(event)
},
})
app.post(
'/api/mcp-events',
express.raw({ type: 'application/json', limit: '256kb' }),
async (req, res) => {
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') headers.set(key, value)
}
const request = new Request(`http://${req.headers.host}${req.url}`, {
method: 'POST',
headers,
body: req.body,
})
const response = await webhook(request)
res.status(response.status)
response.headers.forEach((value, key) => {
res.setHeader(key, value)
})
res.end()
},
)Receiver status codes#
Each delivery must send these headers: webhook-id, webhook-timestamp, webhook-signature, and X-MCP-Subscription-Id.
| Status | When |
|---|---|
| 200 | Verification challenge. The body is { "challenge": "..." } |
| 204 | Event, gap, or terminated succeeded |
| 400 | Body is not JSON, the envelope is invalid, or the payload was rejected |
| 401 | A required header is missing, the route key is missing or unknown, the subscription id does not match, the signature is bad, or the timestamp is more than 300 seconds off |
| 405 | Method is not POST |
| 410 | The lease expired |
| 413 | Body larger than 256 KiB |
| 415 | Content type is not application/json |
| 503 | The record is still pending, the stored secret is invalid, or onEvent / onGap / onTerminated / the store threw |
Every status except 200 and 204 calls onError. A 503 tells the server to retry, so dedupe in your app on (subscription.id, messageId).
Renew leases#
The SDK does not auto-refresh. Run a cron job over store.list?.().
import type { MCPClient } from '@tanstack/ai-mcp'
import type { MCPEventStore } from '@tanstack/ai-mcp/events'
async function renewDue(store: MCPEventStore, mcp: MCPClient) {
const events = mcp.experimental_events
if (events === undefined) return
const now = Date.now()
const list = store.list
if (list === undefined) return
for await (const record of list()) {
if (record.status !== 'active') continue
if (record.refreshBefore === null || record.refreshBefore === undefined) {
continue
}
const due = Date.parse(record.refreshBefore)
if (Number.isNaN(due) || due - now > 60_000) continue
await events.refresh({ key: record.key })
}
}Cursors, gaps, and termination#
| Payload | Handler | Store effect |
|---|---|---|
| Event | onEvent | Writes event.cursor when the field is present |
| { type: 'gap', cursor } | onGap | Sets cursor and truncated: true |
| { type: 'terminated', error } | onTerminated | Deletes the subscription |
| { type: 'verification', challenge } | none | Echoes { challenge } with 200 |
A gap means you missed events. Refresh with the stored cursor to resume.
Security#
- Verify the Standard Webhooks v1 signature on the raw body.
- Reject timestamps outside ±300 seconds.
- Cap the body at 256 KiB.
- Require https callback URLs, except loopback http in tests.
- Keep secrets out of logs. onError never receives the secret.
Do not treat events/list schemas as a security boundary. Pass validateArguments and validatePayload if you need checks.
Feed an event into chat()#
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
createMCPEventWebhook,
inMemoryMCPEventStore,
} from '@tanstack/ai-mcp/events'
const store = inMemoryMCPEventStore()
const webhook = createMCPEventWebhook({
store,
onEvent: async ({ subscription, event }) => {
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
threadId: `mcp-events-${subscription.key}`,
messages: [
{
role: 'user',
content: `Incident ${event.eventId}: ${JSON.stringify(event.data)}`,
},
],
})
for await (const chunk of stream) {
void chunk
}
},
})Close MCP clients after the stream drains if you also pass mcp: { clients }.
Managed adapters#
Pass { adapter } when a host owns the webhook endpoint.
import { createMCPClient } from '@tanstack/ai-mcp'
import { adapter } from './adapter'
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,
})createAdapter is synchronous and runs before connect. The context is { type: 'http', url }, { type: 'sse', url }, or { type: 'custom' }. There is no refresh in managed mode.
Server emitter (illustrative)#
This package does not ship createMCPServer({ events }) yet. A low-level SDK Server can still advertise the extension:
import { Server } from '@modelcontextprotocol/server'
const passThrough = {
'~standard': {
version: 1 as const,
vendor: 'example',
validate(value: unknown) {
return { value }
},
},
}
const server = new Server(
{ name: 'incidents', version: '1.0.0' },
{
capabilities: {
extensions: {
'io.modelcontextprotocol/events': {},
},
},
},
)
server.setRequestHandler('events/list', { params: passThrough }, async () => {
return {
events: [
{
name: 'incident.created',
delivery: ['webhook'],
inputSchema: { type: 'object' },
payloadSchema: true,
},
],
}
})Treat that snippet as a sketch. Production delivery needs a durable store, retries, and an SSRF guard.
Troubleshooting#
| Symptom | Check |
|---|---|
| MCPEventsUnsupportedError | The server did not declare extensions["io.modelcontextprotocol/events"] |
| 401 on every delivery | Middleware parsed the body. Use the raw bytes |
| 401 without a query string | A proxy dropped mcp_event_subscription |
| Duplicate onEvent calls | Dedupe on messageId. 503 after a store failure retries |
| Subscribe times out | The client deletes the pending record. Call subscribe again |
| Mcp-Name missing | With experimental_events set, HTTP and SSE transports add it to events/subscribe and events/unsubscribe. Non-ASCII names use =?base64?...?= |
Next: follow Subscribe to MCP Events, or put createMCPEventWebhook on a POST route and subscribe with { store }.