Skip to content

HTTP Server Mode

By default, GitLab MCP Server runs in stdio mode — each AI client spawns its own server process. HTTP mode is an alternative where a single server process serves multiple clients over the network, each authenticating with their own GitLab token.

ScenarioRecommended Mode
Single developer, local AI clientstdio
Team sharing one server instanceHTTP
Remote/headless server deploymentHTTP
CI/CD integration with MCPHTTP
Testing with curl or HTTP clientsHTTP

The instance is not optional: --gitlab-url names the GitLab this deployment serves, and a start without one is refused unless --allow-any-gitlab-url is passed.

Terminal window
# Single GitLab.com instance (fixed URL for all clients; replace for self-managed GitLab)
gitlab-mcp-server --http --gitlab-url=https://gitlab.com
# Several published instances (each client selects one via the GITLAB-URL header, which is then required)
gitlab-mcp-server --http --gitlab-url=https://gitlab.com,https://gitlab.internal.example.com
# No instance published: GITLAB-URL names any host. Single-user local deployments only,
# and the loopback bind is required rather than advised: the hatch is refused on any other address
gitlab-mcp-server --http --allow-any-gitlab-url --http-addr=127.0.0.1:8080

The server starts listening on port 8080 by default. The MCP endpoint is available at /mcp.

FlagDefaultDescription
--http(off)Enable HTTP transport mode
--http-addr:8080Listen address: host:port, or a filesystem path to bind a unix socket instead. The socket is host-local, so a same-host proxy or client connects through it and the TCP hop between them disappears rather than being encrypted; remote clients still reach the server through that proxy
--http-socket-mode0660Octal permission mode for a unix socket named by --http-addr; the default lets owner and group connect and nobody else
--tls-cert / --tls-key(empty)PEM certificate and key. Serves HTTPS on the listener itself, for a proxy that does not share the machine. Both or neither
--gitlab-url(required)GitLab instance URL, unless --allow-any-gitlab-url is passed. Repeatable (or comma-separated) to publish several instances: all are listed in the RFC 9728 authorization_servers field, and GITLAB-URL is then required to select among them
--allow-any-gitlab-urlfalseStart with no instance published, letting GITLAB-URL name any host. For a single-user local deployment; it is refused unless --http-addr binds a loopback address or a unix socket, and warns at startup even there
--skip-tls-verifyfalseSkip TLS certificate verification when calling GitLab (outbound; unrelated to --tls-cert). OAuth mode refuses it for a non-loopback instance, since bearer tokens are forwarded there on every call
--tool-surfacedynamicCanonical tool catalog selector; see Tool and capability surface options
--capability-surfacefullResource and prompt selector; see Tool and capability surface options
--meta-param-schemaopaqueMeta-tool input schema mode: opaque, compact, or full; applies to meta-tool schemas only
--tier(detected)Force the licensing tier (free, ce, premium, ultimate); omit to detect it per token+URL entry from the instance license, then from the namespace plans, falling back to free
--read-onlyfalseRead-only mode: removes mutating operations per action; reads keep working
--safe-modefalseIntercept mutating tools and return a JSON preview instead of executing them
--embedded-resourcestrueEmbed canonical MCP resource URIs in get_* tool results
--exclude-tools(empty)Comma-separated tool names, group names or canonical action IDs to exclude, on every surface
--ignore-scopesfalseSkip PAT scope detection and register all tools allowed by the configured catalog
--max-http-clients100Maximum unique token+URL entries in the server pool; bounds pooled entries, not the sessions or requests they hold, which the process bounds by its descriptor limit (192 held calls and 96 stateful sessions at once under a hard limit of 1024), not configurable (upper bound: 10000)
--session-timeout30mIdle MCP session timeout; applies to --stateless=false only, since under the default stateless transport each POST’s session ends with its response. A session a client never deletes holds one of the process’s session slots until it expires, and with 0 until the pool evicts its credential, which startup warns about (upper bound: 24h)
--http-idle-timeout0 (disabled)HTTP server idle connection timeout. 0 (default) disables idle closure, so --session-timeout is the effective lifetime; set a positive duration to recycle idle connections sooner
--auth-modelegacyAuthentication mode: legacy or oauth (RFC 9728)
--public-url(empty)Externally reachable https origin of this deployment. Required with --auth-mode=oauth: it is the RFC 9728 protected-resource identifier, and the metadata URL is derived from it; see Where the metadata lives
--resource-documentation(empty)https URL published as RFC 9728 resource_documentation; point it at a page describing your own OAuth application (its client ID and registered redirect URIs). Empty publishes this project’s HTTP server mode page
--resource-policy-uri(empty)https URL published as RFC 9728 resource_policy_uri; empty omits the field
--resource-tos-uri(empty)https URL published as RFC 9728 resource_tos_uri; empty omits the field
--oauth-cache-ttl15mOAuth token identity cache TTL (range: 1m–2h)
--oauth-client-uid(empty)Comma-separated GitLab OAuth application uids whose tokens are admitted. Empty admits any credential the instance accepts; setting it also refuses personal access tokens, which belong to no application
--trusted-origins(empty)Comma-separated absolute origins (scheme://host[:port]) allowed to make cross-origin browser requests; * accepts any origin and disables the protection; empty adds none. The --public-url origin is trusted automatically
--action-timeout65mCancel an action still running after this long; 0 disables it (upper bound: 24h). Falls back to GITLAB_MCP_ACTION_TIMEOUT
--drain-delay0After SIGTERM, keep the listener open and answer /health with 503 draining for this long before closing it, so a balancer that polls /health removes the instance before the close (upper bound: 5m); 0 closes at once. Falls back to GITLAB_MCP_DRAIN_DELAY
--pool-idle-timeout1hReclaim a pooled per-token-and-URL credential entry after this long unused; 0 keeps entries until the pool size bound evicts them (upper bound: 24h). An entry with a live subscription is never idle by this measure
--revalidate-interval15mToken re-validation interval; 0 stops the periodic check, but an entry whose credential is older than 1h is rebuilt anyway (upper bound: 24h)
--rate-limit-rps10Per-credential rate limit, in req/s, on every call that reaches GitLab: tools/call, resources/read, resources/subscribe, subscriptions/listen, prompts/get (0 disables it), plus tools/list on a bucket of its own refilled a tenth as fast and holding the same burst, charged for spending the shared processor rather than for reaching GitLab, and first on one bucket the whole process shares, 3000 tools a second and not configurable; on by default because an HTTP deployment is shared
--rate-limit-burst40Token-bucket burst size when --rate-limit-rps > 0
--trusted-proxies(empty)Addresses or CIDR ranges of the reverse proxies whose --trusted-proxy-header is believed (e.g. 127.0.0.1,10.0.0.0/8); from any other peer the header is ignored. Required with --trusted-proxy-header
--trusted-proxy-header(empty)HTTP header with the real client IP (e.g. CF-Connecting-IP, X-Forwarded-For), so the authentication-failure limiter charges callers rather than the proxy; believed only from --trusted-proxies, which it requires
--statelesstrueSessionless streamable HTTP (SEP-2567 / protocol 2026-07-28): no Mcp-Session-Id tracking, every POST is self-contained, GET/DELETE return 405. Use --stateless=false for legacy stateful sessions, which the process keeps at most half as many of as it may hold calls
--json-responsefalseReturn application/json response bodies instead of text/event-stream (SSE)
--max-request-body-bytes0Maximum streamable HTTP request body size in bytes; 0 uses the SDK default (4 MiB); negative values are rejected at startup. Oversized bodies are rejected with 413
--log-levelinfoLogging verbosity: debug, info, warn or error. Sets GITLAB_MCP_LOG_LEVEL
--client-compatautoPer-client response compatibility, auto or off. Sets GITLAB_MCP_CLIENT_COMPAT; see Client Compatibility
--upload-max-file-size2GBMaximum size for upload and file-read tools. Sets GITLAB_MCP_UPLOAD_MAX_FILE_SIZE
--yolo-modefalseSkip the confirmation prompt on destructive actions. Sets GITLAB_MCP_YOLO_MODE
--description-substitutions(empty)Rewrite listed descriptions and titles for strict gateway validators: comma-separated old=new pairs. Sets GITLAB_MCP_DESCRIPTION_SUBSTITUTIONS
--pprof-addr(empty)Serve Go’s profiling handlers (net/http/pprof) on this loopback address, on a listener of their own; a host that is not loopback is refused at startup. Sets GITLAB_MCP_PPROF_ADDR
--telemetryfalseExport OpenTelemetry traces, metrics and logs over OTLP to the collector the standard OTEL_EXPORTER_OTLP_* variables name; see OpenTelemetry
--telemetry-identitynoneWhat telemetry records about the caller: none, pseudonymous or full
--telemetry-identity-rotation(empty)How long a generated pseudonymisation key lives, e.g. 24h; empty keeps it for the life of the process. Ignored when GITLAB_MCP_TELEMETRY_IDENTITY_KEY is set
--telemetry-tool-nameautoWhether gen_ai.tool.name is a metric dimension: auto (on for dynamic and meta, off for individual), on or off

The flags shared with stdio mode (--transport, --env-file, --probe, --shutdown, --tool-search, --version) are documented in the CLI reference rather than repeated here.

--tool-surface selects the visible MCP tool catalog every HTTP client is served:

  • dynamic (default when omitted): the low-token two-tool surface, gitlab_find_action and gitlab_execute_action.
  • meta: domain-level meta-tools, a consolidated catalog that routes on the action parameter.
  • individual: every GitLab operation exposed as its own tool.

--capability-surface controls resources and prompts independently of tools: full registers all resources, workflow guides, prompts, and the surface-aware gitlab://tools manifest, while minimal keeps the gitlab://tools manifest and omits prompts, workflow guides, and optional GitLab data resources. Dynamic schema discovery still works with minimal because find returns schemas inline.

--meta-param-schema affects visible domain meta-tool schemas only. Keep opaque unless a client needs compact or full in tools/list; exact call shapes remain available through gitlab://tools/{id}.

HTTP clients control only their GitLab token and, in multi-instance mode, the GITLAB-URL selector. Server policy options such as --tool-surface, --capability-surface, --meta-param-schema, --rate-limit-rps, --read-only, --safe-mode, --auth-mode, --trusted-proxy-header and --trusted-proxies are fixed by the MCP server process and cannot be changed per user, session, or JSON-RPC request.

If a client sends config-like headers such as TOOL-SURFACE, META-TOOLS, CAPABILITY-SURFACE, META-PARAM-SCHEMA, RATE-LIMIT-RPS, POOL-IDLE-TIMEOUT, or GITLAB-SAFE-MODE, the server ignores them and logs their option names in ignored_options without logging their values. Deprecated META-TOOLS headers are also identified in deprecated_options.

Server settings resolve in three layers, highest first: a CLI flag passed explicitly, then the matching environment variable, then the built-in default. All three are process-side; no client can reach any of them, so one user can never change behaviour for themselves or for anyone else.

Clients must provide their GitLab Personal Access Token on every HTTP request using one of two headers.

When the server starts with --allow-any-gitlab-url and publishes no instance, a client selects the GitLab instance to target with the GITLAB-URL header. The header is required there too: a request without it is refused, because answering it would send that caller’s token to an instance they never named. When the server publishes several instances the header selects among them, and a request without it is refused the same way.

GITLAB-URL: https://gitlab.example.com

If the deployment pins exactly one instance, this header is ignored and logged. If it publishes several, the header picks one of them and any other value is refused. If it pins none and the header is omitted, the request is refused with 400 rather than resolved to https://gitlab.com.

PRIVATE-TOKEN: glpat-xxxxxxxxxxxxxxxxxxxx
Authorization: Bearer glpat-xxxxxxxxxxxxxxxxxxxx

If both headers are present, PRIVATE-TOKEN takes precedence. Requests without a valid token are rejected.

OAuth mode (--auth-mode=oauth) enables RFC 9728–compliant OAuth 2.1 authentication. Instead of managing tokens manually, MCP clients discover the authorization server automatically and handle the OAuth flow:

Terminal window
gitlab-mcp-server --http --gitlab-url=https://gitlab.com --auth-mode=oauth --public-url=https://mcp.example.com

How it works:

  1. The server exposes /.well-known/oauth-protected-resource with metadata pointing to your GitLab instance as the authorization server
  2. MCP clients (VS Code, Claude Code) discover this endpoint and initiate the OAuth 2.1 PKCE flow
  3. Users authorize in the browser — no token copying required
  4. The server validates Bearer tokens against the GitLab API and caches the identity for --oauth-cache-ttl (default: 15 minutes). The cache holds at most 10,000 identities and, when full, drops an expired one or else the one used least recently, and at most 16 new tokens are verified at once across the process: a new token that finds no slot free within five seconds is answered 503 with Retry-After, without being judged or charged. Neither bound is configurable, and a token already cached never waits for a slot, though it shares the listener with the requests waiting for one (see the price of that ceiling)

Where the metadata lives: host root or sub-path

Section titled “Where the metadata lives: host root or sub-path”

The well-known segment always sits at the root of the host, and the resource’s own path moves behind it. That gives two deployment shapes:

Deployment--public-urlMetadata URL
One server owning the hostnamehttps://mcp.example.comhttps://mcp.example.com/.well-known/oauth-protected-resource
A server under a path prefixhttps://mcp.example.com/gitlabhttps://mcp.example.com/.well-known/oauth-protected-resource/gitlab

The second row is the one that surprises people: the metadata for a server at /gitlab is not at /gitlab/.well-known/.... A proxy forwarding only /gitlab/* strands discovery while every MCP call keeps working, which is a confusing way to fail.

A single server that owns its hostname has neither problem: the derived form is the path-less form, and there is no neighbour to speak for.

Client configuration in OAuth mode:

{
"servers": {
"gitlab": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"oauth": {
"clientId": "YOUR_GITLAB_APPLICATION_ID",
"scopes": ["api"]
}
}
}
}
  • clientId: The Application ID from your GitLab OAuth Application (see docs/guides/oauth-app-setup.md)
  • scopes: Must include api for full tool functionality

VS Code handles OAuth discovery and authorization automatically.

Stateless is the default transport model, following the sessionless design introduced by SEP-2567 (MCP protocol 2026-07-28):

  • The server neither reads nor sets the Mcp-Session-Id header. Every POST is a self-contained JSON-RPC exchange — no initialize round-trip is required.
  • GET and DELETE on the MCP endpoint return 405 Method Not Allowed (Allow: POST), with no credential required — there is nothing they can address, so gating them would replace the specified answer with a 401. Under --stateless=false they are authenticated and ownership-checked like a POST instead, because there a GET opens a session’s SSE stream and a DELETE terminates the session. /health and /.well-known/* endpoints are unaffected.
  • Synchronous server-initiated requests are unavailable. Clients on protocol 2026-07-28 keep full elicitation through multi-round-trip requests (MRTR), which travel inside the tool result; only legacy-protocol clients fall back to non-interactive alternatives (e.g. the confirm parameter for destructive actions).
  • --session-timeout has no effect: no session outlives its request.
  • The per-token-and-URL credential pool still applies, so repeated requests reuse a cached pool entry.

Stateless mode suits load-balanced deployments where requests from one client may land on different replicas. Combine with --json-response for clients or gateways that prefer plain JSON bodies over SSE:

Terminal window
gitlab-mcp-server --http --gitlab-url=https://gitlab.example.com \
--json-response

In stateless mode the legacy resources/subscribe request is refused with an explanatory error — each 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 keep resource subscriptions through subscriptions/listen, which holds the request open.

--stateless=false restores the session-based transport: Mcp-Session-Id is issued on initialize, GET opens the standalone SSE stream, DELETE terminates the session, and --session-timeout governs idle lifetime. It is a compatibility mode for clients that cannot negotiate protocol 2026-07-28 (they negotiate 2025-11-25 or older and use synchronous elicitation), the server logs a warning at startup when it is enabled, and it is intended to be removed once client ecosystems have migrated. The process keeps a bounded number of these sessions across every credential and refuses an initialize past it; see Stateful sessions kept at once.

Every cacheable result carries SEP-2549 hints. Almost everything is private, because catalogs and resource content are filtered by the caller’s token scopes and licensing tier and must never be served from a shared cache. The prompt catalog is the exception: its prompts are compiled into the binary and no tier, token or surface setting alters one, so marking it private would cost every client a round trip for a body that could have been shared.

The freshness window depends on how the licensing tier was resolved. A detected tier can change under a running server, so the tool catalog gets a shorter window; --tier or GITLAB_MCP_TIER pins it and lifts that to an hour.

ResultcacheScopettlMs, tier detectedttlMs, tier pinned
prompts/listpublic3600000 (1 hour)3600000 (1 hour)
resources/list, resources/templates/listprivate3600000 (1 hour)3600000 (1 hour)
tools/list, server/discoverprivate300000 (5 minutes)3600000 (1 hour)
resources/read of gitlab://toolsprivate300000 (5 minutes)3600000 (1 hour)
resources/read of a workflow guideprivate3600000 (1 hour)3600000 (1 hour)
resources/read of live GitLab dataprivate0 (always fresh)0 (always fresh)

Client aborts are always propagated into handler contexts, so an abandoned POST cancels its in-flight GitLab API calls, client-go’s retries included. That holds on every protocol version and in both stateless and stateful mode: a POST is the whole lifetime of the calls it carries, since no event store is configured and a response whose POST has ended has nowhere left to be written. The SDK’s own propagation covers protocol 2026-07-28 only, and an older client has no notifications/cancelled to send, so the server binds each call to the POST that carried it instead.

On the dynamic tool surface, the action property of gitlab_execute_action carries the SEP-2243 x-mcp-header annotation with the value Action — the SDK prefixes it to form the wire header Mcp-Param-Action — so MCP-aware gateways can route, rate-limit, and observe calls by canonical action ID without parsing the JSON-RPC body.

The core of HTTP mode is a bounded LRU pool of per-credential entries, keyed by the SHA-256 hash of each client’s token and GitLab URL. An entry holds what the credential decides: its GitLab client, its rate-limit bucket, its resource watchers and its sessions. The MCP server and its registered tool catalog are built once per configuration and shared by every credential whose configuration matches, because nothing a server holds depends on the credential; the credential travels with each request instead.

HTTP Mode Architecture

Client A
Token: glpat-aaa
URL: gitlab.com

StreamableHTTPHandler

Client B
Token: glpat-bbb
URL: gitlab.com

Client C
Token: glpat-aaa
URL: self-hosted.example.com

Credential Pool

hash(glpat-aaa + gitlab.com)
GitLab Client + credential state

hash(glpat-bbb + gitlab.com)
GitLab Client + credential state

hash(glpat-aaa + self-hosted)
GitLab Client + credential state

MCP Server
one per configuration

GitLab API
as user A @ gitlab.com

GitLab API
as user B @ gitlab.com

GitLab API
as user A @ self-hosted

Key properties:

  • Clients with the same token and same GitLab URL share the same pool entry
  • Clients with different tokens or different GitLab URLs get completely isolated entries: no credential can observe another’s data, its watch state, or the existence of its traffic
  • The MCP server is shared per configuration, and every request runs under the GitLab client of its own entry. A request that cannot be attributed to a credential is refused rather than served from a default
  • Pool lookups use only SHA-256 hashes of token+URL; the entry’s GitLab client keeps the credential it authenticates with for as long as the entry lives, since it cannot call GitLab without it, and nothing is written to disk
  • When the pool reaches --max-http-clients, the least recently used entry that is not serving a subscription is evicted, and only when every entry is serving one does the oldest of those go
  1. First request: Token and GitLab URL are extracted, combined and hashed, and a pool entry with its own GitLab client is created. The MCP server is built too if this is the first credential of its configuration; every later one finds it ready
  2. Subsequent requests: The existing entry is found and promoted in the LRU list
  3. Idle timeout: After --session-timeout of inactivity, the MCP session is closed (but the pool entry remains). A request still carrying its ID is answered 404, and a client that follows the 2025-11-25 transport then starts a new session; the Go SDK client (v1.8.0) does not, so its application has to reconnect
  4. Pool eviction: When capacity is reached, the oldest evictable entry is removed entirely, and its client is told rather than left silent: its watchers stop, its open subscriptions/listen requests are completed with a result naming the ending (credential_evicted for capacity, credential_revoked for a token GitLab refused, and five more), and under --stateless=false the sessions that no stream ended are terminated, after which a client that follows the 2025-11-25 transport re-initializes on its next request (the Go SDK client, v1.8.0, does not, and its application has to reconnect)
  5. A call GitLab refuses with 401: a 401 that names the token (the invalid_token code, or any 401 from GraphQL) removes the entry at once, ending its streams with credential_revoked. A plain 401 is not enough on its own, because GitLab also answers some missing permissions that way, such as approving a merge request you opened: the server first asks GET /api/v4/user, at most once every 30 seconds per credential, and keeps the entry, its subscriptions and its sessions when GitLab still accepts the token

--max-http-clients decides how often any of that happens. Set it above the simultaneous client population and an arriving credential evicts nobody; frequent eviction means the number is below the population.

An entry is busy while its credential holds an open subscriptions/listen stream or at least one resource watcher, and both are bounded across the process: 512 open streams and 512 concurrent watchers, neither configurable. At most 1024 entries can be busy at once, so a pool of 1025 or more cannot be made entirely busy and the fallback that takes a subscriber’s entry never fires. That costs about 50 MiB of tenancy at the measured 50 KiB per entry, against an upper bound of 10000 for the flag.

The default stateless transport needs less: a session-era resources/subscribe is refused there unless it arrived through a listen, so a watcher cannot exist without a stream and 513 entries already make the fallback unreachable.

Two independent layers govern connection and session lifetime:

  • MCP session (--session-timeout, default 30m): idle lifetime of the MCP session at the SDK transport level.
  • HTTP idle connection (--http-idle-timeout, default 0 = disabled): the maximum time the underlying http.Server waits for the next request on a keep-alive connection before closing it.
  • HTTP response write (fixed 60s, disabled for SSE): bounds how long a response may take to write.

The server speaks the modern Streamable HTTP transport, which uses Server-Sent Events (text/event-stream) for streamed responses and for the standalone stream that carries server-initiated notifications. Both are silent for long stretches by design. An active SSE response is bounded by WriteTimeout (not IdleTimeout, which only limits the wait between requests on an idle connection), and the go-sdk SSE writer never resets the write deadline.

To avoid severing those streams without weakening protection elsewhere, the global WriteTimeout stays at a safe 60s (guarding standard endpoints like /health from slow-write attacks) and any response the server actually answers as text/event-stream — both the standalone GET stream and streamed POST responses — disables its own write deadline dynamically and carries X-Accel-Buffering: no. The decision comes from the response, not from the request’s Accept header: a client sending */* or text/* is answered with a stream too. Because --http-idle-timeout defaults to 0 (disabled), the HTTP layer also does not close idle connections out of the box. Under --stateless=false that makes --session-timeout the effective idle lifetime; under the default stateless transport there is no MCP session to expire — each POST’s session ends with its response — so nothing above the transport bounds a connection at all. Set a low --http-idle-timeout only if you want idle connections recycled sooner.

An SSE response that stays silent emits a keep-alive every 25 seconds: a comment frame (a line beginning with :), which a conforming SSE reader discards without producing an event. Clearing the write deadline settles this end of the connection and nothing in between, and nginx closes an idle upstream response at proxy_read_timeout — 60 seconds by default — so the heartbeat is what holds the stream open. A stream that has written recently is skipped rather than padded. This is the server’s own behaviour: the go-sdk emits no periodic ping of its own.

HTTP mode includes a per-credential token-bucket rate limiter that throttles every call that reaches GitLab: tools/call, resources/read, resources/subscribe, subscriptions/listen and prompts/get draw on one bucket. It is on by default in HTTP mode (--rate-limit-rps=10; set 0 to disable) because an HTTP deployment is shared: every call it forwards is charged to its own egress address, so one looping client’s volume lands on every other tenant. Stdio leaves it off, having no co-tenant to protect. The bucket belongs to the pool entry, keyed by (token + GitLab URL), and not to the MCP server, which credentials of the same configuration share: two tenants on one server are limited separately, and one tenant’s two clients are limited together.

tools/list is throttled as well, on a bucket of its own refilled a tenth as fast as that one, and it is the exception that proves the rule: it reaches no GitLab, and what it spends is the processor every tenant of the process is waiting for. One listing on the individual surface marshals about 3.2 MB, which is the majority of that surface’s processor time, so a client listing in a loop starves its co-tenants while a bucket that counts calls to GitLab sees nothing. Its burst is --rate-limit-burst, undivided: the refill is what bounds a loop, while the burst is what a fleet of clients sharing one credential spends when they all reconnect at once, and dividing that too would make discovery the tightest budget in the deployment. Separate buckets are also what keep discovery working: a client that has drained its tool-call budget can still ask what tools exist.

A listing is charged first to one more bucket, the one the whole process shares, and in the tools it carries rather than as one request: 3000 tools a second with 48000 in hand. A credential’s bucket multiplies by however many tokens a caller can mint and the processor does not, so the process’s bucket is what bounds it, and it is not configurable, for the reason the listen and watcher ceilings are not: an operator who can raise the shared number can undo the bound. Counted in tools it comes to between half a core and a core and a half of listing on every surface, since a listed tool costs between 0.16 and 0.5 ms of processor whatever is served, the full meta schema the dearest: some three listings a second on individual, sixty to ninety on meta and fifteen hundred on dynamic, and it holds one credential’s whole default listing burst on the largest surface, so at the defaults it never refuses a single credential what that credential’s own bucket allows. What it protects is the processor, and with it every other request the server answers, not anybody’s listings: it promises no caller a share, so while one tenant keeps it spent another tenant’s listings are refused too, at once and with the advice to retry. It is off with the rest of the limiter when --rate-limit-rps is 0. Its refusal is the credential’s own, the same -42900 in the same words, so a caller is not told that other callers are listing; the log line it writes says scope process, and names its figures limit_tools_per_second and burst_tools.

FlagDefaultMeaning
--rate-limit-rps10Sustained refill rate, in requests per second. 0 disables the limiter
--rate-limit-burst40Maximum bucket capacity (peak burst over 1s)

When --rate-limit-rps > 0, each pool entry gets its own token bucket sized at --rate-limit-burst tokens, refilled at --rate-limit-rps per second. Every call that reaches GitLab consumes a token: tools/call, resources/read, resources/subscribe, subscriptions/listen and prompts/get. tools/list consumes a token from its own bucket instead, holding --rate-limit-burst tokens like the first but refilled at a tenth of --rate-limit-rps, so a listing is answered even on --rate-limit-burst=1 and a fleet on one credential can all discover at once; before that it takes the tools it lists from the bucket the whole process shares, and hands them back if its own bucket refuses it. One token is one request, so a catalog split over several pages would cost one each; this server keeps its whole catalog in one page. resources/list, prompts/list, initialize and the other RPCs the server answers from its own catalog are not rate-limited, nor are the listings the server makes against itself at startup. A refused tool call is a tool result with isError: true; a refused resource, prompt or listing request is a JSON-RPC error with code -42900, the code that mirrors HTTP 429.

A refused tool call comes back as a CallToolResult with IsError: true and a text message such as rate limit exceeded for <tool>; retry after a short backoff. That shape is deliberate: a model is in the loop, it reads the message, and the agent can back off (exponentially or by detecting that message) and retry.

Every other metered method returns a JSON-RPC error with code -42900 and the same sentence, because their results carry no error flag: resources/read, resources/subscribe, subscriptions/listen, prompts/get and tools/list. No model sees those. The client’s own plumbing has to survive them, and a refused tools/list is the one to plan for, since a client that treats a failed listing as fatal will not retry on its own. Give a shared deployment enough burst for its whole population to connect at once, and keep discovery off any retry loop.

The limiter never returns HTTP 429, in either shape, because the limit is enforced after JSON-RPC routing, inside the MCP layer.

The MCP specification’s one mandatory limit is “Rate limit tool invocations” (server/tools, 2026-07-28), with no unit, value or refusal shape. HTTP mode meets it out of the box with the bucket above: counted in requests, one per pool entry, 10 a second with 40 in hand. stdio leaves the same limiter off by default, since one process serving one person has no co-tenant to protect, and switches it on with GITLAB_MCP_RATE_LIMIT_RPS, since stdio ignores --rate-limit-rps and says so at startup, naming the variable instead. Security sets out both halves of that position (issue 959).

  • Single-user deployment (typical local dev): --rate-limit-rps=0 is a reasonable opt-out; stdio already defaults to it
  • Shared instance behind a proxy (Kubernetes, nginx, Cloudflare): start with --rate-limit-rps=10 --rate-limit-burst=40. Each token+URL pair gets its own quota, so this protects against a single noisy client without affecting others. Discovery on those figures is one listing a second with forty in hand
  • A gateway or a fleet on one shared credential: the whole population draws on one set of buckets, discovery included, so size --rate-limit-burst for the number of clients that reconnect together after a restart or a deploy. Each of them lists once as it connects
  • Large multi-tenant deployment: combine with infra-level rate limiting (Cloudflare, Caddy, nginx). The MCP-level limiter is a safety net, not a replacement for edge enforcement

The rate limit bounds how often a credential calls, not how many of its calls are still running. A tools/call holds its POST for as long as the call runs, up to an hour for a pipeline wait and for as long as GitLab keeps it waiting otherwise, and each held call costs the process two file descriptors, six goroutines and about 190 KiB of memory. Measured without a bound, 4000 held calls took 8010 descriptors and 873 MiB, and a process whose hard descriptor limit was 1024 held about five hundred and then stopped accepting connections, /health among them.

So the process holds at most as many calls at once, across every credential, as its descriptor limit leaves room for, and no flag moves the figure. It reads the limit at startup, leaves an eighth of it spare, reserves one descriptor for each of the 512 listen streams and gives each held call two: 192 under a hard limit of 1024, 1536 under 4096, 229120 under the 524288 a systemd service gets by default. The hard limit is the one that counts, because the Go runtime raises the soft limit to it before the server starts; Windows, which has no such limit to read, gets the figure for 1024. The startup line announces it as held_requests_per_process.

  • What counts: every call that reaches GitLab (tools/call, resources/read, resources/subscribe, prompts/get, completion/complete), while it runs. Each call of a JSON-RPC batch counts on its own. A subscriptions/listen does not, on any revision, since the listen ceilings already count it; neither does a notification, nor a client’s answer to a request of the server’s own, such as an elicitation, so a full ceiling never keeps that answer from the call waiting on it. Under --stateless=false each session the process keeps holds one slot for its standalone stream, the GET a stateful client holds open, from the POST that opens the session until the session ends, so the GET itself takes none.
  • Refusal: every refusal says This server is busy. Retry later. and costs its credential no rate-limit token. On protocol 2026-07-28 or later the call, and on any revision a POST that would open a stateful session with no slot left for its stream, is refused before the MCP handler reads it, with 503, JSON-RPC -50300, Retry-After: 30 and the connection closed; on an older revision it is refused the way the rate limit refuses the same method, a tools/call as a result flagged with isError. The log line request refused: too many requests held across the process names scope process and limit_held_requests.
  • Measured: under a hard limit of 1024, with 4000 calls offered at once from one credential or from a hundred, the process held 192 in 394 descriptors, refused the rest and answered /health in a millisecond; with a limit of 1048576 it held all 4000.
  • What it costs you: it promises no caller a share, and one credential can fill it. No per-credential ceiling stands beside it, by decision (issue 951): a credential is a key a caller can mint, so such a number would multiply with every token a caller mints. Where the limit is small, one credential that fills it, or a fleet of clients waiting on pipelines, has the next call from anybody refused until a held call ends; raise the hard descriptor limit or run more replicas behind a balancer. Where the limit is large, memory runs out first, at about 190 KiB a held call. The server keeps no memory cap of its own, also by decision: the memory limit the process runs under is what bounds it, so set one: a memory limit on the container, or MemoryMax on the systemd unit, since a unit without a MemoryMax of its own is bounded by the slices above it where one of them sets one, and otherwise only by the host; systemctl show -p EffectiveMemoryMax <unit> reports the tightest of those limits on systemd 256 and later, and prints nothing on earlier versions. GOMEMLIMIT makes the Go runtime collect harder as the heap nears it but refuses nothing, so it does not replace that limit.
  • What it does not bound: idle connections a client keeps open between requests. --http-idle-timeout closes those.

Under --stateless=false the server keeps every session a client opens until the client deletes it, the pool evicts its credential or it has sat idle for --session-timeout, and initialize spends no rate-limit token. An idle session holds no connection and costs four goroutines (one of them gives its slots back when it ends), 10 to 20 KiB of live heap and 88 to 110 KiB of resident memory, measured at 500 to 4000 sessions; its standalone stream, the GET a stateful client keeps open, costs a descriptor and three goroutines more, seven in all. Measured without a bound under a hard descriptor limit of 1024, the process kept every session it was offered until those streams had taken all 1024 descriptors, at about a thousand sessions, and then answered nothing, /health included.

So the process keeps at most half as many sessions as it may hold calls: 96 under a hard limit of 1024, 114560 under 524288. Each session takes a held-call slot for its standalone stream when it opens and keeps it until it ends, so the sessions take at most half of the held-call slots, a call on an open session is still served when every session slot is taken, and a session the process keeps is never refused its stream, which a client refused it would not ask for again. The startup line of a deployment that keeps sessions announces the figure as stateful_sessions_per_process, and no flag moves it. The default stateless transport keeps no session, so nothing is counted there.

  • What counts: every POST without an Mcp-Session-Id on a revision before 2026-07-28, which is what opens a session. The session keeps its slots until it ends, however it ends; a POST whose session did not survive it gives them back as it returns. A POST on 2026-07-28 or later takes none: the stateful transport answers it with the revisions it serves, so the client falls back.
  • Refusal: before the session exists, in the held-call ceiling’s words: 503, JSON-RPC -50300, Retry-After: 30, the connection closed and This server is busy. Retry later., costing no rate-limit token. The log line request refused: too many stateful sessions across the process names scope process and limit_stateful_sessions; an initialize refused for want of a held-call slot for its stream logs the held-call ceiling’s line instead.
  • Measured: under a hard limit of 1024, with 4000 sessions offered with their streams, from one credential or from a hundred, the process kept 96 in 112 and 183 descriptors, refused the rest and kept answering /health; with the limit inherited it kept all 4000.
  • What it bounds: descriptors, and memory only where the limit is small. The 114560 sessions a hard limit of 524288 allows would take about ten to twelve GiB idle, so on such a host the memory limit the process runs under (a container’s, or a systemd unit’s MemoryMax) bounds them first. No fixed cap stands beside the derived figure, by decision (issue 951): that memory limit already bounds that memory, and a fixed cap no flag moves would be sized for one host.
  • What it costs you: filling it costs a caller nothing, since initialize spends no rate-limit token and an idle session holds no connection. One credential can open every session, and a session nobody deletes then holds its slot for --session-timeout, half an hour by default and a day at most, while every other tenant’s initialize is refused; with --session-timeout=0 it holds it until the pool evicts its credential, and startup warns about that. No per-credential ceiling stands beside it and initialize stays unmetered, by decision (issue 951), for the held-call ceiling’s reason: a per-credential number, or a price in the opener’s own rate, would multiply with every token a caller mints. Have clients delete their sessions, shorten --session-timeout, raise the hard descriptor limit, or move clients to the stateless transport.

Add to .vscode/mcp.json:

{
"servers": {
"gitlab": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": {
"PRIVATE-TOKEN": "glpat-your-token"
}
}
}
}

The project publishes a multi-arch Docker image at ghcr.io/jmrplens/gitlab-mcp-server for linux/amd64 and linux/arm64. The image runs as a non-root user (UID 10001), exposes port 8080, ships a built-in /health endpoint for orchestrators, and serves HTTP whenever it is started without a stdin to speak on, which is every way of running it as a service.

Terminal window
docker run -d \
--name gitlab-mcp \
--read-only \
--tmpfs /tmp:rw,size=64m \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
-p 8080:8080 \
ghcr.io/jmrplens/gitlab-mcp-server:latest \
--http \
--http-addr=0.0.0.0:8080 \
--gitlab-url=https://gitlab.com
services:
gitlab-mcp:
image: ghcr.io/jmrplens/gitlab-mcp-server:latest
ports:
- "8080:8080"
command:
# Single instance mode (fixed GitLab.com URL for all clients; replace for self-managed GitLab):
- "--http"
- "--gitlab-url=https://gitlab.com"
- "--http-addr=:8080"
- "--max-http-clients=200"
- "--session-timeout=1h"
# Or several instances: comma-separate them in --gitlab-url; clients then select one with the GITLAB-URL header
# Security hardening (least privilege, OWASP Docker security)
read_only: true
tmpfs:
- /tmp:rw,size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "gitlab-mcp-server", "--probe"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped

Start the service:

Terminal window
docker compose up -d

The image follows OWASP Docker Top 10 guidance:

PropertyValue
Base imagealpine:3.24 (minimal, regularly patched)
Userappuser (UID 10001, non-root)
FilesystemRead-only with writable tmpfs for /tmp
CapabilitiesAll dropped (--cap-drop=ALL)
Privilege escalationDisabled (no-new-privileges:true)
Build flags-buildmode=pie (PIE binary whose build information records the release version, so the image SBOM names it)
OCI labelsorg.opencontainers.image.* populated with version, commit, source URL

Pull a newer image tag and restart the container. There is no in-place update to consider: the server never replaces its own binary, so the image tag is the version, and a running container keeps serving what it started with until you restart it on a newer one.

A ready-to-use instance of this server runs at https://mcp.jmrp.io/gitlab — nothing to install, no account beyond your own GitLab token. It is the fastest way to try the server; running it locally (stdio, Docker) remains the right way to keep using it, because on a hosted endpoint your token and every request traverse someone else’s machine.

{
"mcpServers": {
"gitlab": {
"type": "http",
"url": "https://mcp.jmrp.io/gitlab",
"headers": { "Authorization": "Bearer glpat-xxxxxxxxxxxx" }
}
}
}
  • Authorization (optional) — Bearer <token>. The endpoint runs in OAuth mode, so a client that speaks the OAuth flow needs no header at all and discovers the authorization server from the 401 challenge. A GitLab personal access token also works, sent as Bearer glpat-..., verified exactly like an OAuth one. It travels with each request and is never stored server-side. A read_api token is accepted and served a read-only tool surface.
  • PRIVATE-TOKEN — the legacy-mode header, not accepted here.
  • GITLAB-URL — ignored: this deployment fixes the instance to https://gitlab.com.

The endpoint runs in stateless streamable HTTP mode: POST is the transport and an authenticated GET answers 405 by design; with no credential, any method answers 401 carrying the RFC 6750 challenge an OAuth client follows — a bare curl that gets 401 is the endpoint working, not failing. https://mcp.jmrp.io/gitlab/health needs no credential and answers 200 with {"status":"ok",…}. The tool surface is the default dynamic one — two tools, gitlab_find_action and gitlab_execute_action.

It is one of the servers listed at mcp.jmrp.io, a directory of the MCP servers maintained by this author, each reachable at its own endpoint; https://mcp.jmrp.io/servers.json is the same list for automated clients.

Size the host from the HTTP server’s resident set, not from the binary, and size it per credential calling at once, which is not the same as per token on the books. These figures come from the resource benchmark, which measures the real binary on both transports and states the machine it ran on: an Intel i5-14400 with 16 logical CPUs and 62 GiB of RAM, kernel 6.12, Go 1.27.1. They are re-measured on that host for every release.

ModeResident setNotes
HTTP, idle, no credentials35 to 38 MiBThe process holds no tool catalog until a credential asks for one
HTTP, twenty credentials, all calling257 to 1073 MiBMeasured by the concurrency series, one process per surface
HTTP, a hundred credentials, all calling0.4 to 1.7 GiBThe same series, at its hundred-credential step
HTTP, per additional pooled credential30 to 53 KiBSettled live heap, the credential held rather than calling
HTTP, per additional calling credential0.81 to 4.27 MiBPeak resident set, with two to four requests in flight throughout
stdio, one process per client106 to 277 MiBIt starts building its catalog at once, so it has no idle state
Binary on disk~55 MBSingle static binary, no runtime dependencies

Three things follow from this.

Memory tracks concurrent work, not the number of tokens. The pool holds one entry per distinct token and GitLab URL, but an entry is a GitLab client and its bookkeeping, not a catalog: the MCP server and its registered surface are built once per configuration and shared by every credential whose configuration matches. The benchmark’s concurrency series measures the tenancy at every step out to a thousand credentials and puts it at 50.6 KiB each on dynamic, 52.6 on meta and 29.8 on individual, so a thousand admitted credentials hold under 120 MiB of live heap on any surface. The point scenarios say the same thing from the other end, admitting 64 credentials one at a time: 0.15, 0.09 and -0.53 MiB per extra credential, which is nothing above the noise of a resident-set reading. What grows is the requests in flight.

--max-http-clients is not a memory setting. At about 50 KiB per entry, its default of 100 bounds five mebibytes; sizing an instance against it is wrong in both directions, since it neither reserves that memory nor limits what the callers behind those credentials allocate while their requests are served. What it does bound is how many live GitLab clients and watchers the process holds, and --pool-idle-timeout (default 1h) decides how long an unused one is kept. --session-timeout is a different thing again, despite sounding like it: it bounds an idle MCP session, it applies to --stateless=false only (under the default stateless transport a session ends with the response to its own POST), and ending a session does not release the pooled entry behind it.

The tool surface changes responses more than memory. All three build the same canonical action catalog, and since the server is shared per configuration the surface says almost nothing about what one more credential costs: 51, 53 and 30 KiB on dynamic, meta and individual, across surfaces whose registered tool counts differ by a factor of five hundred. Under load the surfaces do separate, and by tool count still not at all: meta is the cheapest per calling credential at 0.81 MiB against 1.92 and 4.27, because what is left once the catalog is shared is what a surface allocates while it answers a call. What the surface really decides is the size of a tools/list: 12 KB, 599 KB and 3.2 MB for dynamic, meta and individual. Pick the surface for token cost and response time, and pick the instance size for concurrency.

Absolute figures vary by platform and Go runtime version — treat these as a starting point, and run make bench-resources to measure your own.

GET /health needs no credentials and answers 200 with a JSON body:

Terminal window
curl -s http://localhost:8080/health
{
"status": "ok",
"version": "2.8.0",
"commit": "a6561ff7",
"build": "2.8.0+a6561ff",
"config_digest": "9f2a7c41e0b3",
"started_at": "2026-08-22T09:14:03Z",
"uptime_seconds": 1209600
}

started_at is the process start instant (RFC 3339, UTC) and uptime_seconds is whole seconds since then. Both are reported because they do different jobs: started_at is byte-identical across probes, so a monitor can cache it and spot a restart by noticing it moved, while uptime_seconds is the value you read at a glance.

build is the one label to display: the release the build is closest to plus the short commit it was built from, with .dirty when the tree had uncommitted changes, so a release binary and a build from main report the same shape where version alone gives one a plain number and the other a Go pseudo-version. config_digest is twelve hex characters over the settings that decide what a client sees (tool surface, capability surface, meta parameter schema, tier and whether it was pinned or is detected per credential, scope detection, read-only, safe mode and the excluded tools): every instance behind one balancer must report the same one, or one of them serves a different catalog and nothing else detects that. It is a fingerprint for comparison, not a secret: the settings it covers are few and public, so whoever can read it can also work out which combination produced it.

Once shutdown has been requested the same endpoint answers 503 with "status": "draining" and Cache-Control: no-store. By default the listener closes right after, so set --drain-delay (GITLAB_MCP_DRAIN_DELAY) to at least one probe interval when a balancer polls /health: the listener then stays open that long answering 503, the balancer removes the instance, and only then do the in-flight requests get their drain.

/health reports only that the process is up — it performs no GitLab round-trip. To verify end-to-end connectivity for a specific token, call an authenticated MCP method:

Terminal window
curl -s -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "PRIVATE-TOKEN: glpat-your-token" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | head -c 200

A successful response returns a JSON-RPC result with the list of available tools.

Two documents, and neither needs a credential. They are not the same document.

GET /server-card answers the SEP-2127 Server Card: who this server is and how to connect to it. Identity (name, version, description, title, websiteUrl, repository) and, when the deployment names a --public-url, a remotes entry with the credential header a client must send. It carries no tools, resources, prompts or capabilities, which is that extension’s own decision: what a server exposes varies by authenticated user, session, configuration and feature flags, so a static document cannot answer it.

Terminal window
curl -s http://localhost:8080/server-card
curl -s http://localhost:8080/mcp/server-card

The response carries Content-Type: application/mcp-server-card+json. The extension reserves <streamable-http-url>/server-card, a suffix on the endpoint’s URL rather than on the host, and the MCP endpoint answers at the root and at /mcp, so the card answers at /server-card and at /mcp/server-card. A client configured with https://mcp.example.com/mcp finds it at https://mcp.example.com/mcp/server-card, and <--public-url>/server-card, the URL a client derives from the card’s own remotes[0].url, is always one of the paths served, as long as --public-url carries no query string: appending the suffix to one extends the query rather than the path, so publish the endpoint without one, as RFC 8707 advises for a resource identifier.

GET /.well-known/mcp/server-card.json answers the earlier SEP-1649 document, which does enumerate: every tool, resource, resource template and prompt this deployment registers, with its schemas, plus the capabilities it advertises and blocks describing authentication, subscriptions and telemetry. It is served as application/json, and it is what to fetch when you want that list without a credential.

Terminal window
curl -s http://localhost:8080/.well-known/mcp/server-card.json

Every one of these paths is mounted under --public-url’s path prefix as well, for a proxy that forwards its prefix rather than stripping it: with --public-url=https://mcp.example.com/gitlab the SEP-2127 card answers at /server-card, /gitlab/server-card, /mcp/server-card and /gitlab/mcp/server-card, and the SEP-1649 document and the health check at /gitlab/.well-known/mcp/server-card.json and /gitlab/health beside their root paths. A --public-url whose path is exactly /mcp names the endpoint rather than a prefix, so nothing is mounted under it: the card answers at /mcp/server-card because the endpoint answers at /mcp, while /health and /.well-known/mcp/server-card.json stay at the root only, and a proxy that publishes nothing but /mcp has to route those two to the server’s root paths if it exposes them. A path that only ends in /mcp, such as /gitlab/mcp, is a prefix like any other. Before 3.1.0 both paths answered the enumerating document and differed only in Content-Type, which left the older shape at the location SEP-2127 reserves, and the card was at /server-card alone, so an endpoint published as https://mcp.example.com/mcp answered 404 at the card URL a client derives from it.

The enumerating document is the sanctioned way to tell something holding no credential what the server can do: a directory, a scanner, a documentation build. tools/list stays authenticated, because the MCP authorization specification requires a server that requires authorization to validate the token before processing a request.

Both cards and the RFC 9728 document at /.well-known/oauth-protected-resource/<path of --public-url> answer the same bytes until the process restarts, and all three say so twice: Cache-Control: public, max-age=3600 is how long a client may reuse a copy without asking, and the ETag is what it sends back afterwards to ask whether that copy is still current. The cards are built once at startup; the RFC 9728 document is serialized per request from a value fixed at startup, and its tag is computed from the bytes that request produced.

Terminal window
curl -sI -H 'If-None-Match: "<the etag>"' \
http://localhost:8080/.well-known/mcp/server-card.json
# HTTP/1.1 304 Not Modified

It matters most for the enumerating document, which is around 137 KB on the default surface: a scanner polling it hourly downloads all of that every time without a validator, once per replica.

A CDN can cache the cards and the RFC 9728 document on the strength of those headers. It must not cache /mcp, which is a credentialed POST carrying Cache-Control: no-store, nor /health, whose body changes on every probe.

A client doing domain-level discovery starts at https://<host>/.well-known/ai-catalog.json, an AI Catalog listing what the host publishes, and follows the entries of type application/mcp-server-card+json to their server cards, as the server-card extension describes.

The binary does not serve a catalog, and that is deliberate. GET /.well-known/ai-catalog.json answers the same unauthenticated 404 as any other path it does not serve. A catalog describes everything a host publishes, which only whoever runs the host knows: this server is often one entry among several, behind a prefix, on a host that serves other things too. The catalog belongs to the deployment, and the proxy in front of the server is the natural place to serve it from.

The entry for this server is three members:

{
"specVersion": "1.0",
"entries": [
{
"identifier": "urn:air:example.com:mcp:gitlab",
"type": "application/mcp-server-card+json",
"url": "https://mcp.example.com/gitlab/server-card"
}
]
}
  • url is <--public-url>/server-card, the same URL a client derives from the card’s own remotes[0].url, and the server answers it with the card’s own media type.
  • identifier follows the urn:air:{publisher}:{namespace}:{name} form the catalog specification requires for open systems, where {publisher} is “the domain name of the organization publishing the artifact”. The artifact this entry lists is your deployment’s card at its URL, which you publish, so the publisher is your domain rather than this project’s. The server-card extension’s own example derives it from the card’s name instead (com.example/weather becomes urn:air:example.com:mcp:weather); for this card that would name the project, which publishes the software and not your deployment of it, and two deployments following it would list the same identifier for two different endpoints.
  • No displayName and no description. The card carries title and description itself, and the catalog specification says an entry pointing at an artifact that names itself should omit both: a copy in the catalog drifts out of step with the card at the next upgrade, and when present it wins over the card’s.

Serve the file from the proxy under its own media type. With nginx that needs types { } as well as default_type: nginx picks the type from the file’s extension first, mime.types maps .json to application/json, and default_type alone is never reached.

location = /.well-known/ai-catalog.json {
alias /etc/nginx/ai-catalog.json;
types { }
default_type application/ai-catalog+json;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=3600" always;
}

This is the one location where the proxy answers CORS itself, because it is the one document the proxy serves rather than forwards; every forwarded path gets its CORS headers from the server alone. The catalog may also live on another domain: an entry’s url can name a card on any host.

Frequently asked questions

When should I use HTTP mode instead of stdio?

Use stdio mode for a single developer running a local AI client, where each client spawns its own server process. Use HTTP mode when a team shares one server instance, for remote or headless server deployments, for CI/CD integration with MCP, and for testing with curl or HTTP clients. In HTTP mode a single server process serves multiple clients over the network, each authenticating with its own GitLab token.

How do clients authenticate in HTTP mode?

Clients send their GitLab Personal Access Token on every request using the PRIVATE-TOKEN header (recommended) or an Authorization: Bearer header; if both are present in legacy mode, PRIVATE-TOKEN wins (OAuth mode reads only the Bearer token). A server started with --allow-any-gitlab-url and no instance takes the target instance from a GITLAB-URL header, and one publishing several requires that header to choose among them. For production, --auth-mode=oauth enables RFC 9728–compliant OAuth 2.1 with PKCE, so clients discover the authorization server and authorize in the browser instead of copying tokens.

Do HTTP mode clients share state or context?

No. HTTP mode uses a bounded LRU pool of per-credential entries keyed by the SHA-256 hash of each client's token and GitLab URL. Clients with the same token and same GitLab URL share one entry, while different tokens or different URLs get completely isolated entries: the GitLab client, the rate-limit bucket, the resource watchers and the session ownership are all per entry. The MCP server itself is shared by every credential whose configuration matches, since what it holds is decided by configuration and not by the credential, and every request runs under the client its own entry carries. Pool lookups use only the SHA-256 hashes, each entry's GitLab client keeps the credential it authenticates with while the entry lives, and when the pool reaches --max-http-clients the least recently used entry that is not serving a subscription is evicted.

Why do my MCP sessions drop or streams get cut off?

Two independent layers govern lifetime: the MCP session idle timeout (--session-timeout, default 30m) and the HTTP idle connection timeout (--http-idle-timeout, default 0 = disabled). Because --http-idle-timeout defaults to 0, the HTTP layer does not close idle connections, so --session-timeout is the effective idle lifetime. If sessions drop early, a low --http-idle-timeout or a reverse-proxy read/idle timeout is usually closing long-lived SSE streams; raise the proxy timeout for long-running MCP streams.