Gateway
Gatewaybeta14 min

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-28 HTTP wire; 2025-11-25 and 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).

yaml
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 no store: at all. allow_ephemeral_subscriptions: true accepts 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 file store 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 a gap.
  • Key. cluster.state_encryption_key_env names 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.

yaml
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

SourceWhat it isOccurrence data
inboundA 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.
resourceThe uri of a resources[] binding that has a watch: block. Every change of that resource is one occurrence.{uri, observedAt}
watchA 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
builtinAn event the gateway raises itself: mcp.task.updated, approval.requested, approval.resolved.See Built-in sources.
federatedNot 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.

ProfileSignatureEvent idEvent timeHandshake
standard_webhookswebhook-signature over webhook-id, webhook-timestamp and the body; whsec_ secrets with a 24 to 64 byte keywebhook-idArrivalNone
githubX-Hub-Signature-256 over the bodyX-GitHub-DeliveryArrivalA ping delivery is answered 202
stripeStripe-Signature (t=…,v1=…) over the timestamp and the body/id/createdNone
slackX-Slack-Signature over X-Slack-Request-Timestamp and the body/event_id/event_timeurl_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.

yaml
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:

FieldMeaning
nameWhat routes give in verify.profile: [a-z0-9_-], not a built-in name.
algorithmsha1, sha256 (default) or sha512.
secretprefix 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.
signatureheader 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_payloadThe signed message: {body} once, {timestamp}, {header:NAME} and literal text. Default {body}.
signed_timestampUnix seconds in a header, or a key item of the signature header, with tolerance_secs (default 300).
event_idThe sender's id: a header or a body pointer.
event_timeA body pointer to the event time, RFC 3339 or Unix seconds.
handshakesRequests 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.
yaml
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.

yaml
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
PluginWatch kindevent_typeResumesRuns on
Kafkakafka_topickafka.message: {topic, partition, offset, key, value, valueEncoding, headers}Yes, at the next offset of each partitionThe owner
NATSnats_topicnats.message: {subject, payload, payloadEncoding, headers}No: core NATS keeps no historyThe owner
SQLpostgres_listen_notifypostgres.notification: {channel, payload, payloadEncoding}NoThe owner
SQLsql_changessql.row.changed: {op, row}, op is insert, update or deleteYes, after the last key it storedThe owner
SQLpostgres_cdcsql.row.changed: {op, schema, table, row, old, lsn, xid, commitTime}; sql.table.truncatedNot needed: the replication slot keeps what is not acknowledgedThe owner
Twiliotwilio_inboundtwilio.sms.received, twilio.call.incoming, twilio.message.status, twilio.call.statusNoEvery replica
Croncronschedule.fired: {scheduledAt, firedAt, catchUp}Yes, raising up to 100 missed firesThe owner
Filefilefile.created, file.modified, file.removed: {path, paths}NoThe owner
Filefile_tailfile.line: {path, offset, line, lineEncoding, truncated?}; file.truncated: {path}Yes, each file at its offsetThe owner
Filefile_objectsobject.changed: see Files and objectsYes, exactly while the listing fits snapshot_bytesThe owner
SFTP, FTP, SMBsftp_dir_poll, ftp_dir_poll, smb_dir_pollobject.changed: see Files and objectsYes, exactly while the listing fits snapshot_bytesThe owner
S3s3_objectsobject.changed: see Files and objectsListing: yes, exactly while it fits snapshot_bytes. Notifications: not needed, SQS keeps themThe owner
Cloud Storagegcs_objectsobject.changed: see Files and objectsListing: yes, exactly while it fits snapshot_bytes. Notifications: not needed, Pub/Sub keeps themThe owner
HTTPhttp_pollSnapshot: 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 cursorThe owner
Email (IMAP)imap_mailboxobject.changed with the envelope, flags and a text preview: see Files and objectsYes, exactly while the listing fits snapshot_bytesThe owner
AMQPamqp_queueamqp.message: {queue, exchange, routingKey, redelivered, messageId, headers, body, bodyEncoding, …}; see Message queuesNot needed: the broker keeps what is not acknowledgedThe owner
Amazon SQSsqs_queuesqs.message: {queueUrl, messageId, body, bodyEncoding, sentAt, receiveCount, messageAttributes, …}Not needed: SQS keeps what is not deletedThe owner
Google Pub/Subpubsub_subscriptionpubsub.message: {subscription, messageId, publishTime, attributes, body, bodyEncoding, …}Not needed: Pub/Sub keeps what is not acknowledgedThe owner
Azure Service Busservicebus_entityservicebus.message: {namespace, entity, sequenceNumber, messageId, body, bodyEncoding, enqueuedAt, deliveryCount, applicationProperties, …}Not needed: Service Bus keeps what is not completedThe owner
MQTTmqtt_topicsmqtt.message: {topic, qos, retain, dup, payload, payloadEncoding}Not needed: the broker's persistent session keeps what is not acknowledgedThe owner
Redisredis_streamredis.stream.entry: {stream, id, fields, binaryFields?}Not needed: the consumer group's pending list keeps what is not acknowledgedThe owner
OCI registryoci_registry_polloci.tag.pushed, oci.tag.moved, oci.tag.deleted: {repository, tag, digest, previousDigest}NoThe 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 (valueEncoding or payloadEncoding) says which: json, text or base64.
  • 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}, unless group_id names 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.truncated before 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. A source.resource cannot 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:

FieldMeaning
key_columnsThe ordering key, in ORDER BY order: together unique, and only growing.
initialThe key before the first row, one value per key column.
startlatest (default) skips the rows that exist; beginning reads them all after initial.
latest_sqlFor start: latest: one row with the newest key. No row, or a null, means initial.
op_columnThe column that says what the change did: insert, update or delete, any letter case. Omitted, every row is an insert.
occurred_at_columnThe column with the change time: a timestamp, RFC 3339 text, or Unix seconds.
batch_sizeRows per query, bound to :_limit: 1 to 500, default 100. One poll emits at most 500 rows.
interval_msMilliseconds between polls: at least 100, default 1000.
settle_msRead 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_column from the row:

    yaml
    strategy:
      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.changed
    

    An update reads the row as it is now; two updates between polls arrive as one. A soft delete (deleted_at) arrives as delete.

  • 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:

    yaml
    query:
      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_at
    

    Trim 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, …):

yaml
        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:

FieldMeaning
opcreated, modified or deleted.
storeThe kind of store: file, sftp, ftp, smb, s3, gcs, imap, …
locationWhat the keys are relative to: a folder, a bucket.
keyThe object's name in its store.
sizeBytes, when the store says.
modifiedAtWhen it last changed, RFC 3339. Also the event time of a creation or a modification.
etag, versionWhat 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:

yaml
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:

yaml
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):

yaml
        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:

yaml
        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:

yaml
        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:

yaml
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:

yaml
        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:

yaml
        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):

yaml
        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:

yaml
        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):

yaml
        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:

yaml
mcp:
  capabilities:
    events:
      - name: tasks.updated
        source: { builtin: mcp.task.updated }
      - name: approvals.requested
        source: { builtin: approval.requested }
builtinWhendataWho receives it
mcp.task.updatedA 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.requestedA tool call waits for approval{approvalId, toolName, summary, deadlineAt}Every subscriber the policy admits
approval.resolvedIt 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 terminated envelope 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 -32014 too.
  • 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:

json
{
  "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.

  1. 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 set webhook.require_trust: header_asserted.
  2. 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.
  3. The callback. delivery.url must be https, 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.
  4. Verification. Before the first delivery the gateway POSTs a signed {"type": "verification", "challenge": "…"} envelope. The endpoint proves it wants the deliveries by answering 2xx with {"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 -32015 with timeout, tls_error, http_4xx, http_5xx, connection_refused or challenge_failed, and stores nothing. Two operator options skip the challenge: verification.modes: [challenge, allowlist] with verification.allowlist admits the hosts listed, and modes: [challenge, well_known] admits a callback its origin's receiver document names. The document is https://{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.
  5. TTL. An omitted ttlMs gets 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: null asks for no expiry and gets 24 hours unless the operator sets ttl.allow_no_expiry. Delivery stops at refreshBefore; ChatGPT refreshes before it.
  6. Cursor. On a new subscription, cursor replays from the log: events after it within retention are delivered, and truncated: true says the cursor was older than the log or maxAgeMs moved 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:

HeaderValue
webhook-idThe eventId, the same on every retry
webhook-timestampUnix seconds of this attempt
webhook-signaturev1, + base64 HMAC-SHA256 of id.timestamp.body under the subscription's secret; two entries during a rotation
X-MCP-Subscription-IdThe 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.

  • 2xx acknowledges the event.
  • 410 and 413 give 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:

yaml
mcp:
  configurations:
    events:
      enabled: true
      poll: { enabled: true }
json
{ "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.

  1. From now. A null cursor 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) answers truncated: true and a fresh cursor; it is never an error. maxAgeMs moves the start to events at most that old.
  2. Pacing. Wait nextPollMs (5 seconds by default) between polls. With hasMore: true, poll again at once. A poll sooner than min_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.
  3. Any replica. The cursor holds the position, so every replica answers the same sequence. A type with replay: none has no cursor; the gateway keeps the position of each principal, type and arguments instead.
  4. 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.
  5. Leases. Each principal, type and arguments that polls holds a lease for three nextPollMs past 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"}).
  6. Size. A poll returns at most maxEvents events (100 by default, at most 1,000), and stops early with hasMore: true after 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.

yaml
mcp:
  configurations:
    events:
      enabled: true
      push: { enabled: true }
json
{ "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"]:

FrameWhen
notifications/events/active {cursor, truncated}First. Again with truncated: true where events may be missing.
notifications/events/eventOne 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.

  1. 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.
  2. Reconnecting. The gateway keeps no position for a stream. A client that lost its connection opens a new stream with the last cursor it received and gets everything after it that the log still holds. A null cursor starts at now. A client that hears nothing for twice the heartbeat treats the stream as dead.
  3. 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"}.
  4. 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:

yaml
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

LimitDefaultWhen reached
Subscriptions per principal100-32013 {limit: "subscriptions"}
Callback hosts per principal5-32013 {limit: "callback_hosts"}
Subscriptions per event type5,000-32013 {limit: "subscriptions_per_type"}
Subscriptions per gateway10,000-32013 {limit: "subscriptions_total"}
Challenges per callback host6 a minute, 3 at once-32013 {limit: "callback_verifications"}
Deliveries in flight per host / per subscription8 / 4queued
Inbound requests per source50/s, burst 100429 with Retry-After
Poll leases per principal100-32013 {limit: "poll_leases"}
Reads per poll leaseone per min_next_poll_ms (1 s)no events, the same cursor
Streams per principal16-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/list leaves them out, their inbound route answers 404, and their webhook subscriptions end as those of a removed type do. A change of the cap sends notifications/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 answer 429 with Retry-After set 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 retention and the plan's cap.
  • Retry window. Webhook retries stop at the smaller of webhook.retry.window_ms and the plan's cap.
  • Requests a second. One ceiling per gateway process, shared by tools/call, every events/* method, each events/stream open (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 gets 429 with Retry-After: 1; an events/* 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: verified under webhook, poll and push).
  • Callbacks. A callback URL must use port 443. Another port answers -32602 at 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, a require_trust other than verified in any mode, a verification mode other than challenge, allowed_ports other than [443], allow_private_backends, or an inbound source with allow_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 one inbound source, 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 /mcp and every delivery goes out.

Connect ChatGPT

  1. Run the gateway with events on, at a public https address.
  2. Sign ChatGPT in with an identity source that yields a verified principal (governance.access.oidc_oauth, jwks, or the embedded authorization server).
  3. Add the gateway as a connector in ChatGPT. It calls server/discover on the 2026-07-28 wire, 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:

MetricReads 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_totalCallbacks refused by the egress policy; a rise is worth a look
mcpg_events_egress_probe_ok1 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_shardsDelivery 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_streamsevents/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:

reasonMeaning
unauthenticatedNo principal, or one below the mode's trust floor.
trust, policy, policy_engine, gateThe trust floor, a rule, a policy engine or a tool gate refused it.
insufficient_scopeThe token lacks a scope a gate needs; the answer names the scopes.
approval_requiredA tool gate asks for an approval the request cannot wait for.
licenceThe gateway's licence does not cover webhook delivery (events.webhooks). A used monthly allowance is no refusal: deliveries wait.
expiredThe token a stream stands on expired.
revokedThe token was revoked, or the subscription was terminated.
adminAn operator terminated the subscription.
upstream_forbiddenThe upstream of an imported type refuses the caller.

A reader takes a reason it does not know as a refusal all the same.

Troubleshooting

SymptomLikely cause
events/list is emptyThe 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 setThe receiver is failing; the subscription may be suspended until the next refresh.
lastError: "paused"An operator paused delivery.
Receivers see a gap after a restartThe 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.