Skip to content

Resource Subscriptions

Resource subscriptions let a client say “tell me when this changes” instead of re-reading a resource on a hunch. The client sends resources/subscribe for a URI such as gitlab://project/42/pipelines/latest, and GitLab MCP Server sends notifications/resources/updated whenever that resource’s content changes.

The server honors subscriptions by polling: a watcher re-reads the subscribed URI through the very handler resources/read dispatches to, compares a SHA-256 of the content, and notifies only on a real change. “The content changed” therefore means exactly “what you would read changed” — never a guess. The decision and its bounds are recorded in ADR-0015.

Subscriptions are advertised on GITLAB_MCP_CAPABILITY_SURFACE=full (the default). The minimal surface does not register the GitLab data resources a subscription applies to, so it does not advertise the capability either.

26 resource kinds: single objects with a lifecycle worth following, plus three single-parent lists (a pipeline’s jobs, a merge request’s discussions and its notes):

GroupSubscribable resources
CI/CDPipeline, pipeline job list, latest pipeline, single job, deployment, environment
CollaborationMerge request (plus its discussions and notes), issue
Releases and refsRelease, tag, branch, feature flag
PlanningMilestone and label (project or group), board
ContentWiki page, repository file, snippet (project or personal)
ContainersProject, group, deploy key

Collections are deliberately excluded. Subscribing to gitlab://project/42/issues would fire on every change to any issue in the project and cost a full page read per poll — a subscription to a collection is refused. The full machine-readable list ships in the gitlab://tools manifest under subscriptions.subscribable_uri_templates.

The polling cadence adapts to the resource:

Resource stateIntervalExample
Work in flight5s (floor)a running pipeline, an opened merge request
Settled60sa success pipeline, a closed issue
No lifecycle field15sa wiki page, a file, a label
Lease-demoted10min30 minutes without any request on the session

Watchers use the subscriber’s own GitLab token, capped at 10 watchers per credential (one pool entry per token+URL in HTTP mode) and at 512 per process. The worst case — ten watchers at the 5s floor — is 120 requests/minute; ten demoted watches cost one. A 429 from GitLab pauses every watcher with exponential back-off (30s doubling to 5 minutes). The process ceiling refuses rather than stopping anyone’s watch, and it refuses before a credential at its own cap spends a demoted watch making room, so a refusal never costs you a subscription you already had.

The process ceiling exists because a credential is one API call to mint, so a per-credential number multiplies by however many a caller holds. It refuses rather than making room: stopping one credential’s watch to start another’s is a trade this server does not make. Neither ceiling is configurable, and both refusals say which one was reached.

Nothing retires itself for being “finished”: GitLab has no terminal state — a retried pipeline reuses its ID and runs again, a closed issue reopens — so a watch only ends when the client unsubscribes, its session disconnects, the resource returns 401/403/404, the 24-hour lifetime cap is reached, it is evicted to make room at the watcher cap, or the pool drops the credential’s entry under --max-http-clients pressure. That last one prefers an entry that is not serving a subscription and takes a busy one only when every pooled entry is busy; when it happens the watchers stop, the open subscriptions/listen requests are completed, and reconnecting is enough, because nothing is wrong with the credential.

A watch that goes unrenewed does not silently die — it slows down. After 30 minutes without traffic on the subscribing session, the watch drops to a 10-minute poll; any tool call or resource read on that session restores full speed automatically. Every notification carries the watch state in _meta under io.github.jmrplens/watch:

{
"method": "notifications/resources/updated",
"params": {
"uri": "gitlab://project/42/pipeline/99",
"_meta": {
"io.github.jmrplens/watch": {
"state": "active",
"renewBy": "2026-08-25T19:30:00Z",
"pollIntervalMs": 5000,
"renewedByActivity": true
}
}
}
}

On protocol 2026-07-28 a subscription is a subscriptions/listen request the client leaves open, and the server ends it by answering it. Every ending the server initiates says which one it was, in the result’s _meta, beside the subscription id the SDK stamps there:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": 1,
"io.github.jmrplens/watch-end": {
"reason": "credential_evicted",
"detail": "the server released this credential's pooled entry under capacity pressure; reconnect and subscribe again, the credential itself is still valid"
}
}
}
}
reasonWhat happenedWhat to do
credential_evictedSize pressure took this credential’s pooled entryReconnect and subscribe again now; the credential is still valid
credential_resetThe entry was reclaimed for idleness, staleness, or a rebuildReconnect and subscribe again; nothing is wrong with the credential
credential_revokedGitLab refused the credentialRe-authenticate first; the same token will be refused again
resource_goneThe watched resource answered 401, 403 or 404Check access; subscribe again only if the resource comes back
lifetime_reachedThe absolute 24-hour watch lifetime ran outSubscribe again; expected on a long-lived client
watcher_evictedA demoted watch of yours was stopped at the watcher capSubscribe again, and keep the session busy
shutdownThe server is stoppingReconnect; behind a balancer another instance will take you

detail is a sentence of advice, so a client that does not recognize the reason word can still act on it. A status field appears only on resource_gone, and it is relayed rather than interpreted: GitLab answers 404 for a resource you may not see, so a 404 here does not mean deleted. The vocabulary is closed and published in the server card’s subscriptions.end_reasons, which is served in HTTP mode only: on stdio there is no endpoint to read it from, and the four reasons reachable there (resource_gone, lifetime_reached, watcher_evicted and shutdown) are documented on this page instead. Treat an unrecognized value as “an ending this client does not know”, never as “any ending”.

An ending you caused carries no reason, because you caused it. Neither does a session-era resources/subscribe under --stateless=false: it holds no open request, so there is nothing to answer, and terminating the session is the only ending the protocol offers it. Use subscriptions/listen if you want the reason.

  • stdio: works as-is on both protocol generations.
  • HTTP, stateless mode (the default): the legacy resources/subscribe request is refused with an explanatory error — each stateless POST gets its own session that closes with the response, so a subscription it accepted could never be notified. Clients on protocol 2026-07-28 are unaffected: subscriptions/listen holds the request open, which stateless mode supports. Legacy subscribers need --stateless=false.
  • Client quirks: VS Code subscribes to every resource it reads and routes updates into its file-change pipeline; Cursor sends resources/subscribe even to servers that advertise subscribe: false; the Go SDK client fires subscriptions/listen without awaiting the response, so a refusal may never surface client-side.

A refused legacy resources/subscribe carries a deliberate JSON-RPC code — never the accidental code: 0 a plain error would marshal as:

RefusalCode
URI deliberately not subscribable-32602 (invalid params)
Resource unreadable on the authorization read (401/403/404)-32602 — the same code the SDK answers an unknown resources/read with
Rate limited, watcher cap full, or server shutting down-32000 (server busy — transient, retry later)
Watchers per process at 512-32000, with server-wide in the message
Transient GitLab failure on the first read-32603 (internal error)
resources/subscribe on stateless HTTP-32600 — use subscriptions/listen instead

Frequently asked questions

What are MCP resource subscriptions?

A client asks the server to watch a resource with resources/subscribe, and the server sends notifications/resources/updated when its content changes. GitLab MCP Server honors this by polling: a watcher re-reads the subscribed URI through the same handler resources/read uses and notifies only when the content actually changed. GitLab offers no push channel a local server could consume, so polling under explicit bounds is the honest implementation.

Which resources can be subscribed to?

26 resource kinds, single objects plus three single-parent lists: a project or group; a pipeline, its job list, the latest pipeline, a single job; a merge request with its discussions and notes; an issue; a deployment, environment, or feature flag; a release, tag, or branch; a milestone or label (project or group); a board; a deploy key; a snippet; a wiki page; and a repository file. Collections are refused — subscribing to an issue list would notify on changes the subscriber never asked about.

How fast do notifications arrive?

Latency is the polling cadence: 5 seconds while the resource is busy (a running pipeline, an open merge request), 15 seconds by default, 60 seconds once settled, and 10 minutes after a watch lease-demotes from 30 minutes of session inactivity. Any request on the session restores full speed. The _meta on each notification reports the watch state and its current cadence.

What does watching cost against my GitLab rate limit?

Watchers use the subscriber's own token, capped at 10 watchers per credential. Worst case — all ten at the 5-second floor — is 120 requests per minute, 6% of GitLab.com's per-user limit. Ten demoted watches cost one request per minute. One 429 from GitLab pauses every watcher with exponential back-off.