@ai-sdk/mcp
Version:
239 lines (229 loc) • 8.21 kB
text/typescript
import { safeParseJSON } from '@ai-sdk/provider-utils';
import { z } from 'zod/v4';
import { MCP_EVENT_CALLBACK_KEY } from './mcp-events';
import { verifyMCPEventSignature } from './mcp-event-signature';
import {
MCPEventSchema,
MCPEventControlSchema,
type MCPEvent,
type MCPEventControl,
type MCPEventDefinition,
type MCPEventStore,
type MCPEventSubscriptionInfo,
} from './mcp-event-types';
const MAX_BODY_BYTES = 256 * 1024;
const VerificationSchema = z.object({
type: z.literal('verification'),
challenge: z.string().min(1),
});
export type MCPEventWebhookOptions = {
store: MCPEventStore;
/**
* Optionally validate event data using an application schema or the server's
* payloadSchema. Throw to reject the payload with 400 before onEvent runs.
* The event envelope and signature are always checked.
*/
validatePayload?: (args: {
definition: MCPEventDefinition;
data: MCPEvent['data'];
}) => void | PromiseLike<void>;
/**
* Resolve only after durable acceptance or successful processing. Deliveries
* may be duplicated or reordered; deduplicate by subscription ID + eventId.
* Throw to return 503 so the server can retry. The helper does not run agents.
*/
onEvent: (args: {
subscription: MCPEventSubscriptionInfo;
event: MCPEvent;
}) => Promise<void>;
/**
* Handles a signed replay gap: some events are no longer available. Reconcile
* application state or notify the agent, then resolve after durable acceptance.
* The helper saves the fresh cursor and truncated flag; delivery continues.
* Throw to return 503 without changing saved state. Deduplicate by subscription
* ID + messageId.
*/
onGap?: (args: {
subscription: MCPEventSubscriptionInfo;
messageId: string;
gap: Extract<MCPEventControl, { type: 'gap' }>;
}) => Promise<void>;
/**
* Handles signed termination: the server has ended the subscription. Inspect
* termination.error and notify the agent, then resolve after durable acceptance.
* The helper removes the saved subscription after this callback succeeds.
* Throw to return 503 without changing saved state. Deduplicate by subscription
* ID + messageId.
*/
onTerminated?: (args: {
subscription: MCPEventSubscriptionInfo;
messageId: string;
termination: Extract<MCPEventControl, { type: 'terminated' }>;
}) => Promise<void>;
onError?: (error: unknown) => void;
};
/**
* Create a Web Request/Response handler for MCP webhook deliveries. Mount it
* at the callback URL before subscribing. Verification is enabled automatically
* using the pending delivery secret saved by the client's subscribe method.
*/
export function createMCPEventWebhook({
store,
validatePayload,
onEvent,
onGap,
onTerminated,
onError,
}: MCPEventWebhookOptions): (request: Request) => Promise<Response> {
return async request => {
if (request.method !== 'POST') {
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
}
if (
request.headers
.get('content-type')
?.split(';')[0]
.trim()
.toLowerCase() !== 'application/json'
) {
return new Response(null, { status: 415 });
}
if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) {
return new Response(null, { status: 413 });
}
try {
const key = new URL(request.url).searchParams.get(MCP_EVENT_CALLBACK_KEY);
const id = request.headers.get('X-MCP-Subscription-Id');
if (!key || !id) return new Response(null, { status: 401 });
const subscription = await store.get(key);
if (
!subscription ||
(subscription.id != null && subscription.id !== id)
) {
return new Response(null, { status: 401 });
}
// Bound streamed bodies too; Content-Length is optional and untrusted.
const reader = request.body?.getReader();
if (!reader) return new Response(null, { status: 400 });
const chunks: Uint8Array[] = [];
let length = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > MAX_BODY_BYTES) {
await reader.cancel();
return new Response(null, { status: 413 });
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.length;
}
if (
!(await verifyMCPEventSignature({
headers: request.headers,
body,
secret: subscription.delivery.secret,
}))
) {
return new Response(null, { status: 401 });
}
const parsed = await safeParseJSON({
text: new TextDecoder().decode(body),
});
if (!parsed.success) return new Response(null, { status: 400 });
const value = parsed.value;
let control: MCPEventControl | undefined;
if (value != null && typeof value === 'object' && 'type' in value) {
if (value.type === 'verification') {
const verification = VerificationSchema.safeParse(value);
if (!verification.success) return new Response(null, { status: 400 });
return Response.json({ challenge: verification.data.challenge });
}
const parsedControl = MCPEventControlSchema.safeParse(value);
if (!parsedControl.success) return new Response(null, { status: 400 });
control = parsedControl.data;
}
// Servers can POST before a subscribe or refresh response is persisted.
// Events and gaps must retry until the ID and renewed expiration are saved.
// A verified termination for a known ID can end a pending renewal too:
// access may have been revoked, so renewal can no longer succeed.
if (
subscription.id == null ||
(subscription.status !== 'active' && control?.type !== 'terminated')
)
return new Response(null, { status: 503 });
const subscriptionInfo: MCPEventSubscriptionInfo = {
id: subscription.id,
name: subscription.name,
arguments: subscription.arguments,
delivery: { mode: 'webhook', url: subscription.delivery.url },
refreshBefore: subscription.refreshBefore ?? null,
};
if (control) {
const messageId = request.headers.get('webhook-id')!;
if (control.type === 'gap') {
await onGap?.({
subscription: subscriptionInfo,
messageId,
gap: control,
});
await store.update(subscription.key, {
cursor: control.cursor,
truncated: true,
});
} else {
await onTerminated?.({
subscription: subscriptionInfo,
messageId,
termination: control,
});
await store.delete(subscription.key);
}
return new Response(null, { status: 204 });
}
if (
subscription.refreshBefore != null &&
Date.parse(subscription.refreshBefore) <= Date.now()
) {
return new Response(null, { status: 410 });
}
const event = MCPEventSchema.safeParse(value);
if (
!event.success ||
event.data.name !== subscription.name ||
event.data.eventId !== request.headers.get('webhook-id')
) {
return new Response(null, { status: 400 });
}
try {
await validatePayload?.({
definition: subscription.definition,
data: event.data.data,
});
} catch (error) {
onError?.(error);
return new Response(null, { status: 400 });
}
await onEvent({
subscription: subscriptionInfo,
event: event.data,
});
if (event.data.cursor !== undefined) {
await store.update(subscription.key, { cursor: event.data.cursor });
}
return new Response(null, { status: 204 });
} catch (error) {
onError?.(error);
return new Response(null, { status: 503 });
}
};
}