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 subscribes to 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 “what you would read changed”, never a guess, with one exception: a job’s duration and queued_duration, which GitLab recomputes on every read while the job runs, are left out of the comparison. 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.

Client protocolHow a client subscribesWhat it gets
2025-11-25 and earlierresources/subscribe and resources/unsubscribenotifications/resources/updated
2026-07-28subscriptions/listen, naming the URIs in resourceSubscriptions (SEP-2575)The same notification, plus a result on the open request when the server ends the subscription

The server answers both. Which one a client sends follows the protocol version its session negotiated, and which ones a deployment can serve depends on its transport (Transport notes).

On 2026-07-28 the subscription is the listen request: the client leaves it open, and it ends when the client cancels it or the server answers it. One listen may carry several URIs, and list-changed subscriptions besides. The server answers it only once every URI it carries has stopped, and never while it also carries a list-changed subscription, so the rest of what a client batched into one request is not lost with one resource.

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, most of them nothing the subscriber asked about, and cost a full page read per poll, so a subscription to a top-level collection is refused.

A pipeline’s job list and a merge request’s discussions and notes are lists, and subscribable on purpose: each is bounded by one parent object whose lifecycle the subscriber is already following, so “did this change?” is exactly the question a watch on that parent answers.

A watch on one of them sees the first page only, 100 items, which holds every ordinary pipeline and merge request; a change past it raises no notification. Every read of a list says how complete it is under the io.github.jmrplens/pageInfo key of its _meta (returned, total when GitLab sends it, and complete), so a subscriber can tell when what it watches is partial.

In four places, all built from the whitelist that enforces it, so none can drift from the others:

  1. The description of every subscribable resource template ends with the sentence Subscribable: subscriptions/listen (protocol 2026-07-28). Resources/subscribe on stateful sessions., appended at registration and never written by hand. It is two sentences rather than one with a semicolon because the served surface carries none of the characters strict gateway validators reject.
  2. The same templates carry the _meta key io.github.jmrplens/subscribable: true, for a generic client that filters without knowing this server’s manifest. MCP has no per-resource subscribable field, only the server-wide resources.subscribe capability.
  3. The gitlab://tools manifest lists every template under subscriptions.subscribable_uri_templates.
  4. In HTTP mode, the enumerating server card at /.well-known/mcp/server-card.json carries a top-level subscriptions block, beside a capabilities object that mirrors the handshake, so a directory can learn all of it without connecting. The card at /server-card is a different document and states no capabilities (Server card).

The card’s block says, per method, whether this deployment answers it:

"subscriptions": {
"supported": true,
"methods": {
"subscriptions/listen": {
"available": true,
"requires": "stateless sessions (--stateless, the default)",
"since_protocol": "2026-07-28"
},
"resources/subscribe": {
"available": false,
"requires": "stateful sessions (--stateless=false)"
}
},
"subscribable_uri_templates": ["gitlab://group/{group_id}", "..."],
"notification": "notifications/resources/updated, sent when the watched content changes (server polls GitLab)",
"end_reasons": [
"credential_evicted",
"credential_reset",
"credential_revoked",
"credential_insufficient",
"resource_gone",
"lifetime_reached",
"watcher_evicted",
"shutdown"
],
"end_reason_meta_key": "io.github.jmrplens/watch-end"
}

That is a stateless deployment. Under --stateless=false the two available values swap, and on the minimal capability surface the block is absent.

A refused subscription starts no watcher. One refused for its kind, like a top-level collection, costs no GitLab call; one refused because the resource could not be read has spent that one read. On 2026-07-28 the refusal is the JSON-RPC error that answers the subscriptions/listen itself, sent before any acknowledgment, so a client that reads the listen’s response sees it. The Go SDK client (v1.8.0) never does, because it sends the listen without waiting for its response (upstream entry); the refusal takes effect all the same.

A watcher re-reads the resource through the same handler resources/read uses and compares a SHA-256 of what it returns. For a job, and for each job of a pipeline’s job list, the hash leaves out duration and queued_duration: GitLab recomputes both from the clock on every read while a job runs, and a running job is polled every five seconds, so hashing them notified on every poll while nothing had moved. A change of status, failure reason or any other field still notifies, and a read still returns both durations. The first read happens synchronously when the subscription is accepted, before the client is told it is subscribed, so a subscriber is never notified about a change that predates it.

That first read is also the authorization check: it runs with the subscriber’s own token, so a URI the token cannot read is refused up front rather than accepted and then silently never firing. A watcher is per URI and per credential, so a second session watching the same URI with the same token joins the existing watcher, waits for that same first read, and costs nothing more. The check has a far side too: a token that loses access keeps being used until the next poll answers 401, 403 or 404, which is at most one interval, and up to ten minutes for a slowed watch; that answer is what stops the watch.

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

Work in flight means a pipeline or job in created, waiting_for_resource, preparing, pending or running (a job list is busy while any job in it is), or a merge request or issue that is opened or locked. Every other status counts as settled, manual and scheduled included: both wait on something outside CI, a person or a clock, so polling them at the floor would spend budget on a resource that cannot change until that happens. Wikis, files, branches, labels, boards and the other kinds have no state to read, so they poll at the 15-second base.

Nothing retires itself for being finished. GitLab has no terminal state: a retried pipeline reuses its ID and runs again, a closed issue reopens, a merged merge request can still be edited, so a watcher that stopped at success would go blind to a real change its subscriber asked about.

Latency is bounded by the cadence by design. The pull-safe event channels GitLab offers today (the Events API, GraphQL subscriptions over ActionCable, and conditional requests) were each probed live and declined, as ADR-0017 records.

LimitValueWhy
Watchers per credential10A watcher polls with the subscriber’s own token, so this is the ceiling on one credential’s polling
Watchers per process512A credential is one API call to mint, so a per-credential number multiplies by however many a caller holds; only this one bounds the process
Floor interval5sTen watchers at the floor already make 120 requests a minute
Rate-limit pause30s, doubling to 5 minutesGitLab enforces its limits per user, so one 429 pauses every watcher of that credential

A credential is one pool entry, one token and GitLab URL pair, in HTTP mode. On stdio the process serves one credential, so its ten watchers are the bound and the process ceiling never binds.

The request arithmetic. Ten watchers make 120 requests a minute at the floor, 40 at the 15-second base, 10 once settled, and one once slowed. Across a process, 512 watchers are about 102 requests a second at the floor and about 34 at the base. On a self-managed instance GitLab’s authenticated API rate limit defaults to 7,200 requests an hour per user, which is 120 a minute, and is off until an administrator enables it (GitLab documentation). Ten watchers at the floor would spend all of it while the same user makes tool calls, which is why the floor is not lower. GitLab.com publishes its limits per plan on its rate limits page.

At the per-credential cap, the longest-slowed watch makes room. A new subscription evicts that credential’s watch that has been slowed the longest rather than being refused: a subscriber that is waiting now wins the slot over one whose own inactivity already slowed it, and the evicted watch’s listen is answered with watcher_evicted. If none of the ten is slowed, the new subscription is refused.

The process ceiling only refuses. Making room there would mean stopping another credential’s watch to start this one, and nothing about the arriving subscription makes it worth more than the one already running. It refuses before anything is evicted, so a credential at its own cap keeps its slowed watch when the process is full and is told the server-wide ceiling was reached: a refusal never costs you a subscription you already had.

Neither ceiling is configurable, and both refusals say which one was met: the process one says server-wide in as many words, and the per-credential one carries that credential’s own count and limit, so an operator reading the error knows whether to look at the caller or the deployment. The lever an operator does have is --max-http-clients, which decides how many credentials the pool serves at once (The lever, and what it costs).

What the process ceiling costs is worth knowing before you meet it. Fifty-one credentials at the per-credential cap fill 510 of the 512 slots, and they keep them for as long as their watches live, up to 24 hours each, while every other subscribe is refused. That is accepted deliberately: the alternative is unbounded polling from one process, and a share per credential would bound nothing, since a credential is one API call to mint. A saturated deployment says so in its own log, one WARN line per refusal.

A watch that goes unrenewed does not silently die: it slows down. After 30 minutes without a request on the subscribing session, it drops to a 10-minute poll, and any request on that session restores full speed. A watch ends, or slows, for these reasons and no others:

EventEffectTold to the client?
The client unsubscribes or cancels its listenStops when the last subscriber leavesIt asked for it
The session disconnectsStopsIt is gone
30 minutes with no request on the sessionSlows to a 10-minute poll_meta on the next notification
Any request on the sessionRestores full speedNothing to tell
The resource answers 401, 403 or 404StopsListen answered with resource_gone
24 hours, renewals includedStopsListen answered with lifetime_reached
Evicted to make room at the per-credential capStopsListen answered with watcher_evicted
The pool drops this credential’s entryStopsListen answered with credential_evicted, credential_reset, credential_revoked or credential_insufficient; under --stateless=false the session is terminated
The server shuts downStopsListen answered with shutdown (HTTP)

A legacy resources/subscribe gets none of those answers, because it holds no open request; see Why a subscription ended.

The lease slows a watch down; it does not end it. MCP defines no lease, no expiry and no expiry message, and its one notification means “this changed, read it again”. A watch that retired at a deadline would go silent in a way no client could tell apart from “nothing has happened yet”, so slowing down is the one claim a server can make honestly.

What renews it. Any request that reaches the server on the session: a tool call, a resource read, a prompt, a completion, a listing. A ping and the initialize handshake do not, deliberately, because a ping proves a socket is open and not that anyone is waiting on the other end of it; notifications do not either. From 2026-07-28 a client may answer tools/list, prompts/list or a repeated resources/read from its own cache, and then nothing arrives here to renew anything, which is correct: a client serving itself from cache is not evidence that anyone is waiting.

An open subscriptions/listen renews its own watches, every ten minutes for as long as it stays open. On the default stateless transport a listen’s session sees no request but the listen itself, so nothing else would renew it, and an open listen is better evidence of a waiting subscriber than traffic is. The lease therefore governs the legacy resources/subscribe, and the 24-hour cap applies to every watch.

A watch can also end because the pool dropped its credential. In HTTP mode the watchers belong to a pool entry, and --max-http-clients bounds how many entries a process holds. Size pressure prefers an entry that is not serving a subscription, and takes one that is only when every pooled entry is serving one; then the credential’s watchers stop, its open subscriptions/listen requests are answered, and under --stateless=false the sessions no listen ended are terminated. Nothing is wrong with the credential: reconnect and subscribe again.

Every notification carries the state of the watch that produced it in _meta, under io.github.jmrplens/watch. MCP registers no key for a subscription’s lifetime, so this one is namespaced to this project:

{
"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
}
}
}
}
FieldMeaning
stateactive at full speed, slow once the watch has been slowed
renewByWhen the lease runs out and the watch slows (on a slowed watch, when it ran out)
pollIntervalMsThe cadence the watch is polling at now, in milliseconds
renewedByActivityAlways true: any request on the session pushes renewBy out again

No client is obliged to read any of it, and no client known today does. It is there because a subscriber has no other way to learn that its watch slowed down, and everything it says is true whether or not anyone looks.

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
credential_insufficientGitLab accepts the credential, which carries neither read_api nor apiReplace the token with one that does; its scopes cannot change
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 only when the failed read carried an HTTP status; it is relayed rather than interpreted. GitLab answers 404 for a resource you may not see, precisely so that it cannot be told apart from one that does not exist, so a 404 here does not mean deleted. A 401 does not mean the token is dead either: GitLab answers some missing permissions with 401. Only credential_revoked says GitLab refused the credential itself, and in HTTP mode it is sent only once GitLab has said so or the credential probe has confirmed it (Refused calls).

The vocabulary is closed and published as subscriptions.end_reasons in the enumerating server card at /.well-known/mcp/server-card.json, which is served in HTTP mode only (the card at /server-card carries no capabilities). On stdio there is no endpoint to read it from, and three of the reasons can reach a client there: resource_gone, lifetime_reached and watcher_evicted. A stdio process that stops answers an open listen with nothing at all, as ADR-0015 records. Treat an unrecognized value as “an ending this client does not know”, never as “any ending”.

The server sends no notifications/cancelled for a listen it ends, although 2026-07-28 asks for one: the Go SDK offers no way to send it for a request the client made (upstream entry). The result on the listen is what tells a client the stream is over.

An ending you caused carries no reason, because you caused it, and the result may never reach you anyway. 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. Such a client sees its standalone SSE stream end and its next request answered as a new session. The logging capability would be the one place left to carry an advisory sentence, and this server deliberately does not declare it, because SEP-2577 deprecated it. Use subscriptions/listen if you want the reason.

  • stdio: both methods work, on both protocol generations.
  • HTTP, stateless mode (the default): only subscriptions/listen works. The legacy resources/subscribe is refused with an explanatory error, because each stateless POST gets its own session that closes with the response, so a subscription it accepted could never be notified. subscriptions/listen holds its request open, which stateless mode supports.
  • HTTP, --stateless=false: only resources/subscribe works. The deployment does not offer protocol 2026-07-28, so a request naming it is answered 400 with the revisions it does negotiate (Protocol revisions this server negotiates), and the server card marks subscriptions/listen unavailable.
ClientProtocolNotes
VS Code2025-11-25Subscribes to every resource it reads, and routes updates into its file-change pipeline rather than into chat
Cursor2025-11-25 or earlierSends resources/subscribe even to servers that advertise subscribe: false
Go SDK client (v1.8.0)2026-07-28Sends subscriptions/listen without awaiting the response, so it never sees a refusal or the result that ends a subscription, and a second Subscribe to a refused URI sends nothing until Unsubscribe
Your own integrationanyThe _meta described above is there for you; the notification alone is enough without it

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

RefusalCodeRetry later?
URI deliberately not subscribable-32602 (invalid params)No: pick a subscribable URI
Resource unreadable on the authorization read (401/403/404)-32602, the same code the SDK answers an unknown resources/read withOnly if the resource appears or access is granted
GitLab rate-limited the first read-42900 (too many requests)Yes: after GitLab’s backoff
Your credential’s watchers at their cap (10) with no evictable watch-42900, with unsubscribe from one and retry in the messageYes: once one of your watches ends
This server’s own rate limit, before any watcher is asked-42900Yes: after a short backoff
Watchers per process at 512, or the server shutting down-50300 (cannot serve right now), with server-wide in the ceiling’s messageYes: the condition is transient
Transient GitLab failure on the first read-32603 (internal error)Yes
resources/subscribe on stateless HTTP-32600 (invalid request for this session state)No: use subscriptions/listen

The message beside the code carries GitLab’s failure detail verbatim. The code says which kind of refusal it is, and mirrors the HTTP status the same thing would get from a gateway: -42900 (429) when the caller made too many requests or holds too many of something, -50300 (503) when the server cannot serve the request right now, whoever made it. Both are transient. They replaced -32000, which the specification reserves for codes allocated before it partitioned the range and to which a client must not attach a meaning. On protocol 2026-07-28 a refused subscriptions/listen carries the same codes, as the JSON-RPC error answering the listen itself, sent before any acknowledgment; the last row never applies to one, since stateless HTTP serves a listen. A listen also has refusals of its own: the ceilings on open listen streams, GITLAB_MCP_MAX_LISTEN_STREAMS per credential (64 by default), answered -42900 with close one and retry, and 512 per process, answered -50300 with retry later. This server’s own per-credential rate limit (--rate-limit-rps in HTTP mode, GITLAB_MCP_RATE_LIMIT_RPS on stdio), which is not GitLab’s, refuses either method earlier still, with the -42900 of the table, before a watcher is asked. The Go SDK client (v1.8.0) sees none of them on a listen, and a second Subscribe it makes to a refused URI sends nothing until Unsubscribe.

Frequently asked questions

What are MCP resource subscriptions?

A client asks the server to watch a resource, with resources/subscribe on protocol 2025-11-25 and earlier or with subscriptions/listen from 2026-07-28, 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 for a resource with no state to read, 60 seconds once settled, and 10 minutes after a watch is slowed by 30 minutes without a request on its session. Any request on the session restores full speed, and an open subscriptions/listen keeps its own watches at full speed for as long as it stays open. The _meta on each notification reports the watch state and its current cadence.

What does watching cost against my GitLab rate limit?

Watchers poll with the subscriber's own token, capped at 10 watchers per credential. All ten at the 5-second floor make 120 requests a minute, which is the whole budget of a self-managed instance whose administrator turned on GitLab's authenticated API rate limit at its default of 7,200 requests an hour. At the 15-second base the same ten make 40 a minute, and ten slowed watches make one. A 429 from GitLab pauses every watcher of that credential with exponential back-off.