Skip to main content
Glama
README.md
# MCP GitLab

MCP server for sandboxed agents that forwards every request to the official MCP server of a GitLab instance and adds the GitLab token on the server side.

GitLab ships its own MCP server at `<instance>/api/v4/mcp`, in the Free tier and in the Community Edition. An agent in a sandbox should use it without ever holding a GitLab token. `mwaeckerlin/mcp-gitlab` stands between the two: the sandbox talks to it exactly as it talks to [mwaeckerlin/mcp-github](https://github.com/mwaeckerlin/mcp-github) and [mwaeckerlin/mcp-gitea](https://github.com/mwaeckerlin/mcp-gitea), and the server passes each request to GitLab with the token it reads from a Docker secret. The tools are the ones GitLab offers, so they follow the GitLab version without a release of this server.

The numbered feature list is in [FEATURES.md](FEATURES.md), the tests in [TESTS.md](TESTS.md), the architecture in [ARCHITECTURE.md](ARCHITECTURE.md).

## Security Model

1. The sandboxed client calls this server, at `MCP_GITLAB_URL`.
2. The server, outside the sandbox, reads the GitLab token from the file `GITLAB_TOKEN_FILE` names and adds it as `Authorization: Bearer` to the request it passes to `<GITLAB_URL>/api/v4/mcp`.
3. GitLab decides with that token what the call may do.

- No GitLab token in the sandbox, in a tool argument or in the environment of the server.
- Only the one address `<GITLAB_URL>/api/v4/mcp` is asked; the path and the query of a request never reach GitLab.
- Only the headers of the MCP transport and the tool selection of GitLab pass; the `Authorization` header of the sandbox, which carries `MCP_AUTH_TOKEN`, cookies and every other header stay on the server.
- The OAuth challenge of GitLab stays on the server, so the agent does not start a login of its own; a redirect of GitLab is not followed with the token.
- No token appears in an error message of the server.

## Configuration

### Server

Every variable is optional; an empty value counts as unset.

| Variable | Default | Effect |
|---|---|---|
| `GITLAB_URL` | empty: nothing is forwarded, MCP requests are answered 503 `gitlab_not_configured` | address of the GitLab instance, such as `https://gitlab.example.com`, with its sub path where GitLab lives under one; the server forwards to `<GITLAB_URL>/api/v4/mcp`. An address without `http://` or `https://`, or with a query, a fragment or credentials, stops the start |
| `GITLAB_TOKEN_FILE` | empty: no token, GitLab answers 401 | file with the fine-grained personal access token of GitLab, such as the Docker secret `/run/secrets/gitlab_token`; the line breaks at its end fall away; a named file that does not exist, is a directory, cannot be read or is empty, or a line break or another control character inside the token, stops the start |
| `MCP_AUTH_TOKEN_FILE` | empty | file with the shared token that the sandbox presents, such as the Docker secret `/run/secrets/mcp_auth_token` |
| `MCP_AUTH_TOKEN` | empty: every request is accepted | the shared token itself, as in `mwaeckerlin/mcp-github`; set it or `MCP_AUTH_TOKEN_FILE`, both together stop the start |
| `GITLAB_TIMEOUT` | `120` | seconds the server waits for the answer of GitLab before it answers 502 `gitlab_unreachable`; 1 to 86400 |
| `MCP_GITLAB_HOST` | `0.0.0.0` | address the server listens on |
| `MCP_GITLAB_PORT` | `4000` | port the server listens on; 1 to 65535 |

A GitLab with a certificate of a private authority needs that authority in `NODE_EXTRA_CA_CERTS`, the file variable of Node.js.

The GitLab token is read from a file only. A token in the environment shows in `docker inspect` and in the environment of every process of the container; a Docker secret keeps it in a file under `/run/secrets`.

### Security Trade-offs

- **Without `MCP_AUTH_TOKEN`** the server accepts every request and acts with the token of the server: every client that reaches port 4000 can do in GitLab what the owner of the token may do. Publish no port where the sandbox reaches the server over a Docker network, set `BIND_ADDRESS=127.0.0.1` for the sample stack, or set `MCP_AUTH_TOKEN_FILE`.
- **An `http://` address in `GITLAB_URL`** sends the token unencrypted and belongs on a closed network only, like the test stack.
- **`GITLAB_TIMEOUT`** bounds the wait for the first answer of GitLab; an event stream that has begun has no bound, because GitLab sends it at its own pace.
- **The `token` query parameter** can land in the log of a proxy in front of the server; the `Authorization: Bearer` header does not.

### Sandbox

| Variable | Effect |
|---|---|
| `MCP_GITLAB_URL` | where the MCP client of the sandbox reaches the server, such as `http://mcp-gitlab:4000` |
| `MCP_AUTH_TOKEN` | the shared token, sent as `Authorization: Bearer <token>` or as `?token=<token>`, where the server requires one |

### GitLab Token

GitLab accepts two kinds of token at its MCP server: an OAuth access token with the scope `mcp`, and a fine-grained personal access token. For a server without a person in front of it, the fine-grained token is the one that fits:

1. Sign in to GitLab as the user the agent acts as; the agent can do exactly what this user may do.
2. Avatar › **Edit profile** › **Access** › **Personal access tokens** › **Generate token** › **Fine-grained token**.
3. Name and expiry date; under **Add resource permissions** the tab **User**, the resource **MCP tool**, the permission **Execute**.
4. Store the token in a file, hand it to the server as a Docker secret, and point `GITLAB_TOKEN_FILE` at it.

A legacy personal access token is refused with 403, also with the scope `api`: GitLab CE 19.5.3 answers `{"message":"403 Forbidden"}`, its documentation names the error `insufficient_scope` and the same refusal for project and group access tokens. The administrator of a self-managed instance allows the MCP server with the instance setting «Allow access to the MCP server», which is on in a fresh GitLab CE 19.5.3; while it is off, GitLab answers 403 `MCP server disabled for this instance`.

### Ports

The server listens on port `4000`. [docker-compose.yml](docker-compose.yml) publishes it on every address of the host, IPv4 and IPv6, as long as `BIND_ADDRESS` is unset or empty. `BIND_ADDRESS` limits it to one address of the host: `127.0.0.1` serves the local machine alone, an IPv6 address stands in brackets, such as `[::1]`. `0.0.0.0` publishes on every IPv4 address and on no IPv6 address. Where the sandbox reaches the server over a Docker network, publish no port at all.

### Sample Stack

[docker-compose.yml](docker-compose.yml) reads the token from the file `gitlab_token` beside it, or from the file `GITLAB_TOKEN_SECRET` names, and needs `GITLAB_URL`:

```bash
GITLAB_URL=https://gitlab.example.com npm start
```

### Health

- `GET /healthz` answers 200 while the process runs, without `MCP_AUTH_TOKEN`.
- With `GITLAB_URL` and a token: `{ "ok": true, "status": "ready", "gitlabUrlConfigured": true, "gitlabTokenConfigured": true }`.
- Otherwise `status` is `degraded`, and `message` names the missing variable.
- The image carries a `HEALTHCHECK` that asks `/healthz` and prints its answer; it runs `/usr/bin/node` directly, because the image has no shell. The container is healthy in both states, so a compose file or a Swarm stack needs no healthcheck of its own.

## Usage through MCP

The sandbox connects with the streamable HTTP transport of MCP to `MCP_GITLAB_URL`. GitLab answers every `POST` with JSON and offers no event stream: `GET` returns its 405. `tools/list` returns the tools of the GitLab version, with their argument schemas. Three request headers of GitLab choose the tools: `X-Gitlab-Enabled-Mcp-Server-Toolsets` names toolsets, such as `ci`; `X-Gitlab-Enabled-Mcp-Server-Tools` names single tools; `X-Gitlab-Mcp-Server-Tool-Name-Prefix` puts a prefix before every tool name, such as `gitlab_`.

Example call, read a project:

```json
{ "name": "get_project", "arguments": { "project_id": "mwaeckerlin/mcp-gitlab" } }
```

`SKILL.md` carries the instructions for the agent; the image ships it at `/app/skills/mcp-gitlab/SKILL.md`.

## Troubleshooting

| Answer | Cause | Action |
|---|---|---|
| 401 `unauthorized` of mcp-gitlab | the sandbox sent no or a wrong `MCP_AUTH_TOKEN` | send `Authorization: Bearer <MCP_AUTH_TOKEN>` |
| 401 of GitLab | no token on the server, or GitLab does not know it | set `GITLAB_TOKEN_FILE` to a valid token |
| 403 `{"message":"403 Forbidden"}` of GitLab | legacy, project or group token | use a fine-grained token with «MCP tool: Execute» |
| 403 `MCP server disabled for this instance` | the MCP server is switched off in the instance settings | the administrator allows «Allow access to the MCP server» |
| 502 `gitlab_unreachable` | GitLab did not answer within `GITLAB_TIMEOUT` or not at all | check `GITLAB_URL` and the network; the message names the cause |
| 503 `gitlab_not_configured` | `GITLAB_URL` not set | set `GITLAB_URL` |
| start refused: `GITLAB_TOKEN_FILE names <path>, which cannot be read: …` | the file does not exist, is a directory, or the server's user may not read it | mount the secret at that path, or correct `GITLAB_TOKEN_FILE` |
| start refused: `GITLAB_TOKEN_FILE names <path>, which is empty` | the secret holds no token | recreate the secret with the token |
| start refused: `GITLAB_TOKEN_FILE names <path>, which holds a line break inside the token`, `MCP_AUTH_TOKEN holds a control character inside the token` | the token has a line break or another control character inside, often two tokens or a text pasted into the secret; no such token can stand in an HTTP header | recreate the secret, or set the variable, with the token alone |
| start refused: `MCP_AUTH_TOKEN and MCP_AUTH_TOKEN_FILE are both set` | both ways of giving the shared token are configured | remove one of them |

## Development

```bash
npm install
npm run build:ts
npm run build
npm test
```

`npm run dev` starts the server from the sources. `npm test` runs the groups of [TESTS.md](TESTS.md) in one regression run: the feature register check, the compose contract, the type check and the unit tests, the dependency audit, the image contract and the end-to-end suite; a failed group does not stop the others, an interrupted run resumes where it was cut off, and `npm test -- --from <ID> --to <ID>` runs a range. The end-to-end suite (`tests/e2e.sh`, `test/docker-compose.yml`) starts a real GitLab CE from `gitlab/gitlab-ce`, in which `test/gitlab/setup.rb` creates a user, a private project, a fine-grained token and a legacy token; four servers with different tokens and a test client run beside it on internal networks, and the stack is removed with its volumes afterwards. `npm run test:e2e:up` starts and sets up that GitLab once and leaves it standing, so that every following `npm run test:e2e` and `npm test` builds and starts only the servers and the test client against it; `npm run test:e2e:down` removes it. GitLab CE 19.5.3 needed about four minutes until its `/-/readiness` answered and held 2.8GiB of memory; the health check of its image reports healthy before Rails can start, so the stack waits for `/-/readiness`, and the root password of the stack contains no common words, because GitLab refuses such a password and its database setup then fails. The image is built, tested and published for `linux/amd64` and `linux/arm64` by the reusable workflow of [mwaeckerlin/scratch](https://github.com/mwaeckerlin/scratch#publishing-on-docker-hub); `npm run deploy` pushes it.