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 (fallback 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 to exclude from registration
--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 sessions or concurrent requests (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 (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; 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
--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)

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.

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)
  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, so its next request re-initializes

--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.

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. 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.

  • 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

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-trimpath -buildmode=pie (PIE binary, no source paths in stack traces)
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, at two paths, 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

The response carries Content-Type: application/mcp-server-card+json.

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 the catalog without a credential.

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

Both are mounted under --public-url’s path prefix as well, for a proxy that forwards its prefix rather than stripping it. 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.

This is the sanctioned way to publish the catalog to something holding no credential — 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 all three routes 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.

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.