Guide · Event-Driven & Async Patterns
MCP Server CloudEvents — CNCF standard, specversion, SDK, event routing
Three CloudEvents behaviours catch MCP server authors off-guard: specversion must be the string "1.0" — older "0.3" events still circulate and fail validation against 1.0 schemas — CloudEvents 1.0 is the current stable spec; version 0.3 used a different attribute set (schemaurl replaced by dataschema, contenttype replaced by datacontenttype) and 0.3 events arriving at a 1.0 consumer will either fail validation or silently drop required attributes; always validate the specversion field before deserialising a received event and surface an explicit error rather than propagating a partially-parsed event; the type attribute is a reverse-DNS string by convention but this is not enforced by the spec, and omitting the convention breaks routing in brokers that use type prefix matching — EventBridge, Knative Eventing, and most event routers match event type values by prefix or exact string; using free-form names like "OrderCreated" instead of "com.example.orders.created" breaks prefix-based wildcard rules downstream; and binary content mode sends event attributes in HTTP headers, not in the body — most HTTP clients silently strip unknown headers on redirect — in binary content mode (the preferred HTTP binding for performance), event attributes like ce-id, ce-source, ce-type are sent as HTTP headers and only the data field goes in the body; proxies, load balancers, and CDNs that strip non-standard headers will deliver the body without its envelope, causing the receiver to see raw data with no event context.
TL;DR
Use the cloudevents npm package for type-safe event construction and parsing. Construct events with new CloudEvent({ specversion: '1.0', type, source, id, data }) — id must be unique per source (use crypto.randomUUID()). Emit as structured JSON with new HTTPBinding().toEvent() or emitBinary(). Parse received events with new HTTPBinding().toEvent(req) — it handles both binary and structured content modes automatically. Always validate specversion === '1.0' before processing. Use reverse-DNS type strings (e.g., com.example.order.created) to enable prefix routing in downstream brokers.
Creating and sending CloudEvents
The CloudEvents spec defines a minimal set of required attributes: specversion, id, source, and type. The id must be unique per source — two events from the same source with the same id are considered duplicates and can be deduplicated by brokers. The source is a URI that identifies the originating context (e.g., https://api.example.com/orders or /microservices/order-service). The type describes the event occurrence and conventionally uses reverse-DNS notation with a past-tense verb segment (e.g., com.example.order.created, com.example.user.password.reset).
import { CloudEvent, HTTP } from 'cloudevents';
import { z } from 'zod';
import crypto from 'crypto';
// MCP tool — publish a CloudEvent to an HTTP endpoint
server.tool(
'publish_cloud_event',
{
endpoint: z.string().url(),
type: z.string().min(1), // e.g. 'com.example.order.created'
source: z.string().min(1), // e.g. 'https://api.example.com/orders'
data: z.record(z.unknown()),
subject: z.string().optional(), // e.g. the order ID — narrows the event
dataschema: z.string().url().optional(), // URI to JSON Schema for data
},
async ({ endpoint, type, source, data, subject, dataschema }) => {
const event = new CloudEvent({
specversion: '1.0',
id: crypto.randomUUID(),
type,
source,
subject,
dataschema,
datacontenttype: 'application/json',
data,
time: new Date(),
});
// Structured content mode — single JSON body containing both attributes and data
// Prefer structured mode when passing through HTTP proxies that strip headers
const message = HTTP.structured(event);
const resp = await fetch(endpoint, {
method: 'POST',
headers: message.headers as Record<string, string>,
body: JSON.stringify(message.body),
});
if (!resp.ok) {
throw new Error(`CloudEvent delivery failed: HTTP ${resp.status} ${resp.statusText}`);
}
return {
content: [{
type: 'text',
text: JSON.stringify({
delivered: true,
event_id: event.id,
endpoint,
type: event.type,
source: event.source,
}),
}],
};
}
);
// MCP tool — publish via binary content mode (attributes in headers, data in body)
server.tool(
'publish_cloud_event_binary',
{
endpoint: z.string().url(),
type: z.string().min(1),
source: z.string().min(1),
data: z.record(z.unknown()),
},
async ({ endpoint, type, source, data }) => {
const event = new CloudEvent({
specversion: '1.0',
id: crypto.randomUUID(),
type,
source,
datacontenttype: 'application/json',
data,
});
// Binary content mode — ce-* HTTP headers + JSON body
const message = HTTP.binary(event);
const resp = await fetch(endpoint, {
method: 'POST',
headers: message.headers as Record<string, string>,
body: JSON.stringify(message.body),
});
return {
content: [{
type: 'text',
text: JSON.stringify({ delivered: resp.ok, status: resp.status, event_id: event.id }),
}],
};
}
);
Receiving and validating CloudEvents
The cloudevents SDK's HTTP.toEvent() function parses both binary and structured content modes from an incoming HTTP request. It handles the content-mode detection automatically by inspecting the Content-Type header: structured mode uses application/cloudevents+json; binary mode uses the original datacontenttype with event attributes in ce-* headers. After parsing, always validate the specversion field and check that required attributes are present before processing.
import express from 'express';
import { HTTP, CloudEvent } from 'cloudevents';
const app = express();
app.use(express.raw({ type: '*/*' })); // preserve raw body for cloudevents parsing
// CloudEvent receiver endpoint — called by brokers (Knative, EventBridge Pipes, etc.)
app.post('/events', async (req, res) => {
let event: CloudEvent<unknown>;
try {
// HTTP.toEvent handles both binary and structured content modes
event = HTTP.toEvent({
headers: req.headers,
body: req.body,
});
} catch (err: any) {
res.status(400).json({ error: 'invalid_cloud_event', message: err.message });
return;
}
// Validate specversion
if (event.specversion !== '1.0') {
res.status(422).json({
error: 'unsupported_specversion',
message: `Expected specversion 1.0, got ${event.specversion}`,
});
return;
}
// Validate required attributes
if (!event.type || !event.source || !event.id) {
res.status(422).json({ error: 'missing_required_attributes' });
return;
}
// Route by type prefix
const routed = await routeEvent(event);
if (!routed) {
// Return 200 even for unknown types — the broker should not retry unknown types
res.status(200).json({ status: 'ignored', type: event.type });
return;
}
res.status(200).json({ status: 'processed', event_id: event.id });
});
async function routeEvent(event: CloudEvent<unknown>): Promise<boolean> {
const type = event.type;
if (type.startsWith('com.example.order.')) {
await handleOrderEvent(event);
return true;
}
if (type.startsWith('com.example.user.')) {
await handleUserEvent(event);
return true;
}
return false;
}
// MCP tool — parse and inspect a raw CloudEvent payload
server.tool(
'parse_cloud_event',
{ raw_event: z.string().min(1) },
async ({ raw_event }) => {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw_event);
} catch {
return { content: [{ type: 'text', text: JSON.stringify({ error: 'invalid_json' }) }] };
}
const issues: string[] = [];
if (!parsed['specversion']) issues.push('missing specversion');
if (parsed['specversion'] !== '1.0') issues.push(`specversion '${parsed['specversion']}' is not 1.0`);
if (!parsed['id']) issues.push('missing id');
if (!parsed['source']) issues.push('missing source');
if (!parsed['type']) issues.push('missing type');
const isReversDns = typeof parsed['type'] === 'string' &&
/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+/.test(parsed['type'] as string);
return {
content: [{
type: 'text',
text: JSON.stringify({
valid: issues.length === 0,
issues,
specversion: parsed['specversion'],
id: parsed['id'],
source: parsed['source'],
type: parsed['type'],
type_follows_reverse_dns_convention: isReversDns,
time: parsed['time'],
datacontenttype: parsed['datacontenttype'],
subject: parsed['subject'],
dataschema: parsed['dataschema'],
has_data: 'data' in parsed,
}, null, 2),
}],
};
}
);
Event batching and CloudEvent schemas
CloudEvents supports batch delivery via the application/cloudevents-batch+json content type — a JSON array of CloudEvent objects. Batch mode reduces HTTP overhead when publishing many events to the same endpoint. Some brokers (Knative, Azure Event Grid) deliver batches to subscribers; your MCP server's receiver endpoint must handle both single-event and batch payloads by inspecting the Content-Type header before parsing.
// Publish a batch of CloudEvents
server.tool(
'publish_event_batch',
{
endpoint: z.string().url(),
events: z.array(z.object({
type: z.string().min(1),
source: z.string().min(1),
data: z.record(z.unknown()),
subject: z.string().optional(),
})).min(1).max(100),
},
async ({ endpoint, events }) => {
const cloudEvents = events.map(e =>
new CloudEvent({
specversion: '1.0',
id: crypto.randomUUID(),
type: e.type,
source: e.source,
subject: e.subject,
datacontenttype: 'application/json',
data: e.data,
time: new Date(),
})
);
// Batch message — Content-Type: application/cloudevents-batch+json
const message = HTTP.batch(cloudEvents);
const resp = await fetch(endpoint, {
method: 'POST',
headers: message.headers as Record<string, string>,
body: JSON.stringify(message.body),
});
return {
content: [{
type: 'text',
text: JSON.stringify({
delivered: resp.ok,
status: resp.status,
event_count: cloudEvents.length,
ids: cloudEvents.map(e => e.id),
}),
}],
};
}
);
// Receiver that handles both single and batch CloudEvents
app.post('/events/batch', async (req, res) => {
const contentType = req.headers['content-type'] ?? '';
let events: CloudEvent<unknown>[];
if (contentType.includes('application/cloudevents-batch+json')) {
// Batch mode
const body = JSON.parse(req.body.toString());
events = body.map((raw: unknown) => HTTP.toEvent({ headers: {}, body: raw }));
} else {
// Single event
events = [HTTP.toEvent({ headers: req.headers, body: req.body })];
}
const results = await Promise.allSettled(events.map(e => routeEvent(e)));
const failed = results.filter(r => r.status === 'rejected').length;
res.status(200).json({ processed: events.length, failed });
});