Skip to main content
Glama
README.md
# GitLab MCP

An MCP server for GitLab — works with `gitlab.com` and with any self-hosted instance
(`https://gitlab.velocorner.ch`, `https://dev.tectiers.com`, anything else).

**85 tools** covering issues, merge requests, the repository, CI/CD, wiki, packages and
registries, plus `gitlab_api_request` — a generic escape hatch to any REST API v4 endpoint
that has no dedicated tool.

The headline feature: **the server reads attachments**. A screenshot pasted into an issue is
downloaded with authentication and handed to the model as an image — not as a link it cannot open.

---

## Two ways to run it

| Mode | Transport | Configuration comes from | Use it when |
|---|---|---|---|
| **Hosted** | Streamable HTTP on `/mcp` | request headers, per call | clients should connect to a URL with nothing installed |
| **Local** | stdio | environment variables | the instance is private, or credentials must not leave the machine |

The public deployment lives at `https://gitlab-mcp.webapace.ink` — the landing page on `/`,
the MCP endpoint on `/mcp`. Connecting to it needs no install:

```bash
claude mcp add --transport http gitlab \
  https://gitlab-mcp.webapace.ink/mcp \
  --header "X-GitLab-Url: https://gitlab.example.com" \
  --header "X-GitLab-Token: glpat-xxxxxxxxxxxx"
```

Or write the configuration yourself. The same object goes into `.mcp.json` in a repository,
`~/.claude.json`, `claude_desktop_config.json` or `.cursor/mcp.json` — fill in the blanks:

```json
{
  "mcpServers": {
    "gitlab": {
      "type": "http",
      "url": "https://gitlab-mcp.webapace.ink/mcp",
      "headers": {
        "X-GitLab-Url": "",
        "X-GitLab-Token": "",
        "X-GitLab-Project-Id": ""
      }
    }
  }
}
```

| Header | Fill in with | If left empty |
|---|---|---|
| `X-GitLab-Url` | the address as typed in the browser, `https://gitlab.example.com` | `https://gitlab.com` is assumed |
| `X-GitLab-Token` | the `glpat-…` access token | the request is refused — this one is required |
| `X-GitLab-Project-Id` | default project: `team/backend` (group included) or a numeric id | tools ask for a project each time |

`X-GitLab-Project-Lock` and `X-GitLab-Read-Only` can be added the same way; see
[Hosted mode](#hosted-mode) for the full header list.

Keep the file out of version control, or write `"X-GitLab-Token": "${GITLAB_TOKEN}"` — Claude Code
and Cursor substitute environment variables, so the secret stays in your shell.

Two things worth knowing when a change appears to do nothing:

- In Claude Code an entry added to the **local** scope (kept in `~/.claude.json`) takes precedence
  over the project's `.mcp.json`. `claude mcp list` shows what is actually in use, and
  `claude mcp remove <name> -s local` drops a stale one.
- Client configuration is read at startup, so restart the app after editing the file.

The rest of this document is for running your own copy in either mode.

---

## Install

```bash
git clone <repo> gitlab-mcp
cd gitlab-mcp
npm install
npm run build
```

Requires Node.js 20 or newer.

### Token

GitLab → Settings → Access Tokens. A personal, group or project access token all work.

| Scope | What it allows |
|---|---|
| `read_api` | everything read-only: issues, MRs, files, pipelines, attachments |
| `api` | the above plus writes: comments, commits, merge requests, issue edits |

A project access token is the safest choice when the work is confined to one project.

---

## Configuration

In stdio mode everything is configured through environment variables.

| Variable | Required | Default | Description |
|---|---|---|---|
| `GITLAB_URL` | yes | `https://gitlab.com` | Instance URL. A trailing `/api/v4` is accepted and stripped |
| `GITLAB_TOKEN` | yes | — | Access token |
| `GITLAB_PROJECT_ID` | no | — | Default project: `42` or `group/subgroup/project` |
| `GITLAB_PROJECT_LOCK` | no | `false` | `true` — hard isolation inside `GITLAB_PROJECT_ID` |
| `GITLAB_READ_ONLY` | no | `false` | `true` — every mutating tool is refused |
| `GITLAB_AUTH_TYPE` | no | `pat` | `oauth` — send the token as `Authorization: Bearer` |
| `GITLAB_TLS_REJECT_UNAUTHORIZED` | no | `true` | `false` — skip TLS verification (private CA) |
| `GITLAB_TIMEOUT_MS` | no | `60000` | Per-request timeout |
| `GITLAB_MAX_ATTACHMENT_BYTES` | no | `8388608` | Size cap for downloaded attachments |

Aliases that are also read: `GITLAB_API_URL` / `CI_SERVER_URL` for the URL,
`GITLAB_PERSONAL_ACCESS_TOKEN` / `GITLAB_ACCESS_TOKEN` for the token.

### Connecting from Claude Code

```bash
claude mcp add gitlab \
  --env GITLAB_URL=https://gitlab.velocorner.ch \
  --env GITLAB_TOKEN=glpat-xxxxxxxxxxxx \
  -- node /absolute/path/gitlab-mcp/dist/index.js
```

Or drop a `.mcp.json` into the root of your working repository, so the configuration travels
with the project:

```json
{
  "mcpServers": {
    "gitlab": {
      "command": "node",
      "args": ["/absolute/path/gitlab-mcp/dist/index.js"],
      "env": {
        "GITLAB_URL": "https://dev.tectiers.com",
        "GITLAB_TOKEN": "glpat-xxxxxxxxxxxx",
        "GITLAB_PROJECT_ID": "team/backend",
        "GITLAB_PROJECT_LOCK": "true"
      }
    }
  }
}
```

The same block works in Claude Desktop (`claude_desktop_config.json`) and Cursor (`.cursor/mcp.json`).

Several instances at once — just several entries with different names:

```json
{
  "mcpServers": {
    "gitlab-com":  { "command": "node", "args": ["…/dist/index.js"], "env": { "GITLAB_URL": "https://gitlab.com",            "GITLAB_TOKEN": "glpat-…" } },
    "gitlab-work": { "command": "node", "args": ["…/dist/index.js"], "env": { "GITLAB_URL": "https://gitlab.velocorner.ch", "GITLAB_TOKEN": "glpat-…" } }
  }
}
```

To verify the connection, ask for `gitlab_whoami` — it returns the authenticated user, the
instance URL and the GitLab version.

---

## Hosted mode

Start the HTTP transport with `MCP_TRANSPORT=http` (or `--http`). It serves:

| Route | Purpose |
|---|---|
| `GET /` | the landing page (`landing.html`, or `LANDING_PATH`) |
| `POST /mcp` | the MCP endpoint, stateless — one server instance per request |
| `GET /health` | liveness probe |
| `GET /robots.txt`, `/sitemap.xml` | generated per request from the `Host` header, so a self-hosted copy advertises its own address; `/mcp` and `/health` are excluded from crawling |
| `GET /favicon.ico`, `/favicon.svg`, `/apple-touch-icon.png`, `/icon-192.png`, `/icon-512.png`, `/og-image.png`, `/site.webmanifest` | static files from `assets/` (or `ASSETS_PATH`), cached for a week |

`GET /index.html` redirects to `/` so the page has a single canonical address.

Every request carries its own credentials, so one deployment serves many users and many
instances without holding state:

| Header | Maps to | Notes |
|---|---|---|
| `X-GitLab-Url` | `GITLAB_URL` | required unless the deployment sets a default |
| `X-GitLab-Token` | `GITLAB_TOKEN` | `Authorization: Bearer <token>` is accepted instead |
| `X-GitLab-Project-Id` | `GITLAB_PROJECT_ID` | default project |
| `X-GitLab-Project-Lock` | `GITLAB_PROJECT_LOCK` | `true` locks the session to that project |
| `X-GitLab-Read-Only` | `GITLAB_READ_ONLY` | `true` refuses every mutating tool |
| `X-GitLab-Auth-Type` | `GITLAB_AUTH_TYPE` | `oauth` to send a Bearer token to GitLab |

TLS verification, timeouts and the attachment size cap are deliberately **not** header-controlled:
they are process-wide and belong to whoever runs the deployment.

Deployment settings:

| Variable | Default | Description |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` | `http` to start the HTTP server |
| `PORT` / `HOST` | `8080` / `0.0.0.0` | listen address |
| `LANDING_PATH` | `./landing.html` | page served at `/` |
| `ASSETS_PATH` | `./assets` | directory holding the icons, the manifest and the preview image |
| `GITLAB_ALLOWED_INSTANCES` | — | comma-separated hostnames; when set, only these instances may be targeted |
| `GITLAB_URL`, `GITLAB_TOKEN`, … | — | fallbacks used when the corresponding header is absent |

Without an allowlist the server refuses private addresses (`localhost`, RFC 1918 ranges,
`169.254.*`, `*.internal`, `*.local`) so a public deployment cannot be used to probe the network
it runs in. A GitLab on a private network therefore needs a local or internal deployment — which
is the correct answer anyway.

```bash
docker build -t gitlab-mcp .
docker run -p 8080:8080 -e GITLAB_ALLOWED_INSTANCES=gitlab.example.com gitlab-mcp
```

### Deploying

`deploy.sh` does the whole cycle on the server — pull, build, swap the container, verify,
purge the CDN cache:

```bash
./deploy.sh                # the usual deploy
./deploy.sh --page-only    # only replace landing.html in the running container, no rebuild
./deploy.sh --no-pull      # deploy the working tree as it is
./deploy.sh --logs         # follow the container log afterwards
```

Copy `deploy.env.example` to `deploy.env` on the server and set the port, container name,
`PUBLIC_URL` and, if the site sits behind Cloudflare, a zone id and an API token with the
Cache Purge permission. `deploy.env` is git-ignored, so server-specific values stay there.

The previous image is tagged `:previous` before every build, and a failed health check
restores it automatically and exits non-zero, so a broken build never stays deployed.
Because the server reads `landing.html` from disk on every request, `--page-only` publishes
a page change in a second without rebuilding anything — but the file is baked into the image
by `COPY landing.html`, so the next full deploy is what makes it permanent.

### Landing page assets

`assets/` holds everything the page references: `favicon.svg` (the source of every raster icon),
`favicon.ico`, the touch and PWA icons, `site.webmanifest`, and `og-image.png` — the 1200×630
preview used by link unfurlers, rendered from `assets/og-card.html`.

The PNG and ICO files are committed, so a normal build needs nothing extra. Regenerate them only
after editing `favicon.svg` or `og-card.html`:

```bash
npm run assets      # headless Chrome does the rasterising; set CHROME=… if it is not found
```

The page carries a description, canonical URL, Open Graph and Twitter cards, and JSON-LD
(`SoftwareApplication`, `WebSite`, `FAQPage`). Those absolute URLs point at
`gitlab-mcp.webapace.ink`; a self-hosted copy that should be indexed under its own name needs
them replaced in `landing.html` — `robots.txt` and `sitemap.xml` already follow the request host
on their own.

Because the endpoint accepts tokens from callers, put it behind TLS and treat access logs
accordingly. The server itself keeps nothing: no sessions, no storage, one throwaway server
instance per request.

---

## Single-project isolation

Two modes, and they stack.

**Soft — a default project.** Only `GITLAB_PROJECT_ID` is set: the `project_id` argument becomes
optional, but any other project is still reachable.

**Hard — `GITLAB_PROJECT_LOCK=true`.** The server resolves the project at startup and from then on:

- any `project_id` other than the configured one is refused — the id, the full path and the URL-encoded path are all compared, so `42`, `group/project` and `group%2Fproject` count as the same project;
- group-wide and instance-wide operations (listing groups, group members, group search) are refused;
- `gitlab_api_request` only accepts paths under `/projects/<your project>/` plus the harmless `/user`, `/version`, `/markdown`, `/todos`;
- `gitlab_download_url` and `gitlab_read_attachment` only fetch URLs that belong to the project;
- `gitlab_list_projects` returns exactly one project and an unscoped `gitlab_search` searches inside it.

`GITLAB_PROJECT_LOCK=true` + `GITLAB_READ_ONLY=true` + a project access token with `read_api`
gives a server that can neither reach outside the project nor change anything inside it.

---

## Screenshots and attachments

This is the part worth its own section. GitLab stores attachments at URLs like
`/uploads/<hash>/screenshot.png` — unreachable without authentication, and written into the issue
body as a relative link.

How it works:

**1. Fetch the issue.** `gitlab_get_issue` returns the description, every comment, and a separate
`attachments` block with absolute URLs already resolved:

```json
{
  "attachments": [
    {
      "title": "Screenshot_2026-08-04",
      "url": "https://gitlab.velocorner.ch/team/web/uploads/a1b2…/Screenshot.png",
      "is_image_embed": true,
      "source": "description"
    },
    { "title": "log", "url": "https://…/uploads/c3d4…/error.log", "is_image_embed": false, "source": "note #98123" }
  ]
}
```

**2. Look at the image.** `gitlab_read_attachment` with that `url` — PNG/JPEG/GIF/WebP come back as
an image the model can actually see. Text files come back as text, anything else as a base64 resource.

Relative paths work too: `{"url": "/uploads/a1b2…/Screenshot.png", "project_id": "team/web"}`.

Related tools:

- `gitlab_list_attachments` — scan an issue/MR/epic and get only the attachment list, without the body;
- `gitlab_get_file` with `as_image: true` — view an image committed to the repository;
- `gitlab_get_job_artifact` — pull a screenshot out of CI artifacts (Playwright, Cypress);
- `gitlab_download_url` — authenticated GET of any URL on the instance;
- `gitlab_upload_file` — upload a local file and get the markdown snippet to embed in a comment.

Downloads try several routes in order: the API first (`/projects/:id/uploads/:secret/:file`,
GitLab 16.6+), then the web URL with a `PRIVATE-TOKEN` header, then with `?private_token=`. A
response that turns out to be a login page counts as a failure and the next route is tried — which
is what makes this work on older self-hosted instances.

---

## Tools

Identifiers: a project is a numeric id or a full path `group/subgroup/project`; issues and merge
requests are addressed by their **iid** (the number shown in the UI), not the global id.

Every list tool shares the arguments `page`, `per_page` (max 100), `all_pages` (walk the whole
pagination) and `limit`.

### Instance and utility

| Tool | Description |
|---|---|
| `gitlab_whoami` | Current user, instance URL, GitLab version — the configuration smoke test |
| `gitlab_search` | Search across projects, issues, MRs, code, commits, wiki, comments |
| `gitlab_list_todos` / `gitlab_mark_todo_done` | The user's todo list and marking items done |
| `gitlab_list_events` | Activity stream of the current user or a project |
| `gitlab_list_users` / `gitlab_get_user` | User lookup (project members only in locked mode) |
| `gitlab_markdown_render` | Render markdown through GitLab (expands relative links) |
| `gitlab_api_request` | **Any** REST API v4 endpoint: epics, feature flags, admin, instance specifics |

### Projects and groups

| Tool | Description |
|---|---|
| `gitlab_list_projects` / `gitlab_get_project` | Project list and details |
| `gitlab_create_project` / `gitlab_update_project` / `gitlab_delete_project` / `gitlab_fork_project` | Project management |
| `gitlab_list_project_members` / `gitlab_add_project_member` | Project members |
| `gitlab_list_groups` / `gitlab_get_group` / `gitlab_list_group_projects` / `gitlab_list_group_members` | Groups |
| `gitlab_project_statistics` | Languages, contributors, repository size |

### Issues

| Tool | Description |
|---|---|
| `gitlab_list_issues` | Issues of a project, a group or the whole instance, with filters |
| `gitlab_get_issue` | The whole issue: body, comments, **attachments** |
| `gitlab_create_issue` / `gitlab_update_issue` / `gitlab_delete_issue` | Issue CRUD, including close/reopen |
| `gitlab_list_notes` / `gitlab_create_note` / `gitlab_update_note` | Comments on issues, MRs, snippets, epics, commits |
| `gitlab_list_discussions` / `gitlab_reply_to_discussion` | Discussion threads |
| `gitlab_issue_links` | Links between issues (relates to / blocks / blocked by) |
| `gitlab_issue_time_tracking` | Estimate and spent time |
| `gitlab_issue_related_merge_requests` | Merge requests connected to the issue |
| `gitlab_list_labels` / `gitlab_create_label` / `gitlab_list_milestones` / `gitlab_list_boards` | Labels, milestones, boards |
| `gitlab_award_emoji` | Emoji reactions |

### Merge requests

| Tool | Description |
|---|---|
| `gitlab_list_merge_requests` / `gitlab_get_merge_request` | MR list and details (with attachments) |
| `gitlab_get_merge_request_diff` | Per-file diff, truncated to a readable size |
| `gitlab_create_merge_request` / `gitlab_update_merge_request` / `gitlab_merge_merge_request` | MR lifecycle |
| `gitlab_merge_request_action` | approve, unapprove, rebase, cancel auto-merge, commits, participants, pipelines |
| `gitlab_create_merge_request_thread` | Comment on a specific line of a file under review |
| `gitlab_resolve_merge_request_thread` | Resolve or unresolve a thread |

### Repository

| Tool | Description |
|---|---|
| `gitlab_list_repo_tree` / `gitlab_get_file` | File tree and contents (`as_image` for images) |
| `gitlab_create_or_update_file` / `gitlab_commit_files` | Write one file, or commit several operations at once |
| `gitlab_list_branches` / `gitlab_branch_action` | Branches: create, delete, prune merged |
| `gitlab_list_commits` / `gitlab_get_commit` / `gitlab_compare` / `gitlab_blame_file` | History, commit diff, ref comparison, blame |
| `gitlab_list_tags` / `gitlab_tag_action` / `gitlab_list_releases` / `gitlab_create_release` | Tags and releases |
| `gitlab_list_protected_branches` | Branch protection rules |

### CI/CD

| Tool | Description |
|---|---|
| `gitlab_list_pipelines` / `gitlab_get_pipeline` / `gitlab_pipeline_action` | Pipelines: inspect, run, retry, cancel |
| `gitlab_list_jobs` / `gitlab_job_action` | Jobs: retry, cancel, play, erase |
| `gitlab_get_job_log` | Log of a failed job (tail by default) |
| `gitlab_get_job_artifact` | Job artifact; images come back as images |
| `gitlab_ci_variables` | CI/CD variables |
| `gitlab_lint_ci_config` | Validate `.gitlab-ci.yml` before committing it |
| `gitlab_list_environments` / `gitlab_list_deployments` / `gitlab_list_pipeline_schedules` | Environments, deployments, schedules |

### Attachments

| Tool | Description |
|---|---|
| `gitlab_read_attachment` | Download an attachment with authentication; images arrive as images |
| `gitlab_list_attachments` | Every attachment of an issue/MR/epic in one list |
| `gitlab_upload_file` | Upload a local file, get the markdown snippet back |
| `gitlab_download_url` | Authenticated GET of any instance URL |

### Everything else

| Tool | Description |
|---|---|
| `gitlab_wiki` | Wiki pages: read and edit |
| `gitlab_snippets` | Project snippets |
| `gitlab_project_hooks` | Webhooks |
| `gitlab_list_packages` / `gitlab_list_registry_repositories` | Package registry and container registry |

---

## When no tool fits

`gitlab_api_request` calls any [REST API v4](https://docs.gitlab.com/ee/api/api_resources.html)
endpoint:

```json
{
  "method": "GET",
  "path": "/groups/42/epics",
  "query": { "state": "opened" },
  "all_pages": true
}
```

Slashes inside identifiers are encoded as `%2F`: `/projects/group%2Fproject/issues`.
In read-only mode only `GET` passes; in locked mode only paths inside the locked project do.

---

## Development

```bash
npm run build       # compile
npm run dev         # tsc --watch
npm run typecheck   # types only
npm run start       # stdio mode
npm run start:http  # hosted mode on PORT (default 8080)
```

Layout:

```
src/
  index.ts            entry point, picks stdio or HTTP
  server.ts           builds a configured MCP server with every tool registered
  http.ts             hosted mode: landing page, stateless /mcp, per-request credentials
  config.ts           configuration from environment or headers
  gitlab-client.ts    HTTP client: pagination, errors, downloads, project lock
  tools/
    helpers.ts        shared argument schemas, result formatting, guards
    core.ts           instance, search, todos, raw API
    projects.ts       projects and groups
    issues.ts         issues, comments, labels, milestones
    merge-requests.ts merge requests, diffs, reviews
    repository.ts     files, branches, commits, tags, releases
    ci.ts             pipelines, jobs, artifacts, variables
    attachments.ts    attachments and screenshots
    misc.ts           wiki, snippets, hooks, registries
```

To add a tool, call `defineTool` in the appropriate module: error handling, the read-only guard
and the project lock are wired in automatically.

---

## Troubleshooting

| Symptom | Cause |
|---|---|
| `401 Unauthorized` | The token expired, was revoked, or belongs to a different instance |
| `403 Forbidden` | Missing scope (`read_api` instead of `api`) or insufficient role in the project |
| `404 Not Found` | Wrong project id/path, or the token cannot see it. The path must be complete: `group/subgroup/project` |
| Attachment will not download | Check that the URL belongs to the same instance; for a private CA set `GITLAB_TLS_REJECT_UNAUTHORIZED=false` |
| `Attachment is N bytes, above…` | Raise `GITLAB_MAX_ATTACHMENT_BYTES` |
| Server will not start | Logs go to stderr with the `[gitlab-mcp]` prefix; usually `GITLAB_TOKEN` is missing |

A visual walkthrough with examples lives in `landing.html` at the repository root.

TDQS

C2.8/5.0

Scored across 85 tools

Disambiguation3/5

Many tools target distinct resources, but several have overlapping purposes: the generic 'action' tools (e.g., gitlab_branch_action, gitlab_merge_request_action) bundle multiple operations, and download-style tools (gitlab_download_url, gitlab_read_attachment, gitlab_get_job_artifact) have fuzzy boundaries. Descriptions help, but an agent may struggle to pick between similar tools like gitlab_list_notes and gitlab_list_discussions.

Naming Consistency3/5

Most tools follow gitlab_verb_noun, but there are notable exceptions: noun-only tools (gitlab_wiki, gitlab_snippets), noun+action tools (gitlab_branch_action, gitlab_pipeline_action), and unique names (gitlab_whoami, gitlab_markdown_render). While the uniform prefix helps, the mixing of verb forms creates an inconsistent pattern.

Tool Count1/5

With 85 tools, this set far exceeds even the 'too many' threshold (25+). While a comprehensive GitLab API wrapper could justify breadth, the sheer number makes the tool surface unwieldy for an agent to navigate effectively.

Completeness5/5

The surface covers a wide range: projects, groups, issues, MRs, notes, repository files, branches, commits, pipelines, jobs, artifacts, wiki, snippets, and more. Minor gaps exist (e.g., no group create/update/delete tools, epics only via api_request), but the core lifecycle and operational workflows are well covered.

Maintenance

ActivitySlowing
ResponsivenessNo issues