MCP Events
Let ChatGPT and other MCP clients subscribe to events your systems already emit. The gateway signs and delivers each one to the client's webhook, with replay, retries, and per-subscriber policy.
MCP Events (the experimental
io.modelcontextprotocol/events extension on the 2026-07-28 protocol) lets a
client subscribe to things that happen on the server side — an order placed, an
incident opened, a document changed — instead of polling tools. MCPG serves the
extension for every event type you declare: it lists them, takes webhook
subscriptions, verifies each callback, and delivers every occurrence as one
signed Standard Webhooks POST.
Experimental. The upstream extension is a working-group sketch with no SEP number yet, and MCPG follows it as it changes. Events are off by default and served only on the
2026-07-28HTTP wire;2025-11-25and stdio clients never see them. This release delivers by webhook, which is what ChatGPT uses, and by poll and push when you turn them on.
Enable it
Events need three things: the switch, a store that survives a restart, and a key every replica shares (subscription ids, sealed secrets and cursors derive from it).
cluster:
state_encryption_key_env: MCPG_STATE_KEY # names an env var holding base64 of 32 bytes, the same on every replica
mcp:
configurations:
events:
enabled: true
store: { kind: file, dir: /var/lib/mcpg/events/subscriptions }
log_store: { kind: file, dir: /var/lib/mcpg/events/log }
capabilities:
events:
- name: orders.created
description: An order was placed.
source:
inbound:
source_id: shop
verify:
profile: standard_webhooks
secrets: ["${env.SHOP_WEBHOOK_SECRET}"]
- Store. Webhook subscriptions must outlive a restart, so the gateway
refuses to serve them on a memory store. A single node uses
kind: file; a cluster uses its coordinator (NATS or Redis) and needs nostore:at all.allow_ephemeral_subscriptions: trueaccepts a memory store for tests, and caps every grant at the TTL floor. - Log store. The event log keeps each type's recent occurrences for replay.
Give it a
filestore on a single node too, or a restart starts a new log: nothing delivered repeats, but events that arrived while the gateway was down are gone and the client is told so with agap. - Key.
cluster.state_encryption_key_envnames the environment variable that holds it, on one node or many; it also encrypts the event stores. Without it,mcp.configurations.sessions.synthetic_session_key(a literal base64 value) is used. Rotating the key voids every subscription; clients re-create theirs on their next refresh.
When events are on, server/discover advertises the capability both as
capabilities.events (what ChatGPT reads) and under
capabilities.extensions["io.modelcontextprotocol/events"] (what the draft
conformance suite reads). advertise: picks one if a client needs that.
Declare event types
Each entry under mcp.capabilities.events is one type: a name, an optional
description, the input_schema of the arguments a subscriber may pass, the
payload_schema of each occurrence's data, and exactly one source.
mcp:
capabilities:
events:
- name: issues.opened
description: A GitHub issue was opened.
input_schema:
type: object
properties:
repo: { type: string }
additionalProperties: false
match: '!has(arguments.repo) || arguments.repo == event.data.repository.full_name'
source:
inbound:
source_id: github
verify:
profile: github
secrets: ["${env.GITHUB_WEBHOOK_SECRET}"]
when:
header: { X-GitHub-Event: issues }
body: { /action: opened }
retention: { max_age_ms: 86400000, max_events: 10000 }
Sources
| Source | What it is | Occurrence data |
|---|---|---|
inbound | A signed webhook third parties POST to /webhooks/events/{source_id} on any replica. Verified by a signing profile; when: keeps only matching requests; the sender's event id makes a resend count once. | The request body. |
resource | The uri of a resources[] binding that has a watch: block. Every change of that resource is one occurrence. | {uri, observedAt} |
watch | A plugin watch strategy (nats_topic, kafka_topic, sql_polling, postgres_listen_notify, plugin). Every change it reports is one occurrence. | {observedAt}, or the plugin's payload with event_type |
builtin | An event the gateway raises itself: mcp.task.updated, approval.requested, approval.resolved. | See Built-in sources. |
| federated | Not declared here: a type a federation imported from an upstream MCP server with import.events: true. One upstream subscription runs per set of arguments in demand. See Importing MCP Events. | The upstream occurrence's data. |
An inbound route answers 202 once the event is stored, 401 for a bad
or stale signature, 413 for a body over 256 KiB, 429 with Retry-After
past its rate_limit or when the plan refuses a signed request (see
Plan limits), and 503 when it cannot store the event now.
allow_unsigned: true accepts unsigned requests, for tests only: anyone who
reaches the route can then inject events.
Inbound signing profiles
verify: names a signing profile and gives the route's secrets. The gateway
holds no code for any one sender: a profile is data that says how a sender
signs, and four ship with the gateway.
| Profile | Signature | Event id | Event time | Handshake |
|---|---|---|---|---|
standard_webhooks | webhook-signature over webhook-id, webhook-timestamp and the body; whsec_ secrets with a 24 to 64 byte key | webhook-id | Arrival | None |
github | X-Hub-Signature-256 over the body | X-GitHub-Delivery | Arrival | A ping delivery is answered 202 |
stripe | Stripe-Signature (t=…,v1=…) over the timestamp and the body | /id | /created | None |
slack | X-Slack-Signature over X-Slack-Request-Timestamp and the body | /event_id | /event_time | url_verification is answered 200 with its challenge |
A route's own event_id and timestamp pointers take the place of the
profile's. tolerance_secs sets the largest age or skew of a signed
timestamp; the default is 300. Several secrets are accepted at once, so a
secret can be rolled without a gap. A handshake is answered even when the
plan holds ingest, and it never becomes an event.
Several types can share one route. Each type takes the requests its when
keeps, so one GitHub webhook feeds a type per event kind. Types that share a
route declare the same verify, allow_unsigned and rate_limit; the
signature is checked once for the route.
mcp:
capabilities:
events:
- name: github.push
source:
inbound:
source_id: github
verify: { profile: github, secrets: ["${secret.GITHUB_HOOK}"] }
when: { header: { X-GitHub-Event: push } }
- name: github.pull_request
source:
inbound:
source_id: github
verify: { profile: github, secrets: ["${secret.GITHUB_HOOK}"] }
when: { header: { X-GitHub-Event: pull_request } }
- name: stripe.invoice.paid
source:
inbound:
source_id: stripe
verify: { profile: stripe, secrets: ["${secret.STRIPE_ENDPOINT}"] }
when: { body: { /type: invoice.paid } }
- name: slack.app_mention
source:
inbound:
source_id: slack
verify: { profile: slack, secrets: ["${secret.SLACK_SIGNING}"] }
when: { body: { /event/type: app_mention } }
A sender with no built-in profile gets one under
mcp.configurations.events.inbound_profiles. A profile describes an HMAC:
| Field | Meaning |
|---|---|
name | What routes give in verify.profile: [a-z0-9_-], not a built-in name. |
algorithm | sha1, sha256 (default) or sha512. |
secret | prefix every secret starts with, removed first; encoding of the rest: utf8 (default), hex or base64; min_bytes and max_bytes, the shortest and longest key once decoded. A secret outside them fails the boot, and the error never shows it. |
signature | header with the signatures; separator when it holds several items; key when items are key=value pairs and the signatures are this key's values; prefix before each digest; encoding hex (default) or base64. |
signed_payload | The signed message: {body} once, {timestamp}, {header:NAME} and literal text. Default {body}. |
signed_timestamp | Unix seconds in a header, or a key item of the signature header, with tolerance_secs (default 300). |
event_id | The sender's id: a header or a body pointer. |
event_time | A body pointer to the event time, RFC 3339 or Unix seconds. |
handshakes | Requests the route answers itself: when (headers and body fields to match) and an optional reply of response keys to body pointers. With no reply the answer is 202; with one it is 200 and a JSON object of those strings. |
mcp:
configurations:
events:
inbound_profiles:
- name: acme
algorithm: sha512
signature: { header: X-Acme-Signature, separator: ";", key: sig, encoding: base64 }
signed_payload: "{header:X-Acme-Id}.{timestamp}.{body}"
signed_timestamp: { key: ts, tolerance_secs: 120 }
event_id: { header: X-Acme-Id }
handshakes:
- when: { body: { /kind: hello } }
reply: { echo: /nonce }
capabilities:
events:
- name: acme.order
source:
inbound:
source_id: acme
verify: { profile: acme, secrets: ["${secret.ACME_HOOK}"] }
Every digest is compared in constant time, against every secret, with no early exit. A request with the signature header twice, or with a timestamp that is not plain digits, is refused.
Sources run on demand: a type with no live subscription runs no source, and the first subscription starts it. A source that cannot resume records a gap when it starts, so a subscriber that was away is told events may be missing.
Payload watch sources
Without event_type, a watch source reports only that something changed.
With it, the type carries the plugin's own payload: the message, the record,
the tag that moved. The plugin's payload_schema is the type's default, and
the type's own payload_schema replaces it.
mcp:
capabilities:
events:
- name: orders.recorded
description: A record on the orders topic.
source:
watch:
strategy: { type: kafka_topic, topic: orders }
event_type: kafka.message
| Plugin | Watch kind | event_type | Resumes | Runs on |
|---|---|---|---|---|
| Kafka | kafka_topic | kafka.message: {topic, partition, offset, key, value, valueEncoding, headers} | Yes, at the next offset of each partition | The owner |
| NATS | nats_topic | nats.message: {subject, payload, payloadEncoding, headers} | No: core NATS keeps no history | The owner |
| SQL | postgres_listen_notify | postgres.notification: {channel, payload, payloadEncoding} | No | The owner |
| SQL | sql_changes | sql.row.changed: {op, row}, op is insert, update or delete | Yes, after the last key it stored | The owner |
| SQL | postgres_cdc | sql.row.changed: {op, schema, table, row, old, lsn, xid, commitTime}; sql.table.truncated | Not needed: the replication slot keeps what is not acknowledged | The owner |
| Twilio | twilio_inbound | twilio.sms.received, twilio.call.incoming, twilio.message.status, twilio.call.status | No | Every replica |
| Cron | cron | schedule.fired: {scheduledAt, firedAt, catchUp} | Yes, raising up to 100 missed fires | The owner |
| File | file | file.created, file.modified, file.removed: {path, paths} | No | The owner |
| File | file_tail | file.line: {path, offset, line, lineEncoding, truncated?}; file.truncated: {path} | Yes, each file at its offset | The owner |
| File | file_objects | object.changed: see Files and objects | Yes, exactly while the listing fits snapshot_bytes | The owner |
| SFTP, FTP, SMB | sftp_dir_poll, ftp_dir_poll, smb_dir_poll | object.changed: see Files and objects | Yes, exactly while the listing fits snapshot_bytes | The owner |
| S3 | s3_objects | object.changed: see Files and objects | Listing: yes, exactly while it fits snapshot_bytes. Notifications: not needed, SQS keeps them | The owner |
| Cloud Storage | gcs_objects | object.changed: see Files and objects | Listing: yes, exactly while it fits snapshot_bytes. Notifications: not needed, Pub/Sub keeps them | The owner |
| HTTP | http_poll | Snapshot: object.changed of a feed's or a JSON collection's items. Cursor: http.item: {url, id, item} | Snapshot: yes, exactly while it fits snapshot_bytes. Cursor: yes, at the stored cursor | The owner |
| Email (IMAP) | imap_mailbox | object.changed with the envelope, flags and a text preview: see Files and objects | Yes, exactly while the listing fits snapshot_bytes | The owner |
| AMQP | amqp_queue | amqp.message: {queue, exchange, routingKey, redelivered, messageId, headers, body, bodyEncoding, …}; see Message queues | Not needed: the broker keeps what is not acknowledged | The owner |
| Amazon SQS | sqs_queue | sqs.message: {queueUrl, messageId, body, bodyEncoding, sentAt, receiveCount, messageAttributes, …} | Not needed: SQS keeps what is not deleted | The owner |
| Google Pub/Sub | pubsub_subscription | pubsub.message: {subscription, messageId, publishTime, attributes, body, bodyEncoding, …} | Not needed: Pub/Sub keeps what is not acknowledged | The owner |
| Azure Service Bus | servicebus_entity | servicebus.message: {namespace, entity, sequenceNumber, messageId, body, bodyEncoding, enqueuedAt, deliveryCount, applicationProperties, …} | Not needed: Service Bus keeps what is not completed | The owner |
| MQTT | mqtt_topics | mqtt.message: {topic, qos, retain, dup, payload, payloadEncoding} | Not needed: the broker's persistent session keeps what is not acknowledged | The owner |
| Redis | redis_stream | redis.stream.entry: {stream, id, fields, binaryFields?} | Not needed: the consumer group's pending list keeps what is not acknowledged | The owner |
| OCI registry | oci_registry_poll | oci.tag.pushed, oci.tag.moved, oci.tag.deleted: {repository, tag, digest, previousDigest} | No | The owner |
- Values. A message body is its JSON when it is JSON, its text when it is
UTF-8, and standard base64 of its bytes otherwise; the field beside it
(
valueEncodingorpayloadEncoding) says which:json,textorbase64. - Resume. A resumable source starts where the last owner stopped, so an
owner failover or a reload leaves no gap. The position is stored after each
append; an event read twice across a failover has the same id and is taken
once. A Kafka source reads with its own consumer group,
mcpg-events-{name}, unlessgroup_idnames one, and reads partitions added later after its next restart. - Acknowledgements. The gateway tells the plugin when it is done with
each event: stored, a repeat, or refused for good. A tail or a queue keeps
unacknowledged items and offers them again, so an event dropped on a full
queue is not lost. A file tail reads again from a line the gateway passed
over, follows renames and rotation by file identity, and emits
file.truncatedbefore it reads a truncated file again; its files are those of the owner's node. - Upstream gaps. A plugin that knows events are missing upstream (a
resumed position the upstream no longer holds, a listing too large to
resume exactly) says so, and the log records a gap there: readers past it
get
truncated: true. - Every replica. A Twilio webhook lands on whichever replica Twilio
reaches, so that source runs on every replica and hands what it sees to the
owner, with or without
event_type. Twilio's retry of the same SID is one occurrence. Asource.resourcecannot use such a strategy: it runs on the owner alone. - Plugin build. Only a plugin whose descriptor declares the strategy's event types can feed a payload type. One that declares none is refused at boot with "declares no event types … rebuild the plugin". Its watch still serves resource watches and payload-free types.
- Size. An item over 256 KiB, or one that does not decode, is dropped and
counted in
mcpg_plugin_watch_events_dropped_total, never delivered empty.
Database row changes
sql_changes reads changes from PostgreSQL, MySQL, MariaDB or SQLite with a
keyset query you write: the rows after the last key, in key order, at most
:_limit of them. The gateway binds the last key to :_after_<column> for
each key column, emits each row as one sql.row.changed event, and resumes
after the last row it stored. Its spec is a SQL binding's (driver, url,
pool, session_vars, query with row_mode: many) plus:
| Field | Meaning |
|---|---|
key_columns | The ordering key, in ORDER BY order: together unique, and only growing. |
initial | The key before the first row, one value per key column. |
start | latest (default) skips the rows that exist; beginning reads them all after initial. |
latest_sql | For start: latest: one row with the newest key. No row, or a null, means initial. |
op_column | The column that says what the change did: insert, update or delete, any letter case. Omitted, every row is an insert. |
occurred_at_column | The column with the change time: a timestamp, RFC 3339 text, or Unix seconds. |
batch_size | Rows per query, bound to :_limit: 1 to 500, default 100. One poll emits at most 500 rows. |
interval_ms | Milliseconds between polls: at least 100, default 1000. |
settle_ms | Read only rows older than this, bound to :_settle_before as Unix seconds, so a row that commits late with a lower key is not passed over. |
The query binds only those placeholders. Key values bind with their own type:
an integer as an integer, text as text. On PostgreSQL a timestamp or decimal
key needs a cast, as below. Credentials take ${env.…} or ${secret.…}.
Which changes you get is the key's choice:
-
New rows. An insert-only key, such as an identity column:
WHERE id > :_after_id ORDER BY id LIMIT :_limit. -
Inserts and updates. A change marker that every write moves, with the id as a tie-break, and
op_columnfrom the row:yamlstrategy: type: plugin kind: sql_changes driver: postgres url: "postgres://events:${secret.DB_PASSWORD}@db/shop" query: row_mode: many sql: | SELECT id, status, total, updated_at, CASE WHEN deleted_at IS NOT NULL THEN 'delete' WHEN created_at = updated_at THEN 'insert' ELSE 'update' END AS op FROM orders WHERE (updated_at, id) > (CAST(:_after_updated_at AS timestamptz), :_after_id) AND updated_at < to_timestamp(:_settle_before) ORDER BY updated_at, id LIMIT :_limit key_columns: [updated_at, id] initial: { updated_at: "1970-01-01T00:00:00Z", id: 0 } latest_sql: "SELECT updated_at, id FROM orders ORDER BY updated_at DESC, id DESC LIMIT 1" op_column: op occurred_at_column: updated_at settle_ms: 2000 event_type: sql.row.changedAn update reads the row as it is now; two updates between polls arrive as one. A soft delete (
deleted_at) arrives asdelete. -
Every change, hard deletes too. A trigger writes each change to an outbox table with an increasing sequence, and the watch reads the outbox. This works on every engine and keeps each change, also a row that is gone:
sql-- PostgreSQL CREATE TABLE orders_changes ( seq BIGSERIAL PRIMARY KEY, op TEXT NOT NULL, id BIGINT NOT NULL, row_data JSONB, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()); CREATE FUNCTION orders_changed() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN INSERT INTO orders_changes (op, id, row_data) VALUES (TG_OP, OLD.id, to_jsonb(OLD)); ELSE INSERT INTO orders_changes (op, id, row_data) VALUES (TG_OP, NEW.id, to_jsonb(NEW)); END IF; RETURN NULL; END $$; CREATE TRIGGER orders_changed AFTER INSERT OR UPDATE OR DELETE ON orders FOR EACH ROW EXECUTE FUNCTION orders_changed();sql-- MySQL and MariaDB: one trigger per operation CREATE TABLE orders_changes ( seq BIGINT AUTO_INCREMENT PRIMARY KEY, op VARCHAR(8) NOT NULL, id BIGINT NOT NULL, total INT, changed_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)); CREATE TRIGGER orders_ins AFTER INSERT ON orders FOR EACH ROW INSERT INTO orders_changes (op, id, total) VALUES ('insert', NEW.id, NEW.total); CREATE TRIGGER orders_upd AFTER UPDATE ON orders FOR EACH ROW INSERT INTO orders_changes (op, id, total) VALUES ('update', NEW.id, NEW.total); CREATE TRIGGER orders_del AFTER DELETE ON orders FOR EACH ROW INSERT INTO orders_changes (op, id, total) VALUES ('delete', OLD.id, OLD.total);SQLite takes the same three triggers with
BEGIN … END. The watch:yamlquery: row_mode: many sql: | SELECT seq, op, id, row_data, changed_at FROM orders_changes WHERE seq > :_after_seq ORDER BY seq LIMIT :_limit key_columns: [seq] initial: { seq: 0 } latest_sql: "SELECT MAX(seq) AS seq FROM orders_changes" op_column: op occurred_at_column: changed_atTrim the outbox on a schedule once its rows are older than the event retention.
On MySQL, write a composite key as ts > :_after_ts OR (ts = :_after_ts AND id > :_after_id): the placeholder may appear twice, and that form uses the
index. A row without a key value stops the poll with a warning until the
query selects it; a row over 256 KiB is skipped with a warning. The watch
connects in the background and retries with backoff, so a database that is
down when the source starts is retried, not a failed source.
On PostgreSQL, postgres_cdc reads every change a publication carries from
a logical replication slot, with no outbox, no triggers and no extension: it
decodes the built-in pgoutput plugin through the SQL slot functions, so it
works on managed PostgreSQL too. The server needs wal_level = logical, and
the role needs replication (REPLICATION, or rds_replication,
cloudsqlsuperuser, …):
source:
watch:
strategy:
type: plugin
kind: postgres_cdc
url: postgres://mcpg_cdc:${env.PG_CDC_PASSWORD}@db.internal/shop?sslmode=require
publication: mcpg_orders
tables: [public.orders]
event_type: sql.row.changed
Each insert, update or delete is sql.row.changed with op, schema,
table, row (for a delete, the old key, or the old row under REPLICA IDENTITY FULL), old when the replica identity sends it, unchanged for
TOASTed columns not sent, and the change's lsn, its transaction's xid
and commitTime. A TRUNCATE is sql.table.truncated. The slot advances
only once the gateway has stored every change of a transaction and of every
transaction before it; a change it did not take is read again, and the
slot/lsn event id deduplicates it. The watch creates the slot (named from
the source unless slot names it) and, with tables, the publication. A
slot keeps WAL on the server until it advances, so drop the slot of a source
you remove; a slot that is gone when the source restarts is created again,
and the log records a gap.
Files and objects
A watch over a set of files or objects reports each one that was created,
modified or deleted as one object.changed event. The shape is the same
whatever the store, so one subscriber reads a local folder and a bucket
alike:
| Field | Meaning |
|---|---|
op | created, modified or deleted. |
store | The kind of store: file, sftp, ftp, smb, s3, gcs, imap, … |
location | What the keys are relative to: a folder, a bucket. |
key | The object's name in its store. |
size | Bytes, when the store says. |
modifiedAt | When it last changed, RFC 3339. Also the event time of a creation or a modification. |
etag, version | What the store reports to tell versions apart; for a local file, version is its device and inode, so a file replaced under its name is a modification. |
file_objects watches the local files that globs match:
mcp:
capabilities:
events:
- name: inbox.files
description: A CSV landed in, changed in, or left the inbox.
source:
watch:
strategy:
type: plugin
kind: file_objects
paths: ["/data/inbox/**/*.csv"]
interval_ms: 5000
event_type: object.changed
match: event.data.op == 'created'
The listing runs every interval_ms (default 5000, at least 250), and a
change under a watched directory lists at once. start_at: now (default)
takes the files there at a first start as the baseline; existing reports
each one as created. max_files (default 10 000) bounds the listing.
The SFTP, FTP and SMB plugins watch a remote directory the same way, with
sftp_dir_poll, ftp_dir_poll and smb_dir_poll. Each lists the directory
every interval_ms (default 60 000) over one connection, with a
timeout_ms budget (default 10 000). recursive: true goes into the
subdirectories, at most 1 000 of them, and pattern keeps the files whose
path relative to the directory matches the glob. The connection fields are
the plugin's own; each plugin's page lists them:
mcp:
capabilities:
events:
- name: partner.drops
description: A file arrived in, changed in, or left the partner's drop folder.
source:
watch:
strategy:
type: plugin
kind: sftp_dir_poll
host: sftp.partner.example.com
username: svc-mcpg
password: ${env.PARTNER_SFTP_PASSWORD}
host_key_sha256: "SHA256:…"
root: /outbound
recursive: true
pattern: "**/*.csv"
event_type: object.changed
A directory that cannot be listed, a subdirectory included, fails the whole listing, and the next interval tries again: a listing with a part missing would report that part as deleted. SFTP and SMB report a file's time to the second and FTP to the minute at best, so an overwrite that keeps the size inside that window is not seen.
s3_objects, in the dev.mcpg.storage.s3 plugin, watches the objects under
a bucket prefix, on AWS or any S3-compatible store (endpoint_url,
force_path_style):
source:
watch:
strategy:
type: plugin
kind: s3_objects
bucket: partner-drops
prefix: incoming
region: eu-west-1
notifications:
queue_url: https://sqs.eu-west-1.amazonaws.com/123456789012/partner-drops-events
event_type: object.changed
By default it lists the prefix every interval_ms (default 60 000), and an
overwrite is a modification because its ETag moves. notifications names
the SQS queue the bucket sends its ObjectCreated and ObjectRemoved
events to: each message makes the watch list at once. mode: notifications
reads only that queue, for a bucket too large to list; each record is then
an event, created or deleted (S3 does not tell an overwrite from a
creation), and its message is deleted once the gateway has stored it. The
queue may carry S3's own notifications, the same through SNS, or EventBridge
events.
gcs_objects, in the dev.mcpg.storage.gcs plugin, does the same for a
Google Cloud Storage bucket, with a Pub/Sub subscription to the bucket's
notification topic in notifications.subscription. An overwrite is a
modification in both modes: a listing sees a new generation, and a
notification says which generation it replaced.
http_poll, in the HTTP plugin, reads a feed or a JSON collection the same
way. format: feed takes an RSS, Atom or JSON Feed, and an entry's event
carries its title, link, summary and dates; an entry that scrolls out
of the feed is no deletion. format: json takes the array at
items_pointer, each item keyed by id_pointer, and its event carries the
item. The watch sends the last ETag and Last-Modified back, so an
unchanged endpoint answers 304. mode: cursor instead follows an API's
change cursor, each item an http.item event, the cursor advanced only once
the gateway has the page:
source:
watch:
strategy:
type: plugin
kind: http_poll
url: https://status.example.com/history.atom
format: feed
event_type: object.changed
imap_mailbox, in the email plugin, treats a mailbox the same way: each
message that arrives is created, a flag change (\Seen, \Flagged, a
keyword) is modified, an expunged message is deleted. The key is
{uidvalidity}:{uid}. The watch lists on a read-only session, IDLE makes it
list at once, and only what changed is fetched in full, so the event of a
message carries from, to, subject, date, messageId, flags and a
preview of its text:
source:
watch:
strategy:
type: plugin
kind: imap_mailbox
host: imap.example.com
username: support@example.com
password: ${env.SUPPORT_IMAP_PASSWORD}
event_type: object.changed
match: event.data.op == 'created'
Changes are offered at most max_in_flight (default 256) ahead of the
gateway, and a change it does not acknowledge in ack_timeout_ms (default
60 000) is offered again under the same event id. The last change of each
listing carries a snapshot of what the gateway has stored, so a restart
reports exactly what changed while the source was stopped, deletions
included. When the listing outgrows snapshot_bytes (default 131 072, at
most 196 608; about 2 000 objects), the source warns once, and a restart
records an upstream gap and takes the listing it finds as its new baseline.
Watch a narrower folder or prefix to stay inside the budget.
Message queues
A queue source takes each message off a broker queue as one event, and
settles it on the broker only once the gateway has stored the event. A
message the gateway has not acknowledged in ack_timeout_ms (default
60 000) goes back to the broker and comes again, so delivery is
at-least-once; the broker's message id is the event id, so a message that
comes twice is stored once. At most max_in_flight (default 256, at most
1000) messages are out at a time. A body over max_body_bytes (default
131 072, at most 196 608) is left out of the event, with bodyTruncated: true and its bodySize, rather than lost. When the source stops, the
messages it holds go back to the broker at once.
amqp_queue consumes a RabbitMQ or other AMQP 0.9.1 queue:
mcp:
capabilities:
events:
- name: orders.placed
description: An order message from the orders exchange.
source:
watch:
strategy:
type: plugin
kind: amqp_queue
uri: amqps://mcpg:${env.RABBITMQ_PASSWORD}@rabbit.internal:5671/%2f
queue: mcpg-orders
declare: true
bindings:
- exchange: orders
routing_key: "order.*"
event_type: amqp.message
The queue must exist unless declare: true declares it (durable unless
durable: false); each of bindings binds it to an exchange first.
max_in_flight is the consumer's prefetch. The event id is the message's
message_id property; a publisher that sets none gets at-least-once
delivery without that deduplication. A resource watch on a queue takes the
messages off it too, one change per batch.
sqs_queue, in the dev.mcpg.watch.sqs plugin, long-polls an Amazon SQS
queue, or an SQS-compatible one through endpoint_url:
source:
watch:
strategy:
type: plugin
kind: sqs_queue
queue_url: https://sqs.eu-west-1.amazonaws.com/123456789012/jobs
event_type: sqs.message
A message is deleted once the gateway has stored it and made visible again
when the timeout passes; it stays invisible for ack_timeout_ms plus 30
seconds meanwhile. The SQS message id is the event id. Credentials are
static credentials or the AWS default chain.
pubsub_subscription, in the dev.mcpg.watch.pubsub plugin, streams a
Google Cloud Pub/Sub subscription, or one on the emulator through
emulator_host:
source:
watch:
strategy:
type: plugin
kind: pubsub_subscription
subscription: projects/acme-prod/subscriptions/mcpg-orders
event_type: pubsub.message
A message is acknowledged once the gateway has stored it and nacked when the
timeout passes; the stream holds it for ack_timeout_ms plus 30 seconds
meanwhile, 10 minutes at most. The Pub/Sub message id is the event id.
Credentials are a Google credentials file in credentials_json (a service
account key, an authorized user file or a Workload Identity Federation
configuration) or Application Default Credentials.
servicebus_entity, in the dev.mcpg.watch.servicebus plugin, receives
from an Azure Service Bus queue, a topic subscription (topic and
subscription), or the dead-letter queue of either (sub_queue: dead_letter):
source:
watch:
strategy:
type: plugin
kind: servicebus_entity
namespace: acme.servicebus.windows.net
credential: { type: workload_identity }
queue: orders
event_type: servicebus.message
A message is received in peek-lock mode, completed once the gateway has
stored it, and abandoned when the timeout passes; an abandon counts as a
delivery, so a message the gateway never takes reaches the dead-letter queue
after the entity's MaxDeliveryCount. Its lock is renewed while the gateway
has it. The broker's sequence number is the event id. A session-enabled
entity is read one session at a time, with sessions: true or one named
session_id. Credentials are a connection_string, or a namespace with a
shared access key or signature or an Entra ID identity (client secret,
workload identity, managed identity).
mqtt_topics, in the dev.mcpg.watch.mqtt plugin, subscribes to MQTT 3.1.1
topic filters on a persistent session:
source:
watch:
strategy:
type: plugin
kind: mqtt_topics
url: mqtts://broker.plant.example.com:8883
topics: ["plant/+/temperature"]
event_type: mqtt.message
A QoS 1 or 2 message is acknowledged once the gateway has stored it. MQTT cannot hand one message back, so when the timeout passes the watch reconnects and the broker sends every unacknowledged message again. MQTT 3.1.1 messages carry no id: a message sent again is a new event, and a subscriber may see it twice.
redis_stream, in the dev.mcpg.watch.redis plugin, reads a Redis stream
through a consumer group (group, default mcpg-events, created when
missing):
source:
watch:
strategy:
type: plugin
kind: redis_stream
url: rediss://:${env.REDIS_PASSWORD}@redis.internal:6380/0
stream: orders
event_type: redis.stream.entry
An entry is acknowledged (XACK) once the gateway has stored it. One left
unacknowledged stays pending and is claimed back (XAUTOCLAIM) once idle
for ack_timeout_ms, which also takes over the entries of a consumer that
died; a restart reads the consumer's own pending entries first. The event id
is {stream}/{entry id}.
Built-in sources
The gateway raises three event types itself. Declare the ones you want:
mcp:
capabilities:
events:
- name: tasks.updated
source: { builtin: mcp.task.updated }
- name: approvals.requested
source: { builtin: approval.requested }
builtin | When | data | Who receives it |
|---|---|---|---|
mcp.task.updated | A task changes status | {taskId, status, statusMessage, toolName, lastUpdatedAt} | Only the principal that owns the task's session. A task of an anonymous session raises nothing. |
approval.requested | A tool call waits for approval | {approvalId, toolName, summary, deadlineAt} | Every subscriber the policy admits |
approval.resolved | It is approved, denied or expires | {approvalId, toolName, outcome} | Every subscriber the policy admits |
An approval summary names the tool call it holds. Limit who may subscribe to
the approval types with an event_name: policy rule (see
Governance).
Schema changes
Each type has a schema epoch, and every cursor and subscription carries the
epoch it was made under. A reload that only describes more keeps the epoch:
new properties, a new description, and in a payload_schema a newly
required field. Anything else is a break and moves the epoch: a property that
changes type, a constraint such as additionalProperties: false, a newly
required argument, or a payload field that stops being required.
- After a break, delivery to the type's subscriptions stops, each receiver
gets a
terminatedenvelope with-32014 {reason: "schema_changed"}, and the next refresh answers the same. The client lists the type again and subscribes afresh. A cursor from before the break answers-32014too. - When a type is removed, its subscriptions end with
-32011 {kind: "event"}, and a refresh answers the same. If the type comes back, a client subscribes as new.
Changes to the list
server/discover advertises listChanged: true. A 2026-07-28 client that
opens subscriptions/listen with the events/listChanged target receives
notifications/events/list_changed whenever a reload, or a federated
upstream's own change, adds, removes or changes an event type. It then calls
events/list again.
Matching and arguments
A subscriber's arguments must match input_schema (at most 4 KiB and 8
levels deep). The type's match is a CEL expression over arguments, event
(eventId, name, timestamp, data) and subscriber (principal_id,
trust_level, roles, groups, scopes, attributes). It runs per
occurrence per subscription and fails closed: an error or a non-boolean
delivers nothing. A type with no match delivers every occurrence to every
subscriber.
What a subscriber receives
The gateway builds every occurrence itself. name is the type name; eventId
is the sender's id when it is header-safe ([A-Za-z0-9_:-], at most 128
characters, with no . because it becomes the Standard Webhooks
webhook-id), else a derived one, and it never changes across retries;
timestamp is RFC 3339 UTC; data is validated against payload_schema,
credentials in it are redacted, and the serialized body fits 262,144 bytes.
Treat event data as data, not instructions: it is written by whoever produced the event. Keep payloads to the fields a subscriber needs to decide what to do.
Webhook subscriptions
A client subscribes with events/subscribe:
{
"name": "issues.opened",
"arguments": { "repo": "acme/api" },
"delivery": {
"mode": "webhook",
"url": "https://hooks.example.com/mcp/7f3a",
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
},
"ttlMs": 3600000
}
The answer carries the subscription id, refreshBefore (when the grant
ends), a cursor that is safe to persist, truncated, and a
deliveryStatus.
- Who may subscribe. A cryptographically verified principal (an OIDC or
JWKS token, or a token of the embedded authorization server). Anonymous and
header-asserted callers get
-32012. A self-hosted gateway behind a trusted proxy may setwebhook.require_trust: header_asserted. - Identity. A subscription is the tuple (principal, callback URL, event
name, arguments), arguments compared as canonical JSON. The id is derived
from it. Subscribing again with the same tuple refreshes that
subscription: a new secret takes over (the previous one keeps signing for
secret_rotation_grace_ms), the grant is renewed, and a suspended subscription resumes. Another principal's subscription is another tuple, so no caller can see or remove it. - The callback.
delivery.urlmust behttps, with no user info or fragment, on an allowed port, and not a private, loopback or internal address or name. The gateway resolves the host on every request, refuses it if any address is not public, connects to the checked address, and never follows a redirect. A URL that breaks one of these rules as written answers-32602. A host that resolves to an address the gateway refuses reads as-32015 {reason: "connection_refused"}, the same as one that does not resolve, so subscribe is no probe of your network. - Verification. Before the first delivery the gateway POSTs a signed
{"type": "verification", "challenge": "…"}envelope. The endpoint proves it wants the deliveries by answering2xxwith{"challenge": "…"}. A pass is reused for the same principal and URL for a day, and a destination host gets at most six challenges a minute across the cluster. A failure answers-32015withtimeout,tls_error,http_4xx,http_5xx,connection_refusedorchallenge_failed, and stores nothing. Two operator options skip the challenge:verification.modes: [challenge, allowlist]withverification.allowlistadmits the hosts listed, andmodes: [challenge, well_known]admits a callback its origin's receiver document names. The document ishttps://{host}/.well-known/mcp-webhook-receiver.json. The sketch's form is{"receivers": ["/mcp/"]}: path prefixes of that origin, where a prefix ending in/admits every path under it. The form{"callbacks": ["https://hooks.example.com/mcp/"]}is also read: each entry is a callback URL, or a URL prefix ending in/, and only entries of the callback's own origin count. - TTL. An omitted
ttlMsgets one hour; any value is clamped into [5 minutes, 24 hours], and never past the expiry of the caller's token when it is known.ttlMs: nullasks for no expiry and gets 24 hours unless the operator setsttl.allow_no_expiry. Delivery stops atrefreshBefore; ChatGPT refreshes before it. - Cursor. On a new subscription,
cursorreplays from the log: events after it within retention are delivered, andtruncated: truesays the cursor was older than the log ormaxAgeMsmoved the start. A null cursor starts at now. On a live subscription the cursor is ignored.
events/unsubscribe with the same name, arguments and URL removes it and
answers {} — also for a key that does not exist, because unsubscribe is
idempotent. unsubscribe_unknown: not_found answers -32011 instead.
Each delivery
Every occurrence is one POST with Content-Type: application/json, the
occurrence as its body, and four headers:
| Header | Value |
|---|---|
webhook-id | The eventId, the same on every retry |
webhook-timestamp | Unix seconds of this attempt |
webhook-signature | v1, + base64 HMAC-SHA256 of id.timestamp.body under the subscription's secret; two entries during a rotation |
X-MCP-Subscription-Id | The subscription id |
A receiver verifies with any Standard Webhooks library and drops a repeated
webhook-id. A repeat comes only from an owner that fails: the next owner
sends again what the failed one delivered after its last checkpoint, at most
about one second of events. A reload or a clean shutdown checkpoints first
and repeats nothing. The body's cursor never passes an event still awaiting
delivery, so a receiver can persist the last cursor it saw and resubscribe
with it after any outage.
2xxacknowledges the event.410and413give the event up at once; the subscription stays.- Anything else is retried with backoff, about 5 s, 30 s, 2 min and 5 min
apart, honoring
Retry-After, within 15 minutes and 5 attempts. Then the event is given up. - More than 95% of attempts failing over an hour, with at least 100 attempts,
suspends delivery (
deliveryStatus.active: false). The next refresh resumes it from where it stopped.
Beside events the gateway sends two signed control envelopes, both with a
top-level type an event never has: {"type": "gap", "cursor": …} when
events may have been skipped, and {"type": "terminated", "error": …} when a
subscription ends on the gateway's side. ChatGPT ignores both;
control_envelopes: [] turns them off.
Poll
A client with no public endpoint reads events itself with events/poll.
Turn it on, and every type whose delivery lists poll (the default lists
every mode) offers it:
mcp:
configurations:
events:
enabled: true
poll: { enabled: true }
{ "name": "orders.created", "arguments": {}, "cursor": null, "maxEvents": 100 }
The answer carries events (each with its own cursor), the cursor to send
next, truncated, hasMore and nextPollMs.
- From now. A
nullcursor starts at the newest event and returns none. Send the returned cursor on the next poll to get what came after it. A cursor the log cannot resume (too old, from a lost log, or not a cursor) answerstruncated: trueand a fresh cursor; it is never an error.maxAgeMsmoves the start to events at most that old. - Pacing. Wait
nextPollMs(5 seconds by default) between polls. WithhasMore: true, poll again at once. A poll sooner thanmin_next_poll_ms(1 second) after the last read of the same type and arguments reads nothing: it answers no events, the cursor it sent, and the time left. - Any replica. The cursor holds the position, so every replica answers the
same sequence. A type with
replay: nonehas no cursor; the gateway keeps the position of each principal, type and arguments instead. - Who may poll. A verified principal (
poll.require_trust: verified), as for webhook subscribe. The policy gate runs on every poll, so a principal that loses access stops receiving at once. A gate that asks for an approval refuses the poll with-32012 {reason: "approval_required"}: a poll loop cannot wait for a human. - Leases. Each principal, type and arguments that polls holds a lease for
three
nextPollMspast its last poll. While a type has a lease, its source runs, as it does for a live subscription. A principal holds at most 100 leases (-32013 {limit: "poll_leases"}). - Size. A poll returns at most
maxEventsevents (100 by default, at most 1,000), and stops early withhasMore: trueafter 4 MiB of events or 2,000 log rows read.
Events a payload gate refuses are left out and the answer says truncated.
Every event a poll returns counts as a delivery against a plan's monthly
allowance; once the month's delivery hold point is reached, a poll returns no
events and the same cursor until the 1st (UTC).
Push
A client that keeps a connection open receives events as they happen with
events/stream: one POST per subscription, answered with an SSE stream that
carries only that subscription's frames.
mcp:
configurations:
events:
enabled: true
push: { enabled: true }
{ "jsonrpc": "2.0", "id": 1, "method": "events/stream",
"params": { "name": "orders.created", "arguments": {}, "cursor": null } }
Every frame is an SSE data: line holding one JSON-RPC notification, with the
request's id under params._meta["io.modelcontextprotocol/subscriptionId"]:
| Frame | When |
|---|---|
notifications/events/active {cursor, truncated} | First. Again with truncated: true where events may be missing. |
notifications/events/event | One per occurrence, with its cursor. |
notifications/events/heartbeat {cursor} | Every push.heartbeat_interval_ms (20 seconds by default), with the position read up to. |
notifications/events/error {error} | A read failed; the stream stays open. |
notifications/events/terminated {error} | The subscription ended: the type was removed or its schema broke, the caller lost access, or its token expired. |
When the gateway closes a stream, the last frame is the JSON-RPC result of the
request, {"resultType": "complete", "_meta": {"io.modelcontextprotocol/subscriptionId": …}}.
A client closes a stream by
dropping the connection.
- The request. It must accept
text/event-stream, or it gets-32602. A refused stream (-32011,-32012,-32013,-32014,-32602) gets one JSON-RPC error and no stream. - Reconnecting. The gateway keeps no position for a stream. A client that
lost its connection opens a new stream with the last
cursorit received and gets everything after it that the log still holds. Anullcursor starts at now. A client that hears nothing for twice the heartbeat treats the stream as dead. - Who may stream. A verified principal (
push.require_trust: verified). The policy gate runs when the stream opens, and again every five minutes (reauthorize_interval_ms) from the identity it opened with. A gate that asks for an approval refuses the stream with-32012 {reason: "approval_required"}. - Limits. A principal holds at most 16 streams across the cluster
(
push.max_streams_per_principal,-32013 {limit: "streams"}). Streams do not count against the server-push stream cap of a session. An open stream is demand for its type's source.
Every event a stream sends counts as a delivery against a plan's monthly allowance; once the month's delivery hold point is reached, the stream sends heartbeats and no events until the 1st (UTC), and the events wait in the log.
Governance
An event type is a policy surface of its own, keyed event:<name>, so it
never inherits the rule of a tool with the same name. Name it with
event_name: in governance.policy.tool_access.rules:
governance:
policy:
tool_access:
rules:
- event_name: issues.opened
minimum_trust: verified
cel_allow_if: '"engineering" in identity.groups && (!has(arguments.repo) || arguments.repo.startsWith("acme/"))'
mcp:
capabilities:
events:
- name: issues.opened
input_schema:
type: object
properties: { repo: { type: string } }
source:
inbound:
source_id: github
verify:
profile: github
secrets: ["${env.GITHUB_WEBHOOK_SECRET}"]
CEL rules see two more variables here: surface ("event") and arguments
(the subscription's arguments). A caller the rule refuses does not see the
type in events/list and gets -32012 on subscribe. The policy engine chain
and tool gates run on subscribe as they do on a tool call; an approval gate is
awaited before the subscription exists. Every payload also passes the
post-dispatch tool gates for its subscriber before it is sent, so DLP or
masking plugins apply to events too; a refused payload is not sent and the
receiver gets a gap.
Authorization is checked again. Every five minutes (and after every
config reload) the gateway re-runs the policy for each subscription from the
identity it subscribed with. A token of the embedded authorization server that
is revoked stops delivery at once. A subscription that no longer passes stops:
in-flight retries are dropped, the receiver gets terminated, and the next
refresh is refused. Tokens from an external IdP have no revocation feed; the
grant never outlives the token, so each refresh authenticates again.
Limits
| Limit | Default | When reached |
|---|---|---|
| Subscriptions per principal | 100 | -32013 {limit: "subscriptions"} |
| Callback hosts per principal | 5 | -32013 {limit: "callback_hosts"} |
| Subscriptions per event type | 5,000 | -32013 {limit: "subscriptions_per_type"} |
| Subscriptions per gateway | 10,000 | -32013 {limit: "subscriptions_total"} |
| Challenges per callback host | 6 a minute, 3 at once | -32013 {limit: "callback_verifications"} |
| Deliveries in flight per host / per subscription | 8 / 4 | queued |
| Inbound requests per source | 50/s, burst 100 | 429 with Retry-After |
| Poll leases per principal | 100 | -32013 {limit: "poll_leases"} |
| Reads per poll lease | one per min_next_poll_ms (1 s) | no events, the same cursor |
| Streams per principal | 16 | -32013 {limit: "streams"} |
They sit under mcp.configurations.events.webhook.limits,
mcp.configurations.events.poll, mcp.configurations.events.push and the
source's rate_limit.
Plan limits
On a gateway a control plane manages, the org's plan adds caps of its own.
The gateway takes them from the control plane; a self-hosted gateway without
a control plane has none of them. Every plan includes webhook delivery
(events.webhooks).
- Readers. The live readers of one gateway are capped: webhook
subscriptions, poll leases, open streams, and the upstream subscriptions it
holds for imported types,
counted across the cluster. A new reader past the cap gets
-32013 {limit: "plan.event_subscriptions"}. A refresh of a live subscription, or the next poll of a held lease, is not a new reader. - Event types. The types one gateway serves, declared and imported, are
capped. Configured types come before imported ones, each in name order; the
rest are not served:
events/listleaves them out, their inbound route answers404, and their webhook subscriptions end as those of a removed type do. A change of the cap sendsnotifications/events/list_changed. On mcpg.cloud, a config that declares more types than the cap is refused at publish. - Deliveries a month. Counted over the org's gateways and every mode; deliveries of the built-in types count nothing. Once the month's delivery hold point is reached, deliveries wait: subscriptions stay, polls answer no events and the same cursor, streams send heartbeats only, events stay in the log within retention, and delivery resumes on the 1st (UTC). On the free plan the hold point is the allowance. On an mcpg.cloud paid plan it is the allowance plus what the monthly spend cap buys, and deliveries past the allowance are billed pay-as-you-go.
- Occurrences taken in a month. Counted once, when an occurrence enters a
log; a resent event id counts nothing, and the built-in
types never count and never wait. Once the month's are
used, every other source waits until the 1st (UTC): watch, resource and
federated sources stop and then resume from where they were (one that cannot
resume marks a gap, and readers see
truncated: true), and the inbound webhook routes answer429withRetry-Afterset to the end of the month. Imported types end their upstream subscriptions and take them up again from their cursor when the month's allowance comes back. Events the gateway took before that are still delivered. - Log size. What one event log keeps is capped by age, by events and by
bytes: each type keeps the smaller of its
retentionand the plan's cap. - Retry window. Webhook retries stop at the smaller of
webhook.retry.window_msand the plan's cap. - Requests a second. One ceiling per gateway process, shared by
tools/call, everyevents/*method, eachevents/streamopen (not the frames it sends), and each inbound or upstream webhook that passes its signature check, so a sender without the secret cannot use it up. A request past it gets429withRetry-After: 1; anevents/*method answers-32013 {limit: "plan.rps_per_gateway"}.
On mcpg.cloud
A managed instance runs events with a fixed delivery profile:
- Subscribers. Only a verified principal polls, streams or subscribes
(
require_trust: verifiedunderwebhook,pollandpush). - Callbacks. A callback URL must use port 443. Another port answers
-32602at subscribe, so the failure is clear at once. Deliveries leave through a tenant egress rule that admits public IPv4 addresses on 443 and nothing private. - Refused settings. Publishing a config that sets any of these is
refused:
ttl.allow_no_expiry: true, arequire_trustother thanverifiedin any mode, a verification mode other thanchallenge,allowed_portsother than[443],allow_private_backends, or aninboundsource withallow_unsigned: true. - Inbound sources. The platform edge forwards
/webhooks/events/{source_id}to an instance only when its config turns events on and declares at least oneinboundsource, and/webhooks/mcp-events/…only when a federation imports event types, for the upstream webhooks it subscribes. No other/webhooks/*route is reachable from the internet. A ChatGPT connector needs no inbound route: subscribe arrives on/mcpand every delivery goes out.
Connect ChatGPT
- Run the gateway with events on, at a public
httpsaddress. - Sign ChatGPT in with an identity source that yields a verified principal
(
governance.access.oidc_oauth,jwks, or the embedded authorization server). - Add the gateway as a connector in ChatGPT. It calls
server/discoveron the2026-07-28wire, lists your event types, and subscribes with its own callback and secret.
Nothing else is needed on the gateway side: ChatGPT answers the verification
challenge, receives each event as one POST, and refreshes before
refreshBefore.
Operate it
The admin API (/admin/v1) has an events surface: GET /events,
GET /events/types, GET /events/subscriptions (by event or principal),
POST /events/subscriptions/{id}/suspend and /terminate, POST /events:pause, /events:resume, and POST /events:purge with a principal.
delivery_paused: true holds every delivery from config, in every mode, and
keeps every subscription and every event within retention. Webhook deliveries
wait, a poll answers no events and the cursor it was given, and a stream sends
heartbeats only. Delivery resumes from each position when it is cleared.
POST /events:pause does the same from the admin API.
Listings never show a callback's path or query, which can hold a credential.
In a cluster, one replica (the events owner) runs the sources and writes the
logs, and all replicas share delivery: the subscriptions fall into 32 shards,
each a fenced lock, and every replica takes its share of them. A replica that
leaves hands its shards on when their leases expire; one that joins takes its
share after the others checkpoint what they give up. On NATS the event
subscriptions and logs keep a JetStream bucket of their own,
mcpg-state-events (256 MiB, jetstream.named_bucket_max_bytes), apart from
the bucket sessions use. Prometheus alert rules for all of this ship in
docs/observability/mcpg-events-alerts.yml in the source tree.
Metrics carry the event name, outcome and category, never a principal, URL or host:
| Metric | Reads as |
|---|---|
mcpg_events_subscribe_total{event, outcome} | Subscriptions created, refreshed, rotated |
mcpg_events_verifications_total{outcome} | Challenges passed or failed, by category |
mcpg_events_delivered_total{event, mode} | Events delivered: acknowledged by a receiver (webhook), returned by a poll (poll), sent on a stream (push) |
mcpg_events_retried_total{event, category} | Retries scheduled |
mcpg_events_abandoned_total{event, reason} | Events given up: gone, too_large, exhausted, evicted, or unsignable_id for a stored eventId that is no valid webhook-id (it is never sent and counts toward no suspension) |
mcpg_events_suspended_total{event} | Subscriptions suspended for failures |
mcpg_events_terminated_total{event, reason} | Subscriptions ended: the -32012 reason when access no longer holds, schema_changed, type_removed, suspended, or an upstream_ reason for an imported type |
mcpg_events_egress_blocked_total | Callbacks refused by the egress policy; a rise is worth a look |
mcpg_events_egress_probe_ok | 1 when webhook.egress.probe_url answered, 0 when it did not |
mcpg_events_subscriptions{state} | active, suspended, paused on the replica's shards |
mcpg_events_owned_shards | Delivery shards a replica holds; all replicas add up to 32 |
mcpg_events_log_depth{event}, mcpg_events_log_bytes{event} | Rows and bytes a type's log keeps |
mcpg_events_delivery_latency_seconds{event} | First webhook attempt to acknowledgement |
mcpg_events_inbound_total{outcome} | Inbound requests by outcome; plan_rps and ingest_held when the plan refuses them |
mcpg_events_ingested_total{event, source} | Occurrences taken into the logs, by source kind (watch, resource, inbound, federation), as the plan's monthly allowance counts them |
mcpg_events_upstream_deliveries_total{outcome} | Deliveries the upstream of an imported type sent here, by outcome; plan_rps and ingest_held when the plan refuses them |
mcpg_cp_rps_refusals_total{surface} | Requests past the plan's request ceiling: tools, events, inbound, upstream |
mcpg_events_polls_total{event, outcome} | Polls: served, throttled (sooner than the floor), held (paused, or the month's delivery hold point is reached), ended (the upstream of an imported type ended the arguments) |
mcpg_events_streams | events/stream responses open on the replica |
mcpg_events_streams_opened_total{event}, mcpg_events_streams_closed_total{event, reason} | Streams opened, and closed by client, server or the termination reason |
Audit records cover subscribe, unsubscribe, verification, termination, events
given up, deliveries (a summary per subscription every five minutes, or one
per request with audit.deliveries: every) and admin actions. They name a
callback by origin and a keyed hash of the rest, and arguments by a keyed hash.
Error codes
This page names the codes the ChatGPT profile documents: -32011 not found,
-32012 forbidden, -32013 a limit, -32014 unsupported, -32015 a callback
endpoint error. The SEP-3415 sketch numbers the same five -32023 to
-32027. mcp.configurations.events.error_codes: sep_3415 answers with
those; the default draft keeps the ones above. The data of each error is
the same in both.
-32012 always carries data.reason, which names what refused the caller:
reason | Meaning |
|---|---|
unauthenticated | No principal, or one below the mode's trust floor. |
trust, policy, policy_engine, gate | The trust floor, a rule, a policy engine or a tool gate refused it. |
insufficient_scope | The token lacks a scope a gate needs; the answer names the scopes. |
approval_required | A tool gate asks for an approval the request cannot wait for. |
licence | The gateway's licence does not cover webhook delivery (events.webhooks). A used monthly allowance is no refusal: deliveries wait. |
expired | The token a stream stands on expired. |
revoked | The token was revoked, or the subscription was terminated. |
admin | An operator terminated the subscription. |
upstream_forbidden | The upstream of an imported type refuses the caller. |
A reader takes a reason it does not know as a refusal all the same.
Troubleshooting
| Symptom | Likely cause |
|---|---|
events/list is empty | The type's rule refuses the caller, or the caller is on 2025-11-25. |
Subscribe answers -32012 {reason: "unauthenticated"} | The caller is not a verified principal. |
Subscribe answers -32015 {reason: "challenge_failed"} | The endpoint did not echo the challenge in a 2xx JSON body. |
Subscribe answers -32015 {reason: "connection_refused"} | The host does not resolve, resolves to a private address, is on deny_hosts, or redirected. |
Deliveries stop and deliveryStatus.lastError is set | The receiver is failing; the subscription may be suspended until the next refresh. |
lastError: "paused" | An operator paused delivery. |
Receivers see a gap after a restart | The log store did not survive the restart; give it a file store or a coordinator. |
To see what the gateway actually sends, point the inspector at it: it polls, holds a stream open, or takes webhook deliveries itself, and checks each against what the extension requires. See Watch MCP Events.