Skip to main content
Glama
README.md
# gitlab-mcp

[![CI](https://github.com/mmedum/gitlab-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mmedum/gitlab-mcp/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/mmedum/gitlab-mcp?include_prereleases&sort=semver)](https://github.com/mmedum/gitlab-mcp/releases)
[![Go Reference](https://pkg.go.dev/badge/github.com/mmedum/gitlab-mcp.svg)](https://pkg.go.dev/github.com/mmedum/gitlab-mcp)
[![License: Apache 2.0](https://img.shields.io/github/license/mmedum/gitlab-mcp)](./LICENSE)

An MCP server for gitlab.com. One binary over stdio, signed in as you,
with your own OAuth application and the token in your OS keyring.

It is for a person with a gitlab.com account who wants an assistant to
find and read issues, review merge requests, follow CI and make the
changes they ask for, as them. It works **inside** projects: issues,
merge requests, reviews, the repository and CI. It does not administer
an instance or a group, and it does not serve self-managed instances.

Unofficial, and not affiliated with GitLab Inc. See `NOTICE`.

## Goals and non-goals

Goals:

- **Content never steers the assistant through the server.** Issues,
  reviews, files and job logs come back marked as untrusted data, hidden
  text is counted, nothing they link to is fetched, and token shapes in
  CI output are masked.
- **Nothing it writes runs a command.** A quick action such as `/merge`
  in a comment is refused or escaped, never executed.
- **Nothing ships, deletes or reaches further unless you turn it on.**
  Merging, approving, running CI and deleting are not registered by
  default, code reaches a protected branch only through a merge request,
  and writes can be confined to the groups you name.
- **A write changes only what you read and named.** Writes carry what
  they read and are refused when it moved, a lost create is settled by
  reading, and every write takes `dry_run`.
- **Reads are bounded.** Every read has a budget and says what it left
  out and how to continue.
- **Act as you, and log nothing about your work.** Every call runs as
  the signed-in account, and the logs never carry paths, titles or
  bodies.

Non-goals:

- **Self-managed instances.** It talks to gitlab.com and nothing else.
- **Administration.** Instance and group settings, users, runners,
  access tokens, deploy keys, webhooks and integrations are out.
- **CI variables and secure files.** Reading them would put secrets in a
  model's context.
- **Deleting projects or groups, force pushes and protected-branch
  settings**, not even behind a switch.
- **A raw API tool.** Every call is a tool with its own guards; nothing
  sends an arbitrary REST or GraphQL request.
- **Events pushed to it.** Webhooks need a listener, and a stdio process
  has none.
- **A hosted service.** It speaks stdio only, so Claude on the web and on
  mobile cannot reach it.
- **Asking you before a call.** Your client approves each call; the
  server asks nothing.

## What makes this one different

- **A curated surface, gated by registration.** A tool for each thing a
  person does in a project, rather than an API mirror, and a tool that
  is switched off is not registered, so it cannot be called at all.
- **Nothing it writes runs a quick action.** GitLab runs `/merge` and
  the rest from any comment or description sent through its API.
- **Content is marked as untrusted.** Issues, reviews, files and job
  logs come back inside boundaries the content cannot close.
- **Reviews the way people review.** Draft comments, published together,
  and the server, not the model, computes where an inline comment lands.
- **Signs in like a desktop app.** Your own OAuth application, a browser
  tab, the token in the OS keyring. GitLab's own MCP server runs on the
  instance over HTTP, and a stdio client reaches it through a Node.js
  proxy.
- **Verifiable releases.** Signed checksums, build provenance and SBOMs.

## Tools

| Tool | What it does |
|---|---|
| `get_me` | Who is signed in, with which scopes, and what GitLab reports about itself |
| `resolve_url` | Turn a GitLab web URL into the arguments another tool takes, and name that tool |
| `search_projects` | Find projects by name, or within a group |
| `get_project` | One project: default branch, visibility, what it has turned on |
| `list_members` | Who has access to a project, and with which role |
| `find_users` | Accounts by exact username or by name |
| `search_issues` | Find issues across gitlab.com, a group or a project |
| `get_issue` | One issue, its description marked as untrusted content, and its linked merge requests |
| `create_issue` | Create an issue; labels, assignees and milestone checked first |
| `update_issue` | Change an issue's fields, refused if it changed since you read it |
| `list_discussions` | The comment threads on an issue or a merge request |
| `list_item_events` | An issue's or a merge request's label, state, milestone and weight changes, newest first |
| `add_comment` | Comment on an issue or merge request, reply in a thread, or start one, on a diff line or not |
| `update_comment` | Edit one of your own comments in place, in its thread and on its diff line |
| `resolve_discussion` | Resolve or reopen a thread on a merge request or an issue |
| `link_issues` | Link two issues, in one project or two |
| `unlink_issues` | Remove the link between two issues |
| `link_merge_requests` | Make one merge request wait for another to merge, in one project or two |
| `unlink_merge_requests` | Remove a dependency between two merge requests |
| `search_merge_requests` | Find merge requests across gitlab.com or a project |
| `get_merge_request` | One merge request with its approvals, reviewer states, linked issues and dependencies |
| `list_mr_files` | The files a merge request changes, with line counts and GitLab's markers |
| `get_mr_diff` | A merge request's diffs, file by file, bounded |
| `list_mr_commits` | A merge request's commits |
| `list_mr_versions` | A merge request's diff versions, one per push |
| `compare_mr_versions` | What changed between two versions of a merge request, or since one |
| `create_merge_request` | Open a merge request from a branch |
| `update_merge_request` | Change a merge request's fields, refused if it changed since you read it |
| `track_time` | Set or reset an issue's or a merge request's time estimate, and add or reset its time spent |
| `add_review_comment` | A draft review comment, on the merge request or a diff line |
| `list_review_comments` | Your unpublished review comments on a merge request |
| `delete_review_comment` | Delete one of your drafts |
| `update_review_comment` | Change the text of one of your drafts, keeping its place |
| `publish_review_comment` | Publish one of your drafts on its own |
| `submit_review` | Publish all your drafts at once, with a summary and reviewer state |
| `rerequest_review` | Ask a reviewer for a new review once their requested changes are addressed |
| `get_file` | A file at a ref, bounded |
| `list_tree` | A directory listing at a ref |
| `list_branches` | A project's branches |
| `list_commits` | Commits on a ref or a path |
| `get_commit` | One commit and its diff, bounded, and the merge requests that contain it |
| `compare_refs` | The commits and diffs between two refs, bounded |
| `list_tags` | A project's tags |
| `create_branch` | Create a branch from a ref |
| `create_commit` | Commit file changes to a branch; never the default or a protected one |
| `get_blame` | Who last changed each line of a file, bounded |
| `cherry_pick_commit` | Apply a commit to a branch; never the default or a protected one |
| `revert_commit` | Undo a commit on a branch with a new one; never the default or a protected one |
| `list_pipelines` | A project's CI pipelines |
| `get_pipeline` | One pipeline with the jobs that failed, trigger jobs included |
| `list_jobs` | A pipeline's jobs |
| `get_job_log` | A window of a job's log, secrets masked; the failing section on request |
| `get_test_report` | A pipeline's test counts and failed cases, secrets masked, bounded |
| `lint_ci` | Check a project's CI configuration at a ref, or configuration you pass |
| `list_job_artifacts` | The files a job kept as artifacts |
| `get_job_artifact` | One text file of a job's artifacts, secrets masked, bounded |
| `list_labels` | The labels a project's issues and merge requests can carry |
| `list_milestones` | A project's or a group's milestones |
| `list_boards` | A project's issue boards and their lists, each with the `search_issues` arguments that read it |
| `search` | Code, commits, comments and more, in a project, a group or everywhere |
| `list_todos` | Your to-do items |
| `mark_todos_done` | Mark your to-do items done |
| `add_todo` | Add a to-do for yourself on an issue or a merge request |
| `subscribe` | Subscribe to an issue's or a merge request's notifications, or unsubscribe |
| `react` | Add or remove your emoji reaction on an issue, a merge request or a comment |
| `upload_file` | Upload an image from a directory you allowed and get the Markdown that shows it in a comment or description |
| `merge_merge_request` | Merge at the head you reviewed, or when the pipeline succeeds (Ship) |
| `cancel_auto_merge` | Stop a merge request merging when its pipeline succeeds (Ship) |
| `approve_merge_request` | Approve at the head you reviewed (Ship) |
| `unapprove_merge_request` | Withdraw your approval (Ship) |
| `apply_suggestions` | Commit suggestions from a merge request's diff comments to its source branch; never a protected one (Ship) |
| `rebase_merge_request` | Rebase a merge request's source branch, from the head you reviewed (Ship) |
| `move_issue` | Move an issue to another project, never to one more people can see (Ship) |
| `run_pipeline` | Run a pipeline for a ref, with variables whose values are never shown (Ship) |
| `run_merge_request_pipeline` | Run a merge request's pipeline, merged results or detached as GitLab picks; not for a fork's merge request (Ship) |
| `retry_pipeline` | Retry a pipeline's failed and canceled jobs (Ship) |
| `retry_job` | Run a finished job again, with inputs (Ship) |
| `play_job` | Start a manual job, with variables and inputs (Ship) |
| `cancel_pipeline` | Cancel a running pipeline (Ship) |
| `delete_branch` | Delete a branch; never the default, a protected or an unmerged one unless asked (Destructive) |
| `delete_comment` | Delete one of your own comments (Destructive) |
| `make_issue_public` | Make a confidential issue public (Destructive) |
| `list_wiki_pages` | A project wiki's pages (`wiki` toolset) |
| `get_wiki_page` | One wiki page, bounded (`wiki` toolset) |
| `save_wiki_page` | Create a wiki page, or change one unchanged since you read it (`wiki` toolset) |
| `delete_wiki_page` | Delete a wiki page (`wiki` toolset, Destructive) |
| `list_snippets` | A project's snippets, or your own (`snippets` toolset) |
| `get_snippet` | One snippet and a file of it, bounded (`snippets` toolset) |
| `create_snippet` | Create a snippet, always private (`snippets` toolset) |
| `update_snippet` | Change one of your own private snippets: title, description, files; refused if it changed since you read it (`snippets` toolset) |
| `delete_snippet` | Delete one of your own snippets (`snippets` toolset, Destructive) |
| `list_releases` | A project's releases (`releases` toolset) |
| `get_release` | One release and its notes (`releases` toolset) |
| `create_release` | Create a release, and its tag at a ref, with asset links to the project's own pages (`releases` toolset, Ship) |
| `create_tag` | Create a tag at a ref; never a protected one (`releases` toolset) |
| `delete_tag` | Delete a tag; never a protected one (`releases` toolset, Destructive) |
| `create_label` | Create a project label (`planning` toolset) |
| `update_label` | Change a project label unchanged since you read it (`planning` toolset) |
| `delete_label` | Delete a project label (`planning` toolset, Destructive) |
| `create_milestone` | Create a project milestone (`planning` toolset) |
| `update_milestone` | Change, close or reopen a milestone unchanged since you read it (`planning` toolset) |
| `delete_milestone` | Delete a project milestone (`planning` toolset, Destructive) |
| `list_environments` | A project's environments and their last deployment (`deployments` toolset) |
| `list_deployments` | What was deployed where, and by which job (`deployments` toolset) |
| `list_events` | Recent activity, yours or a project's (`activity` toolset) |

Ship and Destructive tools are registered only with the settings below,
and the toolsets only when `GITLAB_MCP_TOOLSETS` names them.

Resources carry the same text for clients that attach rather than call:
an issue, a merge request and a job log.

## Install

Download an archive for your platform from the
[releases](https://github.com/mmedum/gitlab-mcp/releases) page, or:

```bash
go install github.com/mmedum/gitlab-mcp/v4/cmd/gitlab-mcp@latest
```

Claude Desktop users can open the `.mcpb` bundle from the same release.
It does not log you in; do the steps below first.

## Sign in

You bring your own OAuth application. Nothing is shipped in the binary,
and there are no personal access tokens.

1. **Register an application**, once. On gitlab.com: your avatar → Edit
   profile → Applications (or a group's). Redirect URI
   `http://127.0.0.1/callback`, **Confidential unchecked**, scope `api`
   (`read_api` for read-only). `docs/setup.md` has the exact values.
2. **Log in** from a terminal:

   ```bash
   gitlab-mcp login --client-id <application id>
   ```

   It prints the scopes it will ask for, opens your browser, and stores
   the token in the OS keyring. On a machine without a browser,
   `--no-browser` prints the URL and the `ssh -L` line to forward the
   callback.
3. **Check it**:

   ```bash
   gitlab-mcp doctor
   ```

   `doctor` walks the connection, TLS, the sign-in, the application, the
   granted scopes and one call as you, and names what is missing.
   `status` and `logout` do what they say; `--profile` keeps two
   gitlab.com logins side by side.

## Connect a client

Claude Code:

```bash
claude mcp add gitlab -- gitlab-mcp
```

Any client that takes a JSON server list:

```json
{
  "mcpServers": {
    "gitlab": { "command": "gitlab-mcp" }
  }
}
```

Claude Desktop can instead open the `.mcpb` bundle from a release. It
asks for the same settings: merging, approving, rebasing, applying
suggestions, moving issues, CI and releases (`enable_ship`) and deleting
(`enable_destructive`) stay off unless you turn them on, and
`write_namespaces` confines writes to the groups and projects you name.
This server asks nobody before a call. Claude Desktop approves each
tool, and a tool you always allow then runs unasked, so
leave a switch off unless you need its tools.

## Configuration

Every setting is an environment variable with the `GITLAB_MCP_` prefix
and a matching flag. `docs/configuration.md` lists them all.

## Safety

GitLab content was written by someone other than you, and some of it is
written to steer an agent. The server marks it as untrusted data, never
fetches what it references, and nothing it adds tells the model to act
on it.

What can be registered depends on the settings, because GitLab's `api`
scope cannot separate any of it:

| Setting | Registers |
|---|---|
| `GITLAB_MCP_READ_ONLY=true` | Read tools only, with a `read_api` token |
| default | Read, Write and Adding: issues, comments, reviews, branches, merge requests |
| `GITLAB_MCP_ENABLE_SHIP=true` | adds merging, approving, rebasing, applying suggestions, moving issues, running CI and releases |
| `GITLAB_MCP_ENABLE_DESTRUCTIVE=true` | adds deletion and making an issue public, and each call must pass `confirm: true` |

Your MCP client approves each call; the server does not ask you
itself. claude.ai, Claude Desktop and Codex prompt for every tool
marked destructive: every write but the ones that only add, such as
`create_issue` and `create_label`. VS Code prompts for every tool that
writes. In Claude Code, `delete_label`, `delete_milestone` and
`make_issue_public` ask on every call whatever your rules say, since
none can be undone and each reaches past the one item: a label or
milestone comes off every issue and merge request, and a public issue
is read by everyone. For the rest, list the
calls you want to approve one by one under `permissions.ask` in your
settings, with the name you gave the server in place of `gitlab`. For
example, the writes that ship, publish, upload or delete:

```json
{
  "permissions": {
    "ask": [
      "mcp__gitlab__merge_merge_request", "mcp__gitlab__approve_merge_request",
      "mcp__gitlab__apply_suggestions", "mcp__gitlab__run_pipeline",
      "mcp__gitlab__run_merge_request_pipeline", "mcp__gitlab__play_job",
      "mcp__gitlab__create_release", "mcp__gitlab__create_tag", "mcp__gitlab__move_issue",
      "mcp__gitlab__upload_file",
      "mcp__gitlab__delete_branch", "mcp__gitlab__delete_comment",
      "mcp__gitlab__delete_wiki_page", "mcp__gitlab__delete_label",
      "mcp__gitlab__delete_milestone", "mcp__gitlab__delete_tag",
      "mcp__gitlab__delete_snippet"
    ]
  }
}
```

- **No quick actions.** GitLab runs `/merge`, `/close` and the rest from
  a description or comment. Every body this server sends is checked, and
  a quick-action line is refused, or escaped when you ask.
- **Protected branches.** Code reaches the default branch or a protected
  branch only through a merge request; a direct commit there is refused.
- **No blind retries.** A create that may or may not have happened is
  settled by reading, never by creating again.
- **Local files only from where you allow.** `upload_file` reads an
  image only from the directories `GITLAB_MCP_UPLOAD_DIRS` names, and
  only a PNG, JPEG, GIF or WebP judged from its bytes, so a comment
  asking for a key file to be "attached" gets nothing.

The default token can still merge and approve; leaving Ship off stops
this server doing so, not anything else holding the token.
`docs/security.md` says more.

## Verify a release

Each release signs `checksums.txt` with a keyless Sigstore certificate
and attests every archive and the bundle:

```bash
sha256sum -c checksums.txt --ignore-missing
cosign verify-blob checksums.txt --bundle checksums.txt.bundle \
  --certificate-identity "https://github.com/mmedum/gitlab-mcp/.github/workflows/release.yml@refs/tags/<tag>" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
gh attestation verify <archive> --repo mmedum/gitlab-mcp
```

## Getting help

Run `gitlab-mcp doctor` first: it checks the connection, the sign-in,
the application and the granted scopes, and names what is missing.
[`docs/troubleshooting.md`](docs/troubleshooting.md) covers each
failure. Then open an issue with `doctor`'s output and the version.
**Do not paste a tool result**: it is your organization's code and other
people's words.

## Documentation

- [`docs/setup.md`](docs/setup.md): registering the OAuth application
  and logging in.
- [`docs/configuration.md`](docs/configuration.md): every setting, what
  each registers, and where state is stored.
- [`docs/troubleshooting.md`](docs/troubleshooting.md): symptoms and
  their fixes, then rotating the token, revoking access and handling a
  suspected exposure.
- [`docs/security.md`](docs/security.md): the threat model.
- [`docs/architecture.md`](docs/architecture.md): how it works, and the
  design decisions a contributor should not undo.
- [`docs/release.md`](docs/release.md): how a release is cut and
  checked.
- [`CHANGELOG.md`](CHANGELOG.md): what each release changed. The tool
  names and their output fields are the contract, and anything you have
  to act on is marked **Breaking**.

## Contributing

[`CONTRIBUTING.md`](CONTRIBUTING.md) covers the gates, the live run and
the review flow. Security reports go through [`SECURITY.md`](SECURITY.md),
not an issue.

## License

Apache-2.0. See `LICENSE` and `NOTICE`.