Skip to content

Troubleshooting

GITLAB_TOKEN required

Invalid GITLAB_URL

401 Unauthorized

403 Forbidden

Connection refused

No

Yes

Yes

Yes

No

No

Server won't start

Error message?

Set GITLAB_TOKEN
in env or ~/.gitlab-mcp-server.env

Fix self-managed
GITLAB_URL

Regenerate token
with api scope

Check token has
api scope

Is GitLab reachable?

Check GITLAB_URL
and network

TLS error?

Can install CA cert?

Add CA cert to system
trust store

Set GITLAB_MCP_SKIP_TLS_VERIFY=true
as last resort

Enable debug logging
GITLAB_MCP_LOG_LEVEL=debug

SymptomCauseSolution
GITLAB_TOKEN is required at startupToken not setSet GITLAB_TOKEN in the environment or in ~/.gitlab-mcp-server.env; a working-directory .env is not read, and one that exists is named at WARN on startup
GITLAB_URL is not a valid URL at startupURL has invalid syntaxFix GITLAB_URL or omit it to use https://gitlab.com in stdio mode
stdio exits 1: a flag withholding part of what this server serves was passed to a stdio serverThe client’s args carry --read-only, --safe-mode or --exclude-tools, which only HTTP mode readsMove the setting to the client’s env block under the variable the set_instead field names (GITLAB_MCP_READ_ONLY=true and the like) and remove the flag; the flag is refused even when that variable is already set
stdio exits 1: --gitlab-url names no instance this stdio server connects toThe client’s args carry --gitlab-url, which only HTTP mode reads, and GITLAB_URL names another instance or none, which means https://gitlab.comSet GITLAB_URL to the instance in the client’s env block and remove the flag, so the token goes where the configuration says
stdio: every tools/list, tools/call, resource and prompt request fails with JSON-RPC -40300, GitLab accepted the token this server was started with, which carries neither the read_api nor the api scopeThe token in GITLAB_TOKEN is genuine and below the minimum: it carries read_user alone, or only scopes outside the API such as read_repository or self_rotate. The server keeps answering the handshake and logs the verdict once at ERROR, with the token’s scopes where GitLab described themCreate a token with read_api, or api to write as well, and restart the server with it: a token’s scopes cannot be changed after it is created. GITLAB_MCP_IGNORE_SCOPES does not lift the minimum
HTTP, legacy mode: 403 Forbidden, JSON-RPC -40300, GitLab accepted this token, which carries neither the read_api nor the api scopeThe same token sent to a server in HTTP legacy mode; with --auth-mode=oauth it is refused 403 with an insufficient_scope challenge insteadSend a token with read_api or api. The refusal is not charged to the address’s failure budget and is remembered for five minutes, so retrying the same token changes nothing
stdio or HTTP exits 1: ... is no longer read (removed in 3.1.0) ... will not be started under a capability it did not ask forThe environment, or a dotenv file the server loads, still sets GITLAB_READ_ONLY, GITLAB_SAFE_MODE or EXCLUDE_TOOLS, spellings 3.1.0 stopped reading, so ignoring one would serve what it withheldRename it to the variable the line names (GITLAB_MCP_EXCLUDE_TOOLS and the like). When a bare EXCLUDE_TOOLS belongs to another tool, remove it from the environment this server starts in (env -u EXCLUDE_TOOLS): set to empty, it is still set and still refused
401 Unauthorized from GitLab APIInvalid or expired PAT, or a permission refusal GitLab answers with 401If other calls with the same token work, check the role the action needs (approving your own merge request, merging without push access); otherwise generate a new token with api scope under your avatar > Edit profile > Access > Personal access tokens on the instance
Tool calls fail after connecting, with authentication failed: GitLab rejected the token (GITLAB_TOKEN) itself as invalid, expired, revoked or without the api or read_api scope, so renew or replace itThe token expired or was revoked after the server admitted it: GitLab’s 401 named the credential itself rather than a missing permissionCheck the token’s expiry date and that it carries api, or read_api for read-only use, then replace it: restart a stdio server with the new GITLAB_TOKEN, or send the new token from the HTTP client
403 Forbidden on specific operationsThe token lacks a scope the operation needs, your role in the project or group is too low (some operations need Maintainer or Owner), the instance’s settings restrict the feature, or a fine-grained token’s grant lacks the permission the operation needsA write needs a token with api, not just read_api; otherwise ask for the role the operation needs. For a fine-grained token, see Fine-grained tokens
Connection refused or timeoutGitLab instance unreachableVerify GITLAB_URL is reachable: curl -s $GITLAB_URL/api/v4/version
SymptomCauseSolution
x509: certificate signed by unknown authoritySelf-signed certificateFirst try adding the CA certificate to your system trust store. If that’s not possible, set GITLAB_MCP_SKIP_TLS_VERIFY=true in the environment or in ~/.gitlab-mcp-server.env, or --skip-tls-verify in HTTP mode; --auth-mode=oauth refuses it for a non-loopback instance, so install the CA (or point SSL_CERT_FILE at a bundle) there
x509: certificate has expiredExpired TLS certificateRenew the certificate on the GitLab server. GITLAB_MCP_SKIP_TLS_VERIFY=true works around it temporarily; --auth-mode=oauth accepts it only for a loopback instance

If the server cannot resolve your GitLab hostname:

Terminal window
# Verify DNS from the machine running the MCP server
nslookup gitlab.example.com
# or
dig gitlab.example.com +short

In Docker containers, ensure your compose file or docker run command uses --dns or a custom network with proper DNS configuration. Inside Kubernetes, check CoreDNS logs and resolv.conf in the pod.

Go’s net/http honours the standard proxy environment variables. Set them before launching the server:

Terminal window
export HTTPS_PROXY=http://proxy.corp.example.com:8080
export HTTP_PROXY=http://proxy.corp.example.com:8080
export NO_PROXY=localhost,127.0.0.1,.internal.corp
SymptomCauseSolution
Connection timeout behind corporate networkProxy not configuredSet HTTPS_PROXY / HTTP_PROXY
Proxy works for curl but not for the serverEnv vars not exported into the server processPass them in the client’s env block, ~/.gitlab-mcp-server.env, Docker environment:, or your platform’s secrets
Internal GitLab routed through external proxyNO_PROXY missingAdd your GitLab hostname to NO_PROXY

When running the MCP server behind nginx, Caddy, or a cloud load balancer:

SymptomCauseSolution
429 after a few failed logins from different peopleThe authentication-failure limit (10 per address per minute) sees the proxy’s address, not the client’sSet --trusted-proxy-header to the header your proxy sets (e.g. X-Forwarded-For, CF-Connecting-IP) and --trusted-proxies to the proxy’s address; the per-call rate limiter is keyed by token and unaffected
SSE stream disconnectedProxy read or idle timeout shorter than the stream’s silenceRaise the proxy read timeout; the server’s 25-second SSE keep-alive holds an idle stream open across nginx’s 60-second default, so a tighter edge is what cuts it
502 Bad GatewayServer not listening yetAdd a startup probe or retry; the server needs a few seconds to initialize on first request
SymptomCauseSolution
Only two tools appear: gitlab_find_action and gitlab_execute_actionExpected: dynamic is the default surfaceNot a failure: those two reach the whole catalog. gitlab_find_action returns the matching canonical domain.action ID with its exact schema, and gitlab_execute_action runs it, so ask for what you want in plain language. Set GITLAB_MCP_TOOL_SURFACE=meta or GITLAB_MCP_TOOL_SURFACE=individual if you want a browsable tool list. See Dynamic toolset
MCP client shows hundreds of individual tools instead of 34Individual surface selectedSet GITLAB_MCP_TOOL_SURFACE=meta to consolidate into domain meta-tools: 34 on Free, and the Premium and Ultimate counts are in Meta-tools
Tool not found in tools/listTool surface mismatch: each surface names its tools differentlyCheck GITLAB_MCP_TOOL_SURFACE. dynamic, the default, registers only gitlab_find_action and gitlab_execute_action; meta registers domain meta-tools, so creating an issue is gitlab_issue with action: create; individual registers one tool per operation, such as gitlab_issue_create. It is the only selector: META_TOOLS, under either spelling, was removed in 3.0.0. See Configuration
unknown action in meta-tool callThe action value names no action of that meta-toolThe error lists the valid ones (gitlab_issue: unknown action "x". Valid actions: ... on the meta surface): pick one of those, or see Meta-tools
json: unknown field "<name>" from a meta-toolMisspelled or stale parameter in paramsMeta-tools decode params strictly and reject unknown keys. Use the exact parameter names for the chosen action (e.g. merge_request_iid, issue_iid, epic_iid, work_item_iid, snippet_id); Meta-tools shows where to read them
Reads work but every create, update and delete action is missingThe token carries read_api and not api, so the server serves it a read-only surface: once at startup on stdio, per token in HTTP modeWorking as designed, and --read-only was not set. Use a token with api, or in OAuth mode reauthorize with api, for the full surface. The log line token cannot write; serving a read-only tool surface for it confirms it, and on the dynamic surface gitlab_execute_action answers a write with ... exists but is not available to this session: the credential in use does not carry a GitLab scope that covers it .... In HTTP mode another client’s api token is unaffected
Enterprise tools missingEnterprise catalog disabledIn stdio mode, set GITLAB_MCP_TIER=premium or GITLAB_MCP_TIER=ultimate. In HTTP mode, set --tier=premium/--tier=ultimate to force the catalog or let detection enable it when the instance license, or a namespace the token administers, is on a Premium or Ultimate plan. GITLAB_ENTERPRISE was removed in 3.0.0 and is ignored; there is no --enterprise flag, so use --tier
SymptomCauseSolution
the @jmrp.io/gitlab-mcp-server-linux-x64 package is not installedOn Alpine this is deliberate: the linux packages declare libc: glibc and the released binaries need the glibc dynamic loader, so npm skips them on musl. On a glibc distro it means the install skipped optional dependencies (--no-optional, or a lockfile resolved on another OS)On musl, use the container image ghcr.io/jmrplens/gitlab-mcp-server (built against musl) or build from source — the publish is not broken. On glibc, reinstall without --no-optional, or delete node_modules and the lockfile and install again
Updates never arrive under npxThe server never updates itself, and npm owns the binaryExpected. Upgrade with npm, or add @latest to the npx spec so it resolves afresh instead of reusing its cache

The server never updates itself, so an upgrade is whatever your install channel does plus a restart.

SymptomCauseSolution
Still running the old version after upgradingThe old process was never restartedRestart the server, or run gitlab-mcp-server --shutdown to terminate every running instance first
Cannot replace the binary (file locked)Running instances hold the file openRun gitlab-mcp-server --shutdown to terminate every instance, then replace it
SymptomCauseSolution
No output from the serverThe client is not sending JSON-RPC on the server’s stdinCheck that the client is configured for the stdio transport and sends initialize as its first message. The server writes JSON-RPC to stdout and its logs to stderr
The server exits immediatelyIts stdin was closedThe server ends when the client closes the pipe, so make sure the client keeps it open
VS Code waits on initialize and the Docker logs show starting MCP server in HTTP modeThe container got no stdin, so the image, whose command is --transport auto, inferred HTTP. An image published before --transport auto became its command serves HTTP whatever stdin it is givenAdd -i to the docker run arguments so the container gets a pipe to speak on and infers stdio (JSON-RPC over stdin and stdout, no port needed), and run docker pull ghcr.io/jmrplens/gitlab-mcp-server:latest if the image predates that release. To stay on HTTP instead, give the container an instance to serve (docker run --rm -p 8080:8080 -e GITLAB_URL=https://gitlab.com ghcr.io/jmrplens/gitlab-mcp-server:latest, since HTTP mode exits with --gitlab-url is required in HTTP mode when none is named) and configure the client as HTTP pointing at http://host:8080/mcp, which works only while that container runs and its port is reachable

A stdio server that refuses to start (a flag only HTTP mode reads, a retired variable) and one started with a token below the read_api minimum are under Connection and authentication.

SymptomCauseSolution
401 Unauthorized with WWW-Authenticate: BearerMissing or empty token headerSend PRIVATE-TOKEN or Authorization: Bearer <token> header with every request; in legacy mode the JSON body names both accepted headers, and --auth-mode=oauth reads only Authorization: Bearer
401 Unauthorized: GitLab rejected this token. Check that it is valid, unexpired, and issued by the target instance.GitLab refused the token when the server checked it with GET /api/v4/user before building a session for itCheck that the token is valid, unexpired, and issued by the instance the request names. The check runs when the server builds a session for a token, not on every request, and the refusal is charged to the address’s failure budget
400 Bad Request with no server available in plain textA build older than 2.6.6 received a request with no token, an unparseable GITLAB-URL header, or a backend it could not reachNot a crash and not emitted by this project: it comes from the MCP SDK (go-sdk/mcp/streamable.go) when server selection finds no server. Upgrade: those cases are now 401, 400, 429 and 503 with a JSON-RPC body
400 Bad Request naming GITLAB-URLThe per-request GITLAB-URL header is not a parseable URLSend an absolute URL such as https://gitlab.example.com, or omit the header when the server runs with --gitlab-url
400/403 saying the deployment does not serve that GitLab instance--gitlab-url names more than one instance, so it publishes an allow-listSend a GITLAB-URL the server publishes; omitting the header is refused too (400), never resolved to the first instance. In OAuth mode the error names the published instances (the same list its RFC 9728 metadata already serves unauthenticated) and answers 403 before the token is used anywhere. Legacy mode answers 400 and does not name them: it publishes no metadata document and reaches this rejection before the credential is validated, so ask the operator
429 Too Many Requests with Retry-After: Too many failed authentication attempts from this address. Retry later with a valid token.10 failed authentications from one address within a minute, or 50 distinct refused credentials within 10 minutes (the defaults), for a credential this deployment does not already holdWait out the Retry-After and retry with a valid token. A token the server already holds (a pooled session in legacy mode, a cached identity with a pooled session in OAuth mode) keeps working from a blocked address, so a sprayer behind a shared address does not cut off its neighbours; what stays refused is every credential the server does not already hold. Behind a reverse proxy, set --trusted-proxy-header and --trusted-proxies so the limit counts real client IPs instead of the proxy’s
503 Service Unavailable: Could not initialize a GitLab session for this token. The instance may be unreachable; retry shortly.The server could not build a session for the token: no credential-check slot came free within five seconds (16 run at once across the process), the server was shutting down, or building the session failed. An instance that does not answer is not the cause: the token is then admitted, and the tool calls themselves report the instance unreachableRetry shortly; the refusal is not charged to the address’s failure budget. The log line failed to create server for token carries the error, credential verification is saturated, retry shortly when no slot came free. When tool calls report GitLab server is unreachable (connection refused), check GITLAB-URL / --gitlab-url and the network path from the server
“This server is busy. Retry later.” (a 503 with Retry-After, or a tool error)Every call slot the descriptor limit allows is held across the process, typically by calls waiting on GitLab or on a pipeline (192 under a hard limit of 1024; the startup line’s held_requests_per_process says how many)Retry after the advertised delay. The ceiling counts every credential together and no flag moves it; the log line request refused: too many requests held across the process confirms it. A deployment that needs more calls in flight at once raises its hard descriptor limit or runs more replicas behind a balancer. See HTTP Server Mode for how the ceiling is sized
“This server is busy. Retry later.” on initialize, --stateless=falseEvery stateful session slot is taken across the process, often by sessions no client deleted, which with --session-timeout=0 never expire (96 under a hard limit of 1024; the startup line’s stateful_sessions_per_process says how many)The log line request refused: too many stateful sessions across the process confirms it. Have clients delete their sessions when done, shorten --session-timeout, raise the hard descriptor limit, or move clients to the default stateless transport, which keeps no sessions. See HTTP Server Mode for how the ceiling is sized
403 Forbidden: Cross-origin request refused: the Origin header names an origin this deployment does not trust.A browser sent an Origin this deployment does not trust, or said Sec-Fetch-Site: cross-site, and the request was refused before MCP handlingAllow the origin with --trusted-origins=https://app.example.com (* accepts any origin and turns the protection off); the --public-url origin is trusted on its own. Clients that send no Origin are unaffected. See Cross-origin protection
A browser client still fails after --trusted-origins was setBuilds up to 2.7.4 refused the preflight OPTIONS, so the browser never sent the real requestUpgrade: a preflight from a trusted origin is now answered 204 with the CORS headers. On an older build, put a reverse proxy in front that answers OPTIONS itself
Pool eviction too frequentToo many unique tokensIncrease --max-http-clients (default: 100)
Sessions expiring unexpectedlyMCP idle timeout too shortIncrease --session-timeout (default: 30m); it applies to --stateless=false only, the default stateless transport has no session to expire
MCP sessions drop every ~2 min (older builds also logged keepalive ping failed; closing session; the SDK’s ping is now off for HTTP sessions)A low --http-idle-timeout (or a proxy timeout) is closing long-lived SSE streamsDefault --http-idle-timeout=0 disables HTTP-layer idle closure; if you set a low value, raise it or use 0. Behind a reverse proxy, also raise its read and idle timeouts to at least a few minutes, well above the server’s 25-second SSE keep-alive

See HTTP Server Mode for the architecture, every status code and the configuration. Every flag, with its default and its bounds, is in the CLI reference, and the variable each one falls back to is in the environment variable reference.

SymptomCauseSolution
401 Unauthorized on every request: GitLab rejected this token. Check that it is valid, unexpired, and issued by the target instance.GitLab refused the token when the server verified it with GET /api/v4/user; the challenge carries error="invalid_token"Check the token outside the server: curl -H "Authorization: Bearer $TOKEN" $GITLAB_URL/api/v4/user. An OAuth token is renewed by the client’s refresh, or by authorizing again through the OAuth flow; check that the GitLab OAuth application is still active
401 after working for a whileThe token expired or was revoked. The first call GitLab refuses ends the token’s pool entry, and from the next request the server’s own GET /api/v4/user probe of the token is refused, so the request is answered 401 although the verified identity stays cached until --oauth-cache-ttl or the token’s own expiry passesExpected with GitLab’s two-hour OAuth tokens on a client that does not refresh them: authorize again. A personal access token needs replacing. See the server’s identity cache
High latency on the first request, or the first after the cache expiresA cache miss: the token is verified against the GitLab APIExpected. Requests within --oauth-cache-ttl (default 15m, from 1m to 2h) are answered from the cache
Frequent re-verifications despite the cache--oauth-cache-ttl set low, or more than 10,000 distinct credentials in rotationRaise --oauth-cache-ttl (at most 2h). The identity cache holds at most 10,000 identities (not configurable) and, when full, drops an expired one or else the one used least recently, so near that size each new credential pushes out the one unused longest
503 with Retry-After on a new token: GitLab could not verify this token right now ... The token itself has not been rejected.GitLab throttled or could not answer the verification, or every verification slot stayed busy for five secondsThe token has not been rejected, so do not reauthorize: retry after the delay. The server log says which: token verification unavailable or token verification failed is GitLab (a persistent one means the instance is unreachable from the server), while token verification refused: every verification slot stayed busy means 16 verifications of new tokens were already running, typically a flood of invented tokens. Credentials the server already holds never wait for a slot, while the process has descriptors and memory left for the requests waiting beside them. A new one is served only if a slot frees within its five seconds, so during a flood it may take several retries, and every new token is served once the flood stops
404 on /.well-known/oauth-protected-resourceOAuth mode not enabled, or --public-url carries a pathStart the server with --auth-mode=oauth: the document is served only in OAuth mode. When --public-url has a path, the well-known segment goes between the host and that path, so --public-url=https://mcp.example.com/gitlab publishes its document at https://mcp.example.com/.well-known/oauth-protected-resource/gitlab, at the root of the host, and the bare path answers 404 on purpose
The client does not start the OAuth flowThe client does not implement RFC 9728 discovery, or it has no Application ID to authorize withConfigure the GitLab OAuth application’s Application ID as the client’s clientId (Step 4 of OAuth application). A client with no OAuth support can send a personal access token as Authorization: Bearer <glpat-...>, which is verified the same way, unless the deployment sets --oauth-client-uid
401 when sending PRIVATE-TOKEN in OAuth modeOAuth mode is Bearer-only, by designMove the token to Authorization: Bearer <token>; PRIVATE-TOKEN is accepted only in --auth-mode=legacy
403 Forbidden with error="insufficient_scope", the body naming the scopes the token carries or opening GitLab rejected this token for lacking the scope this request needsThe token is genuine but carries neither read_api nor api: read_user alone, only scopes outside the API such as read_repository, or GitLab’s own mcp scope, which a client that registered itself through Dynamic Client Registration is given. A fine-grained token without User: Read gets a challenge with the same error="insufficient_scope" and scope="read_api" but its own error_description and body, under Fine-grained tokensReauthorize with the scope the challenge’s scope parameter names, read_api, the least any action needs: a read_api token is served the read-only surface, and api the full one. A client that registered itself needs the clientId of an OAuth application with api or read_api (Dynamic client registration and the mcp scope). A personal access token cannot be reauthorized: create one with either scope. The refusal is not charged to the address’s failure budget
The same token keeps getting 401 after it was fixed in GitLabA refusal is remembered for five minutes, so replays do not reach GitLabWait for the entry to expire, or restart the server. Only definitive refusals are remembered, never a throttled or unanswered verification, and a refusal counts only against the instance that issued it

The errors GitLab returns while authorizing (redirect_uri_mismatch, invalid_client, invalid_scope, access_denied) and a 401 carrying error_uri from a deployment that sets --oauth-client-uid are in OAuth application. A 429 is the same budget as in HTTP server mode.

A fine-grained token’s grant is fixed when the token is created, so every way out of a missing permission below is a new token, never an edit. See Fine-grained Tokens for what to grant.

GitLab’s own refusals. GitLab refuses a fine-grained token in four texts, all from one service (Authz::Tokens::AuthorizeGranularScopesService at 19.4.1). Over REST the first three are the error_description of a 403 whose code is insufficient_granular_scope, and the fourth becomes GitLab’s ordinary 404 Not Found; over GraphQL a mutation outside the grant answers 200 with the field null and the text as one errors[] entry. A GraphQL mutation that declares no fine-grained permission at all is refused instead with GitLab’s generic resource-access error, The resource that you are attempting to access does not exist or you don't have permission to perform this action, which does not come from that service and is the same text a classic token gets for a missing permission. The server quotes the service’s texts and puts what each means in front of it:

GitLab’s textWhat it meansWhat to do
Access denied: This operation requires a fine-grained personal access token with the following project permissions: [Project: Read].The call needs the permissions listed, at the boundary named (project, group, user, instance or personal projects), and the token was not granted them. A classic token gets the same text under a group that requires fine-grained tokensCreate a fine-grained token that grants them, or use a classic token where the group does not refuse one
Access denied: This operation doesn't support fine-grained personal access tokens.GitLab declares no fine-grained permission for the operation, so no fine-grained token can call it on this instanceUse a classic token
Access denied: Fine-grained personal access tokens are not yet supported.Fine-grained tokens are not enabled for the token’s user (GitLab’s granular_personal_access_tokens feature flag), so every call the token makes is refusedUse a classic token, or ask the instance’s administrator to enable them
404 Not Found, as a GraphQL errors[] entry (over REST, a plain 404)What the call names was not found, or sits outside what the token may seeCheck the ID or path, and that the grant covers its project or group

A list can name a permission the token creation page does not offer: for sixteen raw permissions GitLab’s lookup prefers the label of a deprecated definition ([Webhook: Test] where the page offers Webhook: Trigger). That is read from GitLab’s source and not yet seen on a running instance (upstream-bugs row 85). The fine-grained permissions reference names the permission the page offers for every action this server runs.

The server’s own answers.

SymptomCauseSolution
HTTP mode answers 403: GitLab accepted this token and refused it the permission to read its own user.The token lacks User: Read, which the door’s GET /api/v4/user needs. It is not charged to the address’s failure budget, and the same token is answered from memory for five minutesCreate a token that also grants User: Read, or use a classic token with read_api or api
stdio logs once at WARN: GitLab refused this token GET /api/v4/version, which a fine-grained personal access token reads only when it grants Metadata: ReadThe token lacks Metadata: Read, so the server runs without the instance version and edition, and the grant is not evaluatedCreate a token that also grants Metadata: Read; meanwhile set GITLAB_MCP_TIER if the instance is licensed and the tier reads Free
A call answers action "..." exists but this fine-grained personal access token was not granted what it needs: ...The grant does not reach the action, read by the server before anything was sentCreate a token that grants the permissions the answer names, or use a classic token
A call answers action "..." exists but is not available to a fine-grained personal access token: ...No fine-grained token reaches the action at the GitLab release the answer names, or the grant was not evaluated and the answer says whyUse a classic token, or grant what the answer says the server needs to read the grant
An action you expected is missing from tools/list or from gitlab_find_actionA fine-grained session is listed what its grant reachesRead gitlab://tools/{id} of the action: its withheld block says why, and its fine_grained block what it needs
A GraphQL read answers null, not found or an empty list with no errorGitLab answers a part the grant does not reach that way; the answer carries a note saying soRead the note; a token granted what it names, or a classic token, reads the rest
On gitlab-mcp-server 3.1.0 and earlier, every write was missing for a fine-grained tokenThose releases read the token’s one scope, granular, as one that cannot write, and served the read-only surfaceUpgrade; the token is now read as unknown authority and its grant decides

A list’s pagination line and its pagination object are described field by field in Output Format.

SymptomCauseSolution
List results truncatedDefault per_page limitPass per_page (max 100) and page parameters to paginate
next_page is 0 and has_more is falseLast page reachedNo more results: this is expected behavior
A list answers with has_next_page and end_cursor instead of next_pageThe action reads GitLab over GraphQL, which pages by cursorPass end_cursor as after for the next page; first sets the page size (max 100)
total_items and total_pages are 0 on a list that has rowsGitLab sent no total for this listPage on has_more and next_page, which do not depend on the total
A search reports a total_items no larger than the pageGitLab’s search API sends no totals, so they are inferred from the page that arrived, a lower boundPage on has_more and next_page
has_more is false on a list asked for with pagination: "keyset"The continuation GitLab sends is not carried yet (issue 1165)Leave pagination at its default and page with page

A subscription that “succeeded” but never fires is often one that was refused. When no notifications arrive, check in order:

  • The server runs GITLAB_MCP_CAPABILITY_SURFACE=full, the default: minimal does not advertise resources.subscribe.
  • The URI names one object, not a collection: gitlab://project/42/pipeline/99 can be watched, gitlab://project/42/issues is refused.
  • In HTTP mode with the default --stateless=true, the legacy resources/subscribe is refused outright, and only subscriptions/listen (protocol 2026-07-28) works there.
  • On protocol 2026-07-28 a refusal may never reach the client: the Go SDK client (v1.8.0) sends subscriptions/listen without waiting for its answer.

When notifications arrive slowly, two different slowdowns look alike from outside. A settled resource, a finished pipeline or a closed issue, is polled every 60 seconds by design, four times the 15-second default. Separately, a watch whose session has had no request for 30 minutes drops to a 10-minute poll, and any tool call or resource read on that session restores full speed. The io.github.jmrplens/watch entry in each notification’s _meta reports the watch’s state and its current poll interval.

When a watch stops by itself, the answer that ends it says why. The reasons, the caps behind them and what to do about each are in Resource subscriptions.

What each part of a tool result carries, and which client reads which part, is in Output Format.

SymptomCauseSolution
Links are not clickable in the IDEThe client does not render Markdown links in tool resultsThe links are in the result’s Markdown, and a list result asks the assistant to keep them as [text](url) links when it presents the results, so ask the assistant for the link
Raw Markdown shown beside the formatted outputThe client shows both content and structuredContentA tool result marks its Markdown audience: ["assistant"], so a client that honours annotations does not show it to the user; update the client to its latest version
No next_steps in the structured resultThe action’s output type declares no next_steps field, or the action has no next step to suggestThe hints are in the Markdown under its Next steps heading, on every surface; the structured result carries next_steps only where the output type declares it
A get result shows the object twice, once as JSONThe result embeds the object’s canonical gitlab:// resource as a second content block, which some clients displaySet GITLAB_MCP_EMBEDDED_RESOURCES=false, or pass --embedded-resources=false in HTTP mode
The error message suggests no fixNot every error has a known corrective actionAn error with a known fix carries Suggestion: followed by the action to take, named by its canonical ID. See Error handling
SymptomCauseSolution
“Tool not found” in Copilot ChatThe server did not start, or the MCP configuration is wrongCheck the Output panel → MCP Logs for errors, and that .vscode/mcp.json has the right command path
Server not appearing in MCP statusThe configuration was not loadedRun Ctrl+Shift+P → MCP: List Servers to verify it, and check that the binary path is absolute and the file exists
“Permission denied” on startup (Linux/macOS)The binary is not executableRun chmod +x /path/to/gitlab-mcp-server
The token prompt does not appear${input:...} is misconfiguredPut the inputs array at the top level of mcp.json, not inside servers
Server restarts repeatedlyIt exits at startup, most often because GITLAB_TOKEN is not setCheck MCP Logs for GITLAB_TOKEN is required or another startup error
Waiting for initialize with Docker logs showing HTTP modeThe container got no stdin, so the image inferred HTTPAdd -i to the docker run arguments; the rest is under Stdio transport
  • View the server’s logs. Most MCP clients show what the server writes to stderr in a log panel. In VS Code: Ctrl+Shift+P → MCP: List Servers, select the server, then Show Output.
  • Restart after a configuration change. Restart the MCP server from the IDE; in VS Code, Ctrl+Shift+P → MCP: Restart Server.
  • The server starts but tools fail. The GitLab URL or the token is likely wrong; see Connection and authentication.

Enable verbose logging to diagnose issues:

  1. Set the log level to debug:

    Terminal window
    # Stdio mode
    GITLAB_MCP_LOG_LEVEL=debug ./gitlab-mcp-server 2>debug.log
    # HTTP mode (logs interleaved with server output)
    GITLAB_MCP_LOG_LEVEL=debug ./gitlab-mcp-server --http --gitlab-url=https://gitlab.com 2>debug.log
  2. Reproduce the issue by running the same operation that failed.

  3. Examine the logs — debug logs include:

    • Every tool call with its tool name, duration and error, never its arguments
    • Tier detection, destructive-action confirmations and fine-grained decisions
    • Token validation events (the token named by a keyed digest, never by its characters)
    • Session pool operations (HTTP mode)

    GitLab API requests and responses are not logged.

--log-level sets the same thing as GITLAB_MCP_LOG_LEVEL on either transport; the CLI reference and the environment variable reference list the other settings worth checking while you debug.

Check the GitLab connection and the token, then drive an HTTP server by hand. Start each server in one terminal and run its curl lines in another:

Terminal window
# GitLab answers, and the token works
curl -s --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "$GITLAB_URL/api/v4/version"
# HTTP mode, legacy authentication
./gitlab-mcp-server --http --http-addr=localhost:8080 --gitlab-url=$GITLAB_URL
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
# HTTP mode, OAuth (an https GITLAB_URL; http is accepted for a loopback instance only)
./gitlab-mcp-server --http --http-addr=localhost:8080 --gitlab-url=$GITLAB_URL --auth-mode=oauth --public-url=http://localhost:8080
curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq .
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $GITLAB_TOKEN" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

In OAuth mode a request that carries no credential is answered with an RFC 6750 challenge that tells a client where to go. Ask for the headers alone:

Terminal window
curl -si -X POST https://mcp.example.com/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | head -20

The WWW-Authenticate: Bearer header carries a resource_metadata URL. Fetch it as written; for a deployment started with --public-url=https://mcp.example.com/mcp it is:

Terminal window
curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp | jq .

Read the fields rather than hard-coding them: resource is the identifier this deployment is known by, authorization_servers names the GitLab instances to authorize against, scopes_supported names the one scope a client should ask for (api, or read_api on a deployment started with --read-only or --safe-mode), and resource_documentation links a page about the deployment (this project’s OAuth application page unless the operator sets --resource-documentation). A client that never fetches that document does not implement RFC 9728 discovery: send it a personal access token as Authorization: Bearer glpat-... instead, which is verified the same way. The 401 challenge explains the scope parameter the challenge carries.

The public instance at https://mcp.jmrp.io/gitlab answers exactly this way, so it is a working reference to compare a deployment against; its metadata is at https://mcp.jmrp.io/.well-known/oauth-protected-resource/gitlab.

If you cannot resolve an issue:

  1. Enable debug logging (GITLAB_MCP_LOG_LEVEL=debug) and capture the output
  2. Check the GitHub Issues for known problems
  3. Open a new issue with:
    • Server version (gitlab-mcp-server --version or check startup logs)
    • Operating system and architecture
    • MCP client name and version
    • Redacted debug logs (remove any tokens or sensitive data)
    • Steps to reproduce the issue

Frequently asked questions

Why does my MCP client show hundreds of tools?

The client is using the individual tool surface, which registers one tool per GitLab operation. Set GITLAB_MCP_TOOL_SURFACE=meta to consolidate them into 34 domain-level meta-tools, or use the default dynamic surface, which exposes only gitlab_find_action and gitlab_execute_action. Tool names also differ by surface: individual mode uses gitlab_issue_create, meta mode uses gitlab_issue with action: create, and dynamic mode uses find and execute.

How do I fix 401 or 403 errors from GitLab?

A 401 Unauthorized usually means the Personal Access Token is invalid or expired, so generate a new token with the api scope under your avatar > Edit profile > Access > Personal access tokens on your GitLab instance. Some endpoints (merge, approve, remote mirrors, access token reads) also answer a permission refusal with 401, so when the same token works for other calls, check the role the action needs instead. A 403 Forbidden on specific operations means the token lacks a scope the operation needs, your role is too low for it, or a fine-grained token's grant lacks the permission; a write needs the api scope, not just read_api. If the server will not start with GITLAB_TOKEN is required, set GITLAB_TOKEN in the client's environment or in ~/.gitlab-mcp-server.env (a .env in the working directory is not read).

Why are Enterprise tools missing?

The Enterprise/Premium catalog is disabled. In stdio mode, set GITLAB_MCP_TIER=premium or GITLAB_MCP_TIER=ultimate. In HTTP mode, set --tier=premium or --tier=ultimate to force the catalog, or let detection enable it when the instance license, or a namespace the token administers, is on a Premium or Ultimate plan. GITLAB_ENTERPRISE was removed in 3.0.0 and is ignored; there is no --enterprise flag, so use --tier. Enterprise tools also require a Premium or Ultimate license on the connected instance.

How do I enable debug logging?

Set GITLAB_MCP_LOG_LEVEL=debug and redirect stderr to a file, for example GITLAB_MCP_LOG_LEVEL=debug ./gitlab-mcp-server 2>debug.log, then reproduce the failing operation. Every tool call is logged with its tool name, duration and error, never with its arguments, and the GitLab requests and responses behind it are not logged at all. The log also carries token validation events (the token named by a keyed digest, never by its characters) and HTTP session pool operations, and debug adds detail such as tier detection, destructive-action confirmations and fine-grained decisions. Logs go to stderr and JSON-RPC messages go to stdout, so always redirect stderr to avoid mixing the two.