Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hookgres": minor
---

Add `hookgres/mcp-events`: webhook delivery for MCP Events (SEP-3415, draft). It handles `events/list`, `events/subscribe` and `events/unsubscribe`, verifies callback endpoints with a signed challenge, and `emit()` queues an event for every matching subscription inside your transaction, with bounded retries, `X-MCP-Subscription-Id` and Standard Webhooks signatures.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export default defineConfig({
},
{ text: "Privacy and GDPR", link: "/guides/privacy" },
{ text: "Migrating from Svix", link: "/guides/migrating-from-svix" },
{ text: "MCP Events webhooks", link: "/guides/mcp-events" },
],
},
{
Expand Down
112 changes: 112 additions & 0 deletions docs/guides/mcp-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# MCP Events webhooks

MCP Events ([SEP-3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415),
extension `io.modelcontextprotocol/events`) lets an MCP server push events to
agents. In webhook mode the client registers a callback URL and a `whsec_`
secret, and the server POSTs each event to it, signed with Standard Webhooks.

`hookgres/mcp-events` is that server side on your Postgres. It answers
`events/list`, `events/subscribe` and `events/unsubscribe`, and `emit()` queues
an event for every matching subscription, inside your transaction if you pass
one.

::: warning Draft specification
SEP-3415 is still under review, and its error codes are provisional. This
module follows the draft as of November 2026 and may change with it.
:::

## Set it up

```ts
import pg from "pg";
import { McpEvents, McpEventsError } from "hookgres/mcp-events";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const events = new McpEvents({
pool,
events: [
{
name: "incident.created",
description: "A new incident",
inputSchema: {
type: "object",
properties: { severity: { type: "string" } },
},
// Which subscriptions get an event, from their arguments.
matches: (args, data) =>
!args.severity || args.severity === data.severity,
},
],
});

await events.migrate(); // its own schema, "hookgres_mcp_events"
events.start(); // delivery worker in this process
```

Add `events.capabilities` to the capabilities your server returns, and route
`events/*` requests to `handle()` with the authenticated principal:

```ts
try {
return await events.handle(request.method, request.params, {
principal: auth.subject, // OAuth sub, API key id, ...
});
} catch (error) {
if (error instanceof McpEventsError) return { error: error.toJSON() };
throw error;
}
```

## Emit events

```ts
const client = await pool.connect();
try {
await client.query("BEGIN");
const incident = await createIncident(client, input);
await events.emit(
"incident.created",
{ incidentId: incident.id, severity: incident.severity },
{ client, eventId: incident.id },
);
await client.query("COMMIT");
} finally {
client.release();
}
```

Roll back and nothing is sent. Commit, and the event reaches every live
subscription even if the process restarts before delivery.

## What it does for you

| SEP-3415 requirement | How |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Standard Webhooks signature with the client's secret | Each subscription is a Hookgres endpoint with that secret |
| `X-MCP-Subscription-Id` on every POST | Set per attempt; the id is derived from `(principal, url, name, arguments)` |
| `webhook-id` unique per message, kept across retries, not derived from `eventId` | Hookgres message ids, one message per subscription |
| Bounded retries (3-5 attempts within 10-15 minutes) | 5 attempts over about 8 minutes by default |
| `410` and `413` fail that delivery only | `nonRetryableStatuses: [410, 413]` |
| `https` only, no private addresses, checked at delivery time, no redirects | `blockPrivateAddresses` and Hookgres's pinned connections |
| Endpoint verification before the first delivery | A signed `verification` challenge, cached per `(principal, url)`, or your own allowlist with `verify` |
| TTL grant, refresh and secret rotation | `ttl` bounds; a new secret on refresh dual-signs for 5 minutes |
| `deliveryStatus` on refresh | `active`, `lastDeliveryAt`, `lastError` category, `failedSince` |

## Not covered yet

- Poll and push delivery: they answer `Unsupported`.
- Cursor replay: events are emit-only (`cursor: null`). Hookgres keeps queued
events across restarts, which is more than the SEP asks of emit-only types.
- `gap` and `terminated` control envelopes.
- `arguments` aren't validated against `inputSchema`; they're only capped at
4 KB. Validate them before calling `handle()` if your `matches` relies on
their shape.
- No-expiry subscriptions are only granted with `allowNoExpiry: true`.

Verification POSTs are limited to 10 per destination host per minute
(`verificationsPerHostPerMinute`); over that, subscribe answers
`ResourceExhausted`. Concurrent subscribe and unsubscribe calls for the same
subscription are serialized with a Postgres advisory lock.

Call `events.deleteExpired()` on a schedule to remove expired subscriptions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"types": "./dist/kysely.d.ts",
"default": "./dist/kysely.js"
},
"./mcp-events": {
"types": "./dist/mcp-events.d.ts",
"default": "./dist/mcp-events.js"
},
"./prisma": {
"types": "./dist/prisma.d.ts",
"default": "./dist/prisma.js"
Expand Down
Loading
Loading