Skip to main content
Glama
ttpears

GitLab MCP Server

by ttpears
README.md
# GitLab MCP Server

[![npm version](https://img.shields.io/npm/v/@ttpears/gitlab-mcp-server)](https://www.npmjs.com/package/@ttpears/gitlab-mcp-server)
[![npm downloads](https://img.shields.io/npm/dm/@ttpears/gitlab-mcp-server)](https://www.npmjs.com/package/@ttpears/gitlab-mcp-server)
[![CI](https://github.com/ttpears/gitlab-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/ttpears/gitlab-mcp/actions/workflows/ci.yml)
[![Container](https://img.shields.io/badge/ghcr.io-ttpears%2Fgitlab--mcp-2496ED?logo=docker&logoColor=white)](https://github.com/ttpears/gitlab-mcp/pkgs/container/gitlab-mcp)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

<img src="assets/logo.svg" width="80" align="right" alt="gitlab-mcp"/>

A community MCP server for GitLab — works with **any GitLab tier** (Free, Premium, Ultimate), no GitLab Duo required. PAT-based auth. Streamable HTTP and stdio transports.

```bash
npx @ttpears/gitlab-mcp-server
```

Field notes: [Two session bugs that broke my GitLab MCP server](https://hackyourworld.com/gitlab-mcp-session-bugs-restart-404/) explains the recursive close callback and stale HTTP session behavior behind the current lifecycle handling.

---

## Choose your deployment

| You're running | Go to |
|---|---|
| Claude Code or another IDE/AI tool, just for you | [Solo IDE](#solo-ide) |
| LibreChat for a team with a service-account read token | [LibreChat — service-account reads](#librechat--service-account-reads) |
| LibreChat where every operation should use the calling user's token | [LibreChat — strict per-user](#librechat--strict-per-user) |

---

## Solo IDE

### Claude Code (recommended)

```bash
claude mcp add gitlab \
  --env GITLAB_URL=https://gitlab.com \
  --env GITLAB_TOKEN=glpat-your-pat \
  -- npx -y @ttpears/gitlab-mcp-server
```

`--scope` controls where the configuration lives:

- `--scope local` (default) — only this user, only this project
- `--scope user` — shared across all of your projects
- `--scope project` — written to `.mcp.json` in the project root, intended for team check-in

For team check-in, use a `${VAR}` placeholder so the PAT itself stays out of git:

```json
{
  "mcpServers": {
    "gitlab": {
      "command": "npx",
      "args": ["-y", "@ttpears/gitlab-mcp-server"],
      "env": {
        "GITLAB_URL": "https://gitlab.com",
        "GITLAB_TOKEN": "${GITLAB_TOKEN}"
      }
    }
  }
}
```

Each contributor exports `GITLAB_TOKEN` in their shell; Claude Code expands it on launch.

### Cold start

If launching via `npx` adds noticeable latency, install once:

```bash
npm install -g @ttpears/gitlab-mcp-server
```

…and replace `npx -y @ttpears/gitlab-mcp-server` with `gitlab-mcp-server` in your config.

### Claude Desktop

**Option A — MCPB one-click install:** download
`gitlab-mcp-community-${VERSION}.mcpb` from the
[latest GitHub Release](https://github.com/ttpears/gitlab-mcp/releases/latest)
and drag it onto the Claude Desktop window. Fill in your GitLab URL and a
Personal Access Token when prompted; the token is stored in your OS keychain.
Use a `read_api`-scoped PAT for read-only access, or an `api`-scoped PAT to
also allow writes (create_issue, create_merge_request, etc.).

**Option B — manual config:** add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "gitlab": {
      "command": "npx",
      "args": ["-y", "@ttpears/gitlab-mcp-server"],
      "env": {
        "GITLAB_URL": "https://gitlab.com",
        "GITLAB_TOKEN": "glpat-your-pat"
      }
    }
  }
}
```

---

## LibreChat — service-account reads

A read-only service token covers reads (so anyone in the workspace can ask
questions). Each user supplies their own PAT for writes through LibreChat's
`customUserVars`.

LibreChat's documented extension point is `docker-compose.override.yml` — the
fragments below assume you're adding `gitlab-mcp` there alongside LibreChat's
own `api`, `mongodb`, etc.

### `.env`

```env
GITLAB_URL=https://gitlab.example.com
GITLAB_READ_TOKEN=glpat-readonly-service-token   # read_api scope only
```

Use `https://gitlab.com` for SaaS GitLab; use your own host for self-hosted.

### `docker-compose.override.yml`

```yaml
services:
  gitlab-mcp:
    image: ghcr.io/ttpears/gitlab-mcp:1.14.0
    env_file:
      - .env
    networks:
      - librechat
    restart: unless-stopped
```

`env_file` reads `GITLAB_URL` and `GITLAB_READ_TOKEN` from `.env`. No port
mapping needed — LibreChat's `api` container reaches `gitlab-mcp` over the
shared `librechat` network. Add `ports: ["8008:8008"]` only if you need to
hit it from the host (e.g. for `curl http://localhost:8008/health`).

Then:

```bash
docker compose -f docker-compose.yml -f docker-compose.override.yml up -d gitlab-mcp api
```

`api` is restarted alongside so it re-reads `librechat.yaml`.

### `librechat.yaml`

```yaml
mcpServers:
  gitlab:
    type: streamable-http
    url: http://gitlab-mcp:8008/
    startup: false                # don't connect until the user supplies their PAT
    initTimeout: 30000
    timeout: 120000
    headers:
      Authorization: "Bearer {{GITLAB_PAT}}"
      X-GitLab-Url: "{{GITLAB_URL_OVERRIDE}}"   # only honored when GITLAB_PIN_HOST=false (default pins to GITLAB_URL)
    customUserVars:
      GITLAB_PAT:
        title: "GitLab Personal Access Token"
        description: "Your GitLab PAT with api scope. Used for issues, MRs, and comments you create or edit."
      GITLAB_URL_OVERRIDE:
        title: "GitLab URL (optional)"
        description: "Leave blank to use the workspace default. Override only if your account is on a different GitLab instance."
```

`initTimeout` / `timeout` are forgiving defaults for slower internal networks
and large schemas — drop them if SaaS GitLab feels snappy.

> Server-side env vars use `${VAR}`. Per-user vars use `{{VAR}}`. They are not interchangeable.

---

## LibreChat — strict per-user

Every call must carry a user PAT. No service-account fallback. Reads as well
as writes are gated on the user's token.

### Add MCP Server (UI path — recommended)

1. Host gitlab-mcp somewhere LibreChat can reach. For a quick standalone host:

   ```bash
   docker run -d --restart unless-stopped \
     -p 8008:8008 \
     -e GITLAB_URL=https://gitlab.example.com \
     --name gitlab-mcp \
     ghcr.io/ttpears/gitlab-mcp:1.14.0
   ```

   Or, when running alongside LibreChat in the same compose project, drop it
   into `docker-compose.override.yml` (no token env vars):

   ```yaml
   services:
     gitlab-mcp:
       image: ghcr.io/ttpears/gitlab-mcp:1.14.0
       environment:
         GITLAB_URL: https://gitlab.example.com
       networks:
         - librechat
       restart: unless-stopped
   ```

2. In LibreChat → **MCP Servers** → **Add MCP Server**:
   - **URL:** `https://your-host/` (or `http://gitlab-mcp:8008/` if same network)
   - **Authentication:** API Key → check **User provides key** → header format **Bearer**
   - **Save**

3. Each user fills in their PAT through the MCP Tool Select Dialog when configuring an agent.

### `librechat.yaml` equivalent

```yaml
mcpServers:
  gitlab:
    type: streamable-http
    url: http://gitlab-mcp:8008/
    startup: false
    initTimeout: 30000
    timeout: 120000
    headers:
      Authorization: "Bearer {{GITLAB_PAT}}"
    customUserVars:
      GITLAB_PAT:
        title: "GitLab Personal Access Token"
        description: "Your GitLab PAT with api scope."
```

The container needs no env-configured token at all in this mode — every
request is rejected unless `Authorization: Bearer …` is present.

---

## Hosting for a remote LibreChat

The server speaks plain HTTP. For LibreChat-as-a-service or any cross-network
deployment, terminate TLS at a reverse proxy. Common patterns:

- **Caddy** — auto-TLS via Let's Encrypt:

  ```caddyfile
  mcp.example.com {
    reverse_proxy gitlab-mcp:8008
  }
  ```

- **Traefik** — Docker labels on the `gitlab-mcp` service.
- **Cloudflare Tunnel** — no public IP needed; expose `gitlab-mcp:8008` through the tunnel.

The `Authorization` and `Mcp-Session-Id` headers must pass through unchanged. Most defaults handle this fine.

---

## OAuth for remote users

For a publicly hosted instance where each user signs in with **their own GitLab
identity** — and modern MCP clients (Claude.ai, Claude Code) connect with **no
manual token setup** — enable brokered OAuth 2.1. The server then acts as its own
OAuth Authorization Server in front of GitLab:

- advertises Protected Resource Metadata (RFC 9728) and Authorization Server
  Metadata (RFC 8414) at the standard `.well-known` endpoints,
- supports Dynamic Client Registration (RFC 7591), so clients self-register,
- runs authorization-code + PKCE against both the MCP client **and** GitLab
  (dual-PKCE) behind **one** fixed GitLab callback,
- gates the MCP endpoints with bearer validation — unauthenticated requests get
  `401` + `WWW-Authenticate` pointing at the resource metadata (the discovery trigger),
- mints its own opaque tokens and keeps each user's GitLab token server-side
  (no token passthrough).

**Setup**

1. Register **one** GitLab application (instance/group/user → **Applications**):
   - Redirect URI: `https://<your-host>/gitlab/callback`
   - Scopes: `api` (read + write) or `read_api`
   - Confidential: yes (recommended) → you get a client secret; or mark it public for PKCE-only.
2. Run the server (HTTP mode) with **no** `GITLAB_TOKEN` — identity comes from each user's OAuth login:

   ```bash
   MCP_TRANSPORT=http GITLAB_MCP_PORT=8008 \
   GITLAB_URL=https://gitlab.example.com \
   GITLAB_MCP_OAUTH=true \
   MCP_SERVER_URL=https://gitlab-mcp.example.com \
   GITLAB_OAUTH_CLIENT_ID=<application id> \
   GITLAB_OAUTH_CLIENT_SECRET=<application secret> \
   GITLAB_OAUTH_SCOPES=api \
   npx -y @ttpears/gitlab-mcp-server
   ```

3. Point an MCP client at `https://gitlab-mcp.example.com/` — it discovers the
   metadata, registers, and walks the user through GitLab sign-in automatically.

`MCP_SERVER_URL` must be HTTPS (terminate TLS at your reverse proxy) and must
match the host the GitLab redirect URI is registered under. Token/registration
state is in-memory, so run a single instance (or add a shared store) per issuer.

---

## How this differs from GitLab's official MCP server

GitLab ships an [official MCP server](https://docs.gitlab.com/user/gitlab_duo/model_context_protocol/mcp_server/) (Beta) that requires **Premium/Ultimate** and **GitLab Duo**.

| | This server | GitLab official |
|---|---|---|
| **GitLab tier** | Free, Premium, Ultimate | Premium / Ultimate only |
| **GitLab Duo required** | No | Yes |
| **Auth** | PAT, or brokered OAuth 2.1 + Dynamic Client Registration | OAuth 2.0 Dynamic Client Registration |
| **Transport** | stdio + streamable HTTP | stdio + HTTP |
| **Multi-user** | Per-call PAT, OAuth per-user, or service-account fallback | OAuth per-user |
| **GraphQL schema discovery** | Yes — introspect & run custom queries | No |
| **Repository browsing & file reading** | Yes | No |
| **Update issues / MRs / notes** | Yes | No (create only) |
| **Delete issues / notes** | Yes | No |
| **CI/CD pipeline management** | Yes | Yes |
| **MR diffs & commits** | Yes | Yes |
| **Time tracking & timelogs** | Yes | No |
| **MR reviewer & approval status** | Yes | No |
| **Iteration / milestone tracking** | Yes | No |
| **Project statistics dashboard** | Yes | No |
| **Group member listing** | Yes | No |
| **Semantic code search** | No | Yes (requires additional setup) |

**Choose this server** for Free/CE, GraphQL flexibility, LibreChat multi-user,
or brokered OAuth without GitLab Duo. **Choose the official server** for
Premium+Duo with semantic code search.

---

## Tools

### Search & Discovery
| Tool | Description |
|------|-------------|
| `search_gitlab` | Global search across projects, issues, and merge requests |
| `search_projects` | Find repositories by name or description |
| `search_issues` | Search issues globally or within a project (filter by assignee, author, labels, state) |
| `search_merge_requests` | Find merge requests by username or within a project |
| `search_users` | Find team members and contributors |
| `search_groups` | Discover groups and organizations |
| `search_labels` | Search labels in a project or group |
| `list_group_members` | List group members with access levels |
| `browse_repository` | Explore directory structure and files |
| `get_file_content` | Read file contents for code analysis |

### Read Operations
| Tool | Description |
|------|-------------|
| `get_project` | Detailed project information |
| `get_issues` | List project issues with pagination |
| `get_merge_requests` | List project merge requests with pagination |
| `get_merge_request_pipelines` | Get CI/CD pipelines for a merge request |
| `get_pipeline_jobs` | Get jobs for a specific pipeline |
| `get_merge_request_diffs` | Get diff statistics for a merge request |
| `get_merge_request_commits` | Get commits for a merge request |
| `get_notes` | Get notes/comments on an issue or merge request |
| `list_milestones` | List milestones with progress statistics |
| `list_iterations` | List iterations/sprints (Premium/Ultimate) |
| `get_time_tracking` | Get time estimate, spent, and timelogs |
| `get_merge_request_reviewers` | Get MR approval and reviewer status |
| `get_project_statistics` | Aggregate project stats dashboard |
| `get_user_issues` | Get all issues assigned to a user |
| `get_user_merge_requests` | Get MRs authored by or assigned to a user |
| `get_work_item` | Fetch a work item (issue, task, epic, incident, OKR) by ID with full widget data |
| `list_work_items` | List work items in a group or project, filtered by type and state |
| `list_broadcast_messages` | List instance-wide broadcast messages |
| `get_broadcast_message` | Get a specific broadcast message by ID |
| `list_my_todos` | Authenticated user's to-do inbox — notifications about issues, MRs, mentions, reviews requested |
| `list_my_events` | Authenticated user's activity feed — pushes, MRs, comments, approvals |
| `list_user_events` | Another user's public activity feed by username or ID |
| `list_project_events` | Activity events for a specific project |
| `resolve_path` | Resolve a path to a project or group |
| `get_available_queries` | Discover available GraphQL operations |
| `execute_custom_query` | Run custom GraphQL queries |

### Achievements

GitLab Achievements are available on all tiers when enabled on the instance (introduced behind a feature flag in 15.10, enabled by default in 19.2, generally available in 19.3). Writes require a full-access token and group Maintainer/Owner permissions.

| Tool | Description |
|------|-------------|
| `list_achievements` | List group achievements by cursor, optionally filter IDs and preview active recipients |
| `create_achievement` | Create a named group achievement with an optional description |
| `update_achievement` | Update name or description; an empty description clears it |
| `delete_achievement` | Delete an achievement and all associated awards |
| `award_achievement` | Award to an exact username with an optional message |
| `revoke_achievement` | Revoke a specific award ID or look up a unique active award by group, achievement, and username |

IDs can be numeric or GitLab global IDs. `list_achievements` uses `first`, `after`, and `fetchAll` as above; `fetchAll` stops at `first` items. `includeRecipients` adds one page of active awards per achievement with its own `pageInfo`; it does not fetch every recipient. Use the returned award ID (`userAchievementId`) for precise revocation. The username lookup scans recipient pages, stops with an explicit error if the scan limit is reached, and requires an award ID when multiple active awards match.

GitLab may require recipient acceptance before an award appears on a profile and sends an award notification. Avatar uploads and user profile display/reordering are managed in GitLab. See [GitLab Achievements documentation](https://docs.gitlab.com/user/profile/achievements/) and the [GraphQL mutation reference](https://docs.gitlab.com/api/graphql/reference/#mutationachievementscreate).

### Write Operations (require user authentication)
| Tool | Description |
|------|-------------|
| `create_issue` | Create new issues |
| `create_merge_request` | Create new merge requests |
| `create_note` | Add a comment/note to an issue or merge request |
| `update_issue` | Update title, description, assignees, labels, due date |
| `update_merge_request` | Update title, description, assignees, reviewers, labels |
| `update_note` | Edit the body of an existing comment |
| `delete_issue` | Delete an issue (issue author or maintainer required) |
| `delete_note` | Delete a comment (note author or maintainer required) |
| `manage_pipeline` | Retry or cancel a CI/CD pipeline |
| `create_broadcast_message` | Create a broadcast message (instance admin) |
| `update_broadcast_message` | Update a broadcast message (instance admin) |
| `delete_broadcast_message` | Delete a broadcast message (instance admin) |
| `mark_todo_done` | Mark a single to-do item as done |
| `mark_all_todos_done` | Mark all pending to-do items as done for the authenticated user |
| `restore_todo` | Restore a previously-done to-do item back to pending |

---

## Configuration

### Environment variables

| Variable | Description | Default |
|---|---|---|
| `GITLAB_URL` | GitLab instance URL | `https://gitlab.com` |
| `GITLAB_TOKEN` | Full-access fallback token (reads + writes) | — |
| `GITLAB_READ_TOKEN` | Read-only fallback token (writes always rejected) | — |
| `GITLAB_PIN_HOST` | Force every request to `GITLAB_URL`, ignoring per-call/header `gitlabUrl` (SSRF guard). Set `false` to serve multiple instances | `true` |
| `GITLAB_ALLOW_SHARED_ESCAPE_HATCH` | Allow `execute_custom_query` / `execute_rest_read` / `execute_rest_write` to run on the shared token (otherwise they require per-call credentials) | `false` |
| `GITLAB_MAX_PAGE_SIZE` | Maximum items per page (1–100) | `50` |
| `GITLAB_TIMEOUT` | Request timeout in milliseconds | `30000` |
| `GITLAB_MAX_CONCURRENCY` | Max concurrent in-flight requests to GitLab (excess queue, never rejected). `0` = unlimited | `16` |
| `GITLAB_ANALYTICS_MAX_PROJECTS` | Cap on projects scanned per group-analytics fan-out (logs when bounded) | `500` |
| `GITLAB_MCP_PORT` | HTTP server port | `8008` |
| `MCP_TRANSPORT` | Transport mode (`http` for LibreChat) | `stdio` |
| `MCP_ALLOWED_ORIGINS` | Comma-separated browser origins allowed for HTTP requests. Defaults to localhost/127.0.0.1 on the configured port plus the origin of `MCP_SERVER_URL`. Requests without Origin remain supported | — |
| `TRUST_PROXY` | Express `trust proxy` when behind a reverse proxy (hop count like `1`, boolean, or IP/subnet list). Needed for correct per-IP OAuth rate limiting behind traefik/nginx | unset |
| `GITLAB_MCP_OAUTH` | Enable brokered OAuth 2.1 (HTTP mode) — see [OAuth for remote users](#oauth-for-remote-users) | `false` |
| `MCP_SERVER_URL` | Public HTTPS URL of this server (OAuth issuer/resource id) — required when OAuth is on | — |
| `GITLAB_OAUTH_CLIENT_ID` | GitLab application id — required when OAuth is on | — |
| `GITLAB_OAUTH_CLIENT_SECRET` | GitLab application secret (omit for a public/PKCE-only app) | — |
| `GITLAB_OAUTH_SCOPES` | Space-separated GitLab scopes to request | `api` |
| `GITLAB_OAUTH_CALLBACK_PATH` | Path of the fixed GitLab redirect URI | `/gitlab/callback` |
| `GITLAB_OAUTH_ALLOWED_GROUPS` | Restrict the connector to members of these GitLab groups (comma/space separated full-paths; subgroups included). Empty = any authenticated user | unset |
| `REDIS_URL` | Back OAuth broker state with Redis (survives redeploys, enables >1 replica). Unset = in-memory | unset |
| `REDIS_KEY_PREFIX` | Base namespace for Redis keys (issuer host is appended) | `gitlab-mcp` |

`GITLAB_TOKEN` and `GITLAB_READ_TOKEN` are **mutually exclusive**; setting both is a startup error.

### Removed in 1.14.0

- `GITLAB_AUTH_MODE` — the three-way enum is gone. Pick your deployment shape by which env var you set; see [Choose your deployment](#choose-your-deployment).
- `GITLAB_SHARED_ACCESS_TOKEN` — rename to `GITLAB_TOKEN` (full access) or `GITLAB_READ_TOKEN` (read-only).

Old env vars trigger a deprecation warning at startup and are otherwise ignored.

---

## Troubleshooting

**"This operation requires authentication" / "Write operation requires a user token":**
- For LibreChat: check the user filled in their PAT in the MCP Tool Select Dialog (or in their `customUserVars`).
- For Claude Code: confirm `GITLAB_TOKEN` is set in the MCP server's `env` block.
- If you set `GITLAB_READ_TOKEN`, write operations against it are rejected by design — supply per-call user creds for writes.

**Connection issues with LibreChat:**
- `type: streamable-http` (not `sse`).
- URL is the Docker service name (`http://gitlab-mcp:8008/`), not localhost, when LibreChat and gitlab-mcp share a Docker network.
- `docker logs gitlab-mcp` shows session init and request lines.

**Schema introspection failed:**
- GitLab 12.0+ with GraphQL API enabled.
- Verify `GITLAB_URL` is reachable from the container.

**Debug logging:**

```bash
NODE_ENV=development GITLAB_TOKEN=glpat-... npm start
```

**Health check (HTTP mode):**

```bash
curl http://localhost:8008/health
```

---

## Changelog

See [`CHANGELOG.md`](./CHANGELOG.md). Releases before 1.14.0 are in
[GitHub releases](https://github.com/ttpears/gitlab-mcp/releases).

## Runtime and MCP compatibility

Node.js 22 or later is required; the container uses Node.js 24 LTS. HTTP browser clients must use an allowed origin (`MCP_ALLOWED_ORIGINS`); public deployments should set `MCP_SERVER_URL`. Native clients without an `Origin` header continue to work. Tool execution and input-validation failures return `isError: true` with a text explanation. Object results include `structuredContent` alongside the JSON text for older clients.


## License

[MIT](./LICENSE)

TDQS

B3.3/5.0

Scored across 63 tools

Disambiguation2/5

There is significant overlap between search_gitlab, search_issues, search_merge_requests, search_notes, and the various get_*/list_* tools. For example, get_issues and search_issues both list/filter issues, and get_project vs get_projects vs get_group_projects can be confusing. The presence of generic escape hatches (execute_rest_read/write, execute_custom_query) further blurs boundaries, making tool selection error-prone.

Naming Consistency3/5

Most tools use consistent verb_noun patterns (e.g., get_project, create_issue, delete_note, search_users), but there are deviations: 'manage_pipeline' uses a vague verb, 'analytics_user_summary' starts with a noun, and 'get_type_fields' and 'get_available_queries' are meta-tools that don't follow the resource-action model. Mixing of get_/list_/search_ prefixes is acceptable but not perfectly uniform.

Tool Count2/5

With 63 tools, the server is heavily overloaded. Many tools are redundant or overly granular (e.g., separate context-bundling tools, multiple analytics tools, separate list_broadcast_messages vs get_broadcast_message). A typical GitLab MCP would consolidate to 20-30 tools; the current count strains agent decision-making and prompt context.

Completeness4/5

The surface covers core GitLab workflows well: project/issue/MR CRUD, notes, pipelines, todos, users, groups, repository browsing, searches, and analytics. Minor gaps include lack of direct tools for repository commits/branches (only file browsing), and some admin/ops endpoints are only accessible via execute_rest_write. Overall, the set is nearly complete but slightly fragmented.

Maintenance

ActivityMaintained
ResponsivenessSlow