Gateway
Gateway11 min

Watching resources for change

How MCPG detects that a resource changed and tells subscribed clients — polling with content hashes, inbound webhooks, event-driven strategies, and how to scope who gets notified.

An MCP client can subscribe to a resource and be told when it changes. On the gateway side that is a watch: block on the binding. This guide covers what each strategy does, how to shape a resource so change detection is accurate, and how to stand up an inbound webhook.

For the field-by-field schema see ResourceWatchConfig in the configuration reference. This page is the how and the why.

The contract

  1. A client calls resources/subscribe with a resource URI.
  2. The gateway starts a watcher for that URI.
  3. When the watcher decides the resource changed, the gateway sends notifications/resources/updated to subscribers.
  4. The client calls resources/read to get the new content.

The notification carries the URI, not the data. Nothing about the change travels in the message — no diff, no payload, no reason. The client always re-reads. That keeps authorization honest: the re-read goes through the same policy path as any other read, so a subscriber never receives content it could not have fetched itself.

A watch: block attaches to mcp.capabilities.resources[] and mcp.capabilities.resource_templates[]. Tools do not take one — there is nothing to subscribe to.

Strategies at a glance

typeChange signalReach for it when
pollSHA-256 of the response body movedThe upstream has no push channel. The default.
webhookSomeone POSTed to a token URLThe upstream can call you.
nats_topicA message arrived on a subjectYou already run NATS.
kafka_topicA message arrived on a topicYou already run Kafka.
postgres_listen_notifyA NOTIFY on a channelPostgres is the source of truth.
sql_pollingA tracking scalar advancedAny SQL database with a monotonic column.
pluginWhatever the plugin decidesA custom watch_strategy plugin.

Omit watch.strategy entirely and you get poll at 60 000 ms.

Polling

The watcher re-reads the resource through its own binding, takes a SHA-256 of the result, and compares it to the previous one. Identical hash, no notification. This is why a poll watch is not the same as a timer: an unchanged resource produces no client traffic at all.

yaml
mcp:
  capabilities:
    resources:
      - name: reports.folder
        description: Contents of the reports folder.
        uri: "drive://folder/reports"
        mime_type: application/json
        backend:
          kind: http
          url: "https://www.googleapis.com/drive/v3/files?q=%27FOLDER_ID%27+in+parents&orderBy=name&fields=files(id,name,modifiedTime,md5Checksum)"
          method: get
          headers:
            Authorization: "Bearer ${env.GOOGLE_TOKEN}"
          expected_status_codes: [200]
          require_json_response: true
        watch:
          strategy:
            type: poll
            interval_ms: 60000

Two behaviours that surprise people

The interval has a 10-second floor. Configure interval_ms: 500 and you get 10 000 ms. A misconfigured watcher cannot hammer a backend.

The first read never notifies. The watcher sleeps one interval, fetches, and stores that hash as the baseline. Only a later differing hash fires. A change between subscribe time and the first fetch is invisible, because there was nothing to compare against. If a client must have current state at subscribe time, it reads once itself — do not expect the subscription to deliver an opening event.

The response body is the design

The hash covers the whole response, so what you ask the upstream for decides both what you can detect and what wakes you up for nothing. Three rules, and they matter more than the interval.

Include a signal that moves when content moves. A Drive listing that returns only id and name cannot see a file edited in place — the listing is byte-identical afterwards. Add md5Checksum, or modifiedTime, or whatever the API offers. On a Google Doc, revisionId alone is enough and is the cheapest possible watch.

Exclude anything that moves on its own. Google Calendar returns a nextSyncToken that changes on every read. Leave it in the response and the watch fires on every single poll, forever. Pin the field list.

Pin the order. A listing that comes back in a different order is a different hash. Set orderBy, or an unchanged folder reports a change.

Turning a delete into an event

A resource that vanishes usually starts failing rather than reporting a change. You can make the disappearance itself the signal by accepting the not-found status:

yaml
        backend:
          kind: http
          url: "https://www.googleapis.com/drive/v3/files/FILE_ID?fields=id,name,trashed,md5Checksum"
          method: get
          expected_status_codes: [200, 404]
          require_json_response: true

A hard delete replaces the file document with an error document. The hash moves, the notification fires. Treat 404 as a failure instead and you get a broken watch rather than a delete event.

A fetch that fails outright is skipped: no baseline update, no notification. A flapping upstream goes quiet rather than firing noise.

Webhooks

When the upstream can call you, drop the cadence entirely.

yaml
        watch:
          strategy:
            type: webhook
            token: "${env.REPORTS_WEBHOOK_TOKEN}"

The gateway serves:

bash
POST /webhooks/resource-updated/{token}

The route is always mounted. On a matching token the gateway sends notifications/resources/updated for that resource and answers 200 {"ok": true, "uri": "..."}. An unknown token gets 404 {"error": "unknown webhook token"}.

Leave token empty and the gateway generates a UUID-v4 at startup — fine for a scratch run, but the URL then changes on every restart. Set it from the environment for anything real.

Rotating a token without dropping events

A sender is re-registered out of band, so it cannot switch at the same instant the gateway does. previous_tokens keeps the old value routing until you are done:

yaml
        watch:
          strategy:
            type: webhook
            token: "${env.REPORTS_WEBHOOK_TOKEN}"
            previous_tokens: ["${env.REPORTS_WEBHOOK_TOKEN_OLD}"]

Promote the new token, restart, re-register the senders at their own pace, then empty previous_tokens and restart again. The second restart is the rotation — until then the old token still works, so a half-finished rotation has revoked nothing.

Two resources must not share a token. The gateway logs the collision and the later resource wins, which silently misroutes the earlier one.

What is checked, and what is not

One token maps to one resource URI. Watching five resources by push means five tokens. There is no broadcast token.

The body is ignored. Empty or JSON, it makes no difference — only the token in the path selects the resource. Nothing a sender puts in the body reaches a client, because the notification carries only the URI and the client re-reads through the normal path.

Origin is validated against gateway.server.allowed_origins, the same posture as /mcp. A request with no Origin header passes, which is what server-to-server senders send. That check exists to stop a browser on another site from triggering your watches, not to authenticate the sender.

The security posture, stated plainly

The token is a routing key in a URL path. It is the only gate. Anyone who learns it can make the gateway emit a change notification for that resource.

What that costs you is bounded: a spurious notification causes subscribers to re-read a resource they were already allowed to read. An attacker cannot inject content, cannot read the resource, and cannot reach any other resource with that token. The realistic damage is noise and the load of the re-reads it triggers.

Treat tokens as secrets anyway — long, random, per resource, from the environment rather than the config file, rotated like any other credential. They appear in upstream configuration and in the URL, so they are more exposed than a header credential.

Wiring up a real sender

Most upstreams need three things: an HTTPS URL they can reach, proof you own the domain, and periodic re-registration.

Google Drive is the representative case:

  1. Configure the resource with a webhook strategy and a known token.
  2. Expose the gateway on HTTPS at a domain you have verified with Google.
  3. Call Drive's changes.watch or files.watch with your /webhooks/resource-updated/{token} URL as the channel address.
  4. Re-register before the channel expires. Drive drops a channel within 24 hours, and MCPG does not renew it for you. Without a cron job the watch silently stops.

One caveat specific to push senders: the trigger fires on any POST to that path. MCPG does not inspect sender headers, so Google's opening sync message fires the watch once, exactly as a real change would. Subscribers get one harmless extra notification per channel registration.

If you cannot meet the HTTPS and domain-verification bar, or cannot run the renewal job, use poll. It is less elegant and considerably more robust.

Scoping who gets told

By default every subscriber to a URI receives every notification. For a shared resource that is right. For a mailbox, an inbox, or anything per-person it is a leak of activity metadata — subscribers learn that something changed even where they cannot read it.

notification_filter narrows the fan-out:

yaml
        watch:
          strategy:
            type: poll
            interval_ms: 60000
          notification_filter:
            scope: subject_id
scopeWho is notified
allEvery subscriber. The default.
subject_idOnly subscribers whose principal matches the event.
session_idOnly the originating session.
expressionWhatever a CEL expression decides, per subscriber.

The CEL form sees subscriber.principal_id, subscriber.trust_level, subscriber.roles, subscriber.groups, subscriber.scopes, subscriber.attributes, and event.uri:

yaml
          notification_filter:
            scope: expression
            expression: 'subscriber.trust_level == "verified" && "finance" in subscriber.groups'

Filtering notifications is not an access control. A subscriber that is not notified can still call resources/read whenever it likes; whether that read succeeds is decided by policy, not here. Use the filter to control noise and metadata leakage, and authorization to control access.

Event-driven strategies

When the change already announces itself on a bus, take it from there instead of polling. Any message on the subject or topic means "changed" — the payload is not inspected.

yaml
        watch:
          strategy:
            type: nats_topic
            subject: "inventory.updates"
yaml
        watch:
          strategy:
            type: postgres_listen_notify
            url: "postgres://gateway@db.internal/app"
            channel: "inventory_changed"

postgres_listen_notify holds one dedicated connection per watch and re-emits NOTIFY payloads — far cheaper than polling for change-feed-shaped data. sql_polling is the portable alternative: it runs a scalar tracking query on a cadence and fires when the value advances, which suits any database with a monotonic column.

plugin is the escape hatch. It hands the whole spec to a loaded watch_strategy plugin by its kind() discriminator, so a custom source needs no gateway change:

yaml
        watch:
          strategy:
            type: plugin
            kind: twilio_inbound
            kinds: [sms, voice]

Operating notes

A watch that never fires and a watch whose upstream is failing look identical from the client side. Three things tell them apart.

Metrics. mcpg_watch_active_watchers is a gauge; mcpg_watch_fired_total and mcpg_watch_notifications_sent_total are counters labelled by strategy; mcpg_watch_poll_failures_total counts reads that failed and were skipped. Firing while notifications_sent stays flat means the change was detected and nobody was told.

The admin API lists what is running right now:

bash
curl -s -H "Authorization: Bearer $MCPG_ADMIN_TOKEN" \
  http://gw.internal:9090/admin/v1/watches

A watcher exists only while something is subscribed, so an empty list means nothing is subscribed rather than nothing is configured.

Audit. Every fire writes mcpg.watch.fired with the URI, the strategy, and the subscriber count.

Start conservative on cadence. A 60-second poll against a cheap endpoint is nearly free; the same poll against a paid API multiplies by the number of watched resources and runs forever. Where an upstream bills by request, prefer a narrow fields list and a longer interval over a chatty watch, or move to webhook and pay only for real changes.

See also