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.
Which method does a client use?
Section titled “Which method does a client use?”| Client protocol | How a client subscribes | What it gets |
|---|---|---|
2025-11-25 and earlier | resources/subscribe and resources/unsubscribe | notifications/resources/updated |
2026-07-28 | subscriptions/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.
What can be watched?
Section titled “What can be watched?”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):
| Group | Subscribable resources |
|---|---|
| CI/CD | Pipeline, pipeline job list, latest pipeline, single job, deployment, environment |
| Collaboration | Merge request (plus its discussions and notes), issue |
| Releases and refs | Release, tag, branch, feature flag |
| Planning | Milestone and label (project or group), board |
| Content | Wiki page, repository file, snippet (project or personal) |
| Containers | Project, 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.
Why are three lists allowed?
Section titled “Why are three lists allowed?”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.
Where is the list published?
Section titled “Where is the list published?”In four places, all built from the whitelist that enforces it, so none can drift from the others:
- 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. - The same templates carry the
_metakeyio.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-wideresources.subscribecapability. - The
gitlab://toolsmanifest lists every template undersubscriptions.subscribable_uri_templates. - In HTTP mode, the enumerating server card at
/.well-known/mcp/server-card.jsoncarries a top-levelsubscriptionsblock, beside acapabilitiesobject that mirrors the handshake, so a directory can learn all of it without connecting. The card at/server-cardis 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.
What does a refused subscription cost?
Section titled “What does a refused subscription cost?”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.
How is a change detected?
Section titled “How is a change detected?”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.
How fast, and at what cost?
Section titled “How fast, and at what cost?”The polling cadence adapts to the resource:
| Resource state | Interval | Example |
|---|---|---|
| Work in flight | 5s (floor) | a running pipeline, an opened merge request |
| Settled | 60s | a success pipeline, a closed issue |
| No lifecycle field | 15s | a wiki page, a file, a label |
| Lease-demoted | 10min | 30 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.
What are the limits?
Section titled “What are the limits?”| Limit | Value | Why |
|---|---|---|
| Watchers per credential | 10 | A watcher polls with the subscriber’s own token, so this is the ceiling on one credential’s polling |
| Watchers per process | 512 | A 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 interval | 5s | Ten watchers at the floor already make 120 requests a minute |
| Rate-limit pause | 30s, doubling to 5 minutes | GitLab 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.
Lifetime and renewal
Section titled “Lifetime and renewal”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:
| Event | Effect | Told to the client? |
|---|---|---|
| The client unsubscribes or cancels its listen | Stops when the last subscriber leaves | It asked for it |
| The session disconnects | Stops | It is gone |
| 30 minutes with no request on the session | Slows to a 10-minute poll | _meta on the next notification |
| Any request on the session | Restores full speed | Nothing to tell |
| The resource answers 401, 403 or 404 | Stops | Listen answered with resource_gone |
| 24 hours, renewals included | Stops | Listen answered with lifetime_reached |
| Evicted to make room at the per-credential cap | Stops | Listen answered with watcher_evicted |
| The pool drops this credential’s entry | Stops | Listen answered with credential_evicted, credential_reset, credential_revoked or credential_insufficient; under --stateless=false the session is terminated |
| The server shuts down | Stops | Listen 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.
What does a notification carry?
Section titled “What does a notification carry?”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 } } }}| Field | Meaning |
|---|---|
state | active at full speed, slow once the watch has been slowed |
renewBy | When the lease runs out and the watch slows (on a slowed watch, when it ran out) |
pollIntervalMs | The cadence the watch is polling at now, in milliseconds |
renewedByActivity | Always 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.
Why a subscription ended
Section titled “Why a subscription ended”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" } } }}reason | What happened | What to do |
|---|---|---|
credential_evicted | Size pressure took this credential’s pooled entry | Reconnect and subscribe again now; the credential is still valid |
credential_reset | The entry was reclaimed for idleness, staleness, or a rebuild | Reconnect and subscribe again; nothing is wrong with the credential |
credential_revoked | GitLab refused the credential | Re-authenticate first; the same token will be refused again |
credential_insufficient | GitLab accepts the credential, which carries neither read_api nor api | Replace the token with one that does; its scopes cannot change |
resource_gone | The watched resource answered 401, 403 or 404 | Check access; subscribe again only if the resource comes back |
lifetime_reached | The absolute 24-hour watch lifetime ran out | Subscribe again; expected on a long-lived client |
watcher_evicted | A demoted watch of yours was stopped at the watcher cap | Subscribe again, and keep the session busy |
shutdown | The server is stopping | Reconnect; 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.
Transport notes
Section titled “Transport notes”- stdio: both methods work, on both protocol generations.
- HTTP, stateless mode (the default): only
subscriptions/listenworks. The legacyresources/subscribeis 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/listenholds its request open, which stateless mode supports. - HTTP,
--stateless=false: onlyresources/subscribeworks. The deployment does not offer protocol2026-07-28, so a request naming it is answered400with the revisions it does negotiate (Protocol revisions this server negotiates), and the server card markssubscriptions/listenunavailable.
How do clients behave?
Section titled “How do clients behave?”| Client | Protocol | Notes |
|---|---|---|
| VS Code | 2025-11-25 | Subscribes to every resource it reads, and routes updates into its file-change pipeline rather than into chat |
| Cursor | 2025-11-25 or earlier | Sends resources/subscribe even to servers that advertise subscribe: false |
| Go SDK client (v1.8.0) | 2026-07-28 | Sends 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 integration | any | The _meta described above is there for you; the notification alone is enough without it |
Error codes
Section titled “Error codes”A refused legacy resources/subscribe carries a deliberate JSON-RPC code, never the accidental code: 0 a plain error would marshal as:
| Refusal | Code | Retry 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 with | Only 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 message | Yes: once one of your watches ends |
| This server’s own rate limit, before any watcher is asked | -42900 | Yes: 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 message | Yes: 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.