Security
GitLab MCP Server never persists your GitLab token. In stdio mode it reads the token from an environment variable at startup, holds it only in memory for the life of the process, and sends it only to your GitLab instance — over TLS when GITLAB_URL points at an https:// endpoint (the default) — never to any third party. Read-only mode and safe mode (dry-run previews) add optional guardrails, and every release ships with checksums and signatures for integrity verification. This page details that security model, credential handling, and best practices for safe deployment.
Security model overview
Section titled “Security model overview”Key principles
Section titled “Key principles”- Token isolation: In stdio mode, the GitLab token never leaves the local server process. It is loaded from the environment and used exclusively for GitLab API calls.
- No token forwarding: The token is never sent to the MCP client and never included in tool outputs.
- Process-level isolation: The server runs as a local process communicating via stdin/stdout. No network ports are opened in stdio mode.
- Minimal privilege: The server only needs a GitLab token with scopes required for the operations you intend to use.
Token management
Section titled “Token management”Recommended: Environment file
Section titled “Recommended: Environment file”Store your token in ~/.gitlab-mcp-server.env with restricted permissions:
# Create the home env fileecho 'GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx' > ~/.gitlab-mcp-server.env# Add GITLAB_URL here only for self-managed instances.
# Restrict permissions (owner read/write only)chmod 600 ~/.gitlab-mcp-server.envTo keep the file elsewhere, name it by absolute path in GITLAB_MCP_ENV_FILE.
VS Code input variables
Section titled “VS Code input variables”For VS Code users, you can use input variables to avoid storing tokens in plain text:
{ "servers": { "gitlab": { "type": "stdio", "command": "gitlab-mcp-server", "env": { "GITLAB_TOKEN": "${input:gitlabToken}" } } }}The token is prompted at startup and kept only in memory.
Token scopes
Section titled “Token scopes”Use the minimum required scopes for your workflow:
| Scope | Required For |
|---|---|
read_api | Read-only operations (list, get, search) |
api | Full operations (create, update, delete) |
read_repository | Repository file access |
write_repository | Repository file modifications |
Scope-based tool filtering
Section titled “Scope-based tool filtering”On startup, the server detects your token’s scopes via the GitLab API and automatically disables tools that require scopes your token does not have. For example, a token without admin_mode scope will not see gitlab_admin tools.
This prevents the AI from attempting operations that would fail with permission errors and keeps the tool list focused on what your token can actually do.
To disable scope detection and register all tools regardless of token permissions:
GITLAB_MCP_IGNORE_SCOPES=trueOr in HTTP mode:
./gitlab-mcp-server --http --ignore-scopesTLS verification
Section titled “TLS verification”By default, the server verifies TLS certificates when connecting to GitLab. For self-signed certificates:
GITLAB_MCP_SKIP_TLS_VERIFY=trueIn HTTP mode, --auth-mode=oauth refuses --skip-tls-verify for a non-loopback instance: bearer tokens are forwarded to that instance on every call, and an unverified certificate would let any host answering its address collect them. Install the CA in the system trust store, or point SSL_CERT_FILE at a CA bundle, instead.
Read-only mode
Section titled “Read-only mode”Enable read-only mode to prevent any mutating operations:
GITLAB_MCP_READ_ONLY=trueIn read-only mode:
- All write tools are not registered (create, update, delete, merge, etc.)
- Only read operations are available (list, get, search)
- This provides a hard guarantee at the server level — the LLM cannot accidentally modify data
This is useful for:
- Exploration and discovery workflows
- Demo environments
- Environments where the token has write access but you want to restrict the server
Safe mode
Section titled “Safe mode”Enable safe mode to preview mutating operations without executing them:
GITLAB_MCP_SAFE_MODE=trueIn safe mode:
- Mutating tools return a structured JSON preview showing tool name, parameters, and annotations
- Read-only tools execute normally
- If
GITLAB_MCP_READ_ONLY=trueis also set, it takes precedence (mutating tools are fully disabled)
This is useful for dry-run workflows, training environments, and debugging tool parameters.
HTTP mode security
Section titled “HTTP mode security”When running in HTTP mode (--http), additional security considerations apply:
Per-request authentication
Section titled “Per-request authentication”In HTTP mode, GitLab tokens are provided per-request via headers, not environment variables. Each user session uses its own token:
Authorization: Bearer <gitlab-personal-access-token>Session isolation
Section titled “Session isolation”The server maintains a bounded LRU pool of per-credential entries:
- Each token and GitLab URL pair gets its own isolated entry: its GitLab client, its rate-limit bucket, its resource watchers and its sessions. The MCP server and its tool catalog are shared by every credential of the same configuration, since neither depends on the credential, and every request runs under the client its own entry carries
- Credentials are independent — one user cannot access another’s context, watch state, or the existence of their traffic
- Idle sessions expire after
--session-timeout(default: 30 minutes) under--stateless=false; the default stateless transport ends each POST’s session with its response - Pooled entries are bounded by
--max-http-clients(default: 100), which caps token+URL entries rather than sessions or concurrent requests. Under that bound eviction prefers an entry that is not serving a subscription, and takes a busy one only when every pooled entry is busy; the evicted credential is told rather than left silent
HTTP mode recommendations
Section titled “HTTP mode recommendations”- Terminate TLS on a reverse proxy, or on the server itself with
--tls-cert/--tls-key; when the proxy shares the machine,--http-addr=/run/…/server.sockremoves the hop rather than encrypting it - Configure
--trusted-proxy-headerto match the header your proxy sets (e.g.CF-Connecting-IP,X-Real-IP,X-Forwarded-For) together with--trusted-proxies, the addresses or CIDR ranges the proxy connects from, so the authentication-failure limiter (ten failures per address per minute, answered with429) charges real client addresses rather than the proxy’s; the per-call rate limiter is keyed by token and needs no address. The header is believed only on a connection from a listed address; from any other peer it is ignored and the peer itself is charged, so a client that reaches the listener directly cannot choose the address its failures count against. One flag without the other refuses startup. ForX-Forwarded-For, the server reads from the right, skipping hops that are themselves listed, and charges the first that is not; a hop that is not an address charges the peer. - Enable rate limiting at the proxy level
- Restrict access to trusted networks
- Monitor session metrics for unusual patterns
OAuth mode
Section titled “OAuth mode”For production HTTP deployments, consider using OAuth mode (--auth-mode=oauth). It enables RFC 9728–compliant OAuth 2.1 authentication:
- Users authorize through the browser — no manual token distribution
- OAuth 2.1 with PKCE protects against authorization code interception
- Token identity is cached for
--oauth-cache-ttl(default: 15 minutes, range: 1m–2h), keyed by a SHA-256 hash — raw tokens are never stored - Granted scopes are introspected from GitLab rather than assumed
- OAuth mode is Bearer-only:
PRIVATE-TOKENis rejected with401. Clients without OAuth support send a personal access token asAuthorization: Bearer <glpat-...>, which is verified the same way - Admission asks only for
read_api, the least any action needs; whether a call may write is settled per action, against the surface built for that token. Aread_apitoken is therefore accepted by a deployment that can write, and is served the read-only tool surface — the only credential refused at the door is one carrying no GitLab API scope at all. The challenge andscopes_supportedname the one scope that buys the full surface (api, orread_apiunder--read-only/--safe-mode), never both: a client asks GitLab for every scope listed, and GitLab refuses a request naming a scope the OAuth application does not have. A client that wants a credential which cannot mutate anything namesread_apiitself - Rejections are cheap and bounded: ten authentication failures from one address in a minute earn a
429, and a token GitLab has already refused is refused from memory for five minutes rather than asked about again. Neither an outage nor a429from GitLab is ever cached — those say nothing about the credential - A throttled or unreachable GitLab answers
503withRetry-Afterand no challenge, not401. Reporting it as an invalid token would make a well-behaved client discard a good credential and start a fresh authorization flow, adding upstream load exactly when the instance asked for less
See docs/guides/oauth-app-setup.md for creating the required GitLab OAuth Application, and HTTP Server Mode for full configuration details.
Cross-origin protection
Section titled “Cross-origin protection”Non-safe requests (POST, DELETE) made by a browser from another origin are refused with 403 before authentication, satisfying the 2026-07-28 transport requirement to validate Origin against DNS rebinding. Non-browser clients — every CLI, IDE and SDK — send neither Origin nor Sec-Fetch-Site and are unaffected, and safe methods (server card, /health, OAuth metadata) are exempt.
To allow browser clients from specific origins, list them explicitly:
gitlab-mcp-server --http --trusted-origins=https://mcp.example.comAn allowlist is validation: every origin not on it is still refused. A bare IP works for local deployments, * accepts any origin (disabling the protection — only sensible on a trusted network or behind a same-origin proxy), and the --public-url origin is trusted automatically. A malformed entry fails startup.
If a reverse proxy in front already advertises CORS on the server’s behalf — the shape most deployments started with — that block has to come out in the same change. Two Access-Control-Allow-Origin headers is a CORS failure, not a merge: curl reports 200 and a browser refuses the response, saying the header “contains multiple values … but only one is allowed”. Keeping both leaves the endpoint worse off than before, because the proxy’s lone * at least worked for requests without credentials.
Allowing the origin is only half of what a browser needs. Before it sends a cross-origin POST carrying Authorization, it sends a preflight OPTIONS with no credentials — which OAuth mode used to refuse with 401, so the real request never happened. A preflight from a trusted origin is now answered 204 with the CORS headers, and the response exposes Mcp-Session-Id and Mcp-Protocol-Version so a browser can actually read them. The origin is echoed rather than answered with *, because a browser rejects the wildcard on a credentialed request.
Verifying release integrity
Section titled “Verifying release integrity”Every GitHub Release ships with three integrity artifacts:
checksums.txt— SHA-256 hashes for every binary in the releasechecksums.txt.sigstore.json— keyless Cosign / Sigstore signature bundle (GitHub OIDC, no key distribution required)<asset>.sbom.json— an SPDX software bill of materials for each binary
Nothing verifies these for you: the server never downloads or replaces its own binary, so whoever puts a binary on the machine is the one who checks it. Package managers do their own verification (Homebrew pins a checksummed formula, npm and the container registry pin digests). For a binary you download yourself, verify both the signature and the checksum before running it.
1. Install Cosign
Section titled “1. Install Cosign”Follow the official installation guide. Quick install:
# macOSbrew install cosign
# Linux (binary release)curl -L https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 -o cosignchmod +x cosign && sudo mv cosign /usr/local/bin/2. Download release artifacts
Section titled “2. Download release artifacts”From the Releases page, download:
- The binary for your platform (e.g.
gitlab-mcp-server-linux-amd64) checksums.txtchecksums.txt.sigstore.json
3. Verify the signature
Section titled “3. Verify the signature”cosign verify-blob \ --bundle checksums.txt.sigstore.json \ --certificate-identity-regexp "^https://github.com/jmrplens/gitlab-mcp-server/" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ checksums.txtA successful verification prints Verified OK. The --certificate-identity-regexp constraint ensures the signature was produced by a GitHub Actions workflow running in this repository, and --certificate-oidc-issuer pins the identity to GitHub’s official OIDC issuer.
4. Verify the binary checksum
Section titled “4. Verify the binary checksum”After the signature verification succeeds, validate that your binary matches the signed checksum:
# Linuxsha256sum --check --ignore-missing checksums.txt
# macOS (shasum has no --ignore-missing; filter the relevant line first)grep "$(ls gitlab-mcp-server-*)" checksums.txt | shasum -a 256 -cExpected output: gitlab-mcp-server-linux-amd64: OK (or the corresponding filename for your platform).
5. Verify build provenance (optional)
Section titled “5. Verify build provenance (optional)”Every release artifact carries a SLSA provenance attestation stored by GitHub, tying the file to the workflow run that produced it:
gh attestation verify gitlab-mcp-server-linux-amd64 -R jmrplens/gitlab-mcp-serverThis is independent of the Cosign signature: the signature says the checksums came from this repository’s release pipeline, the attestation says which workflow run built this exact file.
6. Verify the container image
Section titled “6. Verify the container image”The image is published to two registries, and the same index is pushed to both, so the digest is identical and either reference verifies the same artifact:
# Signature: who pushed this index. Either registry, same answer.cosign verify ghcr.io/jmrplens/gitlab-mcp-server:3.0.0 \ --certificate-identity-regexp "^https://github.com/jmrplens/gitlab-mcp-server/" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
cosign verify docker.io/jmrplens/gitlab-mcp-server:3.0.0 \ --certificate-identity-regexp "^https://github.com/jmrplens/gitlab-mcp-server/" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
# Build provenance: which commit and which workflow run produced itgh attestation verify oci://ghcr.io/jmrplens/gitlab-mcp-server:3.0.0 -R jmrplens/gitlab-mcp-servergh attestation verify oci://docker.io/jmrplens/gitlab-mcp-server:3.0.0 -R jmrplens/gitlab-mcp-serverThe two commands answer different questions and neither substitutes for the other. The signature binds the index to the release workflow’s identity; its predicate is empty, so it says who pushed the image and not what went into it. The provenance attestation names the source commit and the workflow run.
The image also carries an SBOM attestation over the index and over each platform manifest, so a scanner that resolves a tag and one that resolves the platform it runs both find a document. The predicate type is the unversioned https://spdx.dev/Document, which is the spelling scanners match on:
# SBOM: what is inside the image. A tag resolves to the index, which carries one.gh attestation verify oci://ghcr.io/jmrplens/gitlab-mcp-server:3.0.0 -R jmrplens/gitlab-mcp-server \ --predicate-type https://spdx.dev/Document
# Or by the digest of the platform manifest you actually rundigest=$(docker buildx imagetools inspect ghcr.io/jmrplens/gitlab-mcp-server:3.0.0 \ --format '{{range .Manifest.Manifests}}{{if and .Platform (eq .Platform.Architecture "amd64")}}{{.Digest}}{{end}}{{end}}')gh attestation verify "oci://ghcr.io/jmrplens/gitlab-mcp-server@${digest}" -R jmrplens/gitlab-mcp-server \ --predicate-type https://spdx.dev/DocumentEach of those documents is attached a second time as a bare application/spdx+json referrer, because what the attestation writes is a sigstore bundle and a reader that looks for the SPDX media type itself does not match one. Both are listed side by side:
oras discover --format tree ghcr.io/jmrplens/gitlab-mcp-server:3.0.0Does the token appear in logs?
Section titled “Does the token appear in logs?”The server does not log the token. Tool-call logging is structured and writes a fixed set of fields to stderr — the tool name, the call duration, the error when one occurs, and, when the request carries an authenticated identity, the GitLab username and user ID for audit purposes. The token is not one of those fields, and it is sent to GitLab as a request header rather than in a URL, so it does not appear in logged request paths.
Two caveats worth stating plainly. First, logs at any level contain the GitLab URL, project paths, and resource identifiers you operate on, and GITLAB_MCP_LOG_LEVEL=debug adds more of that detail — treat them as you would any other operational log. Second, the server cannot control what your MCP client records in its own transcript. If you find a credential in server output, please report it through the channel below.
Reporting a vulnerability
Section titled “Reporting a vulnerability”Report security issues privately through GitHub Security Advisories, which keeps the report confidential until a coordinated fix is published. Do not open a public issue for a security vulnerability.
A useful report includes the affected version (gitlab-mcp-server --version), the transport in use (stdio or HTTP), steps to reproduce, and the impact you believe it has. If GitHub Security Advisories is unavailable to you, contact the maintainer privately on GitHub (@jmrplens) rather than through a public channel. The full policy, including supported versions and preferred languages, is in SECURITY.md.
Best practices checklist
Section titled “Best practices checklist”Token security
Section titled “Token security”- ☐ Use a dedicated GitLab token with minimum required scopes
- ☐ Store tokens in
~/.gitlab-mcp-server.envwithchmod 600permissions - ☐ Keep any file holding a token out of version control
- ☐ Rotate tokens periodically
- ☐ Use
read_apiscope when write access is not needed
Server configuration
Section titled “Server configuration”- ☐ Enable
GITLAB_MCP_READ_ONLY=truefor read-only workflows - ☐ Keep TLS verification enabled (
GITLAB_MCP_SKIP_TLS_VERIFYunset orfalse) - ☐ Use stdio transport when possible (no network exposure)
- ☐ Keep the server binary updated through whichever channel installed it
- ☐ Verify Cosign/Sigstore signature on first manual install (instructions above)
- ☐ Schema lockdown: all tool input schemas enforce
additionalProperties: falseto reject unexpected fields
HTTP mode
Section titled “HTTP mode”- ☐ Terminate TLS — reverse proxy,
--tls-cert/--tls-key, or a unix socket to a same-host proxy - ☐ Configure
--trusted-proxy-headerand--trusted-proxiesso authentication failures are charged to real client addresses - ☐ Configure appropriate
--session-timeoutand--max-http-clients - ☐ Enable rate limiting
- ☐ Restrict network access to trusted clients
Monitoring
Section titled “Monitoring”- ☐ Review server logs regularly
- ☐ Monitor for unusual API call patterns
- ☐ Check for token expiration or permission changes
- ☐ Enable
GITLAB_MCP_LOG_LEVEL=infofor production audit trails
Frequently asked questions
What is the difference between read-only mode and safe mode?
Read-only mode (GITLAB_MCP_READ_ONLY=true) does not register any mutating tools, so only list, get, and search operations are available — a hard guarantee at the server level. Safe mode (GITLAB_MCP_SAFE_MODE=true) still registers mutating tools but intercepts them and returns a structured JSON preview of the tool name, parameters, and annotations instead of executing. If both are set, read-only takes precedence and mutating tools are fully disabled.
How does GitLab MCP Server protect my token in stdio mode?
In stdio mode the GitLab token never leaves the local server process. It is loaded from the environment and used exclusively for GitLab API calls — never sent to the MCP client and never included in tool outputs. The server runs as a local process communicating over stdin/stdout, so no network ports are opened, and it only needs a token with the scopes required for the operations you intend to use.
What token scopes should I use?
Use the minimum scopes for your workflow: read_api for read-only operations, api for full create/update/delete operations, and read_repository or write_repository for repository file access or modification. If you only need read operations, use read_api: on startup the server detects your token's scopes, serves only the read actions to a token that cannot write, and disables tools that require scopes the token lacks. GITLAB_MCP_READ_ONLY=true keeps a token that could write read-only as well, for defense-in-depth.
How do I verify the integrity of a downloaded binary?
Every GitHub Release ships checksums.txt (SHA-256 hashes) and checksums.txt.sigstore.json (a keyless Cosign/Sigstore signature bundle using GitHub OIDC). Verification is yours to run, since the server never downloads a binary for you: run cosign verify-blob with the bundle, pinning the certificate identity to this repository and the OIDC issuer to GitHub, then validate the binary hash against the signed checksum. If verification fails, do not run the binary.
External references
Section titled “External references”- GitLab personal access token scopes — choosing least-privilege token scopes
- OAuth 2.0 Protected Resource Metadata (RFC 9728) — the standard behind HTTP OAuth mode
- Sigstore / Cosign documentation — keyless signature verification for release binaries