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

[![CI](https://github.com/igordev96/gitlab-readonly-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/igordev96/gitlab-readonly-mcp/actions/workflows/ci.yml)

A small, read-only, **git-backed** MCP server for a self-managed GitLab.

It lets an AI agent read files from **any branch, tag, or commit** — including
branches that are not checked out locally — without ever touching your working
tree. Content comes from a local bare mirror the server keeps in a cache
directory, or from a clone of your own when you register one.

## Why git instead of the GitLab REST API?

Reading a file at a ref, comparing branches, history, blame and code search are
git's strengths and, on a self-managed free tier, the REST API's weaknesses.
[How it works](docs/internals.md) has the longer answer.

## Requirements

- Node.js 24+ (developed on Node 24.21)
- `git` on `PATH`
- A GitLab token with the **`read_repository`** scope (a personal or project
  access token). Never use `api` or `write_repository`.

## Install

Install the tarball attached to the [latest
release](https://github.com/igordev96/gitlab-readonly-mcp/releases/latest). No
checkout, no build, no npm account:

```powershell
npm install -g https://github.com/igordev96/gitlab-readonly-mcp/releases/download/v0.10.1/gitlab-readonly-mcp-0.10.1.tgz
```

Each release carries `SHA256SUMS` beside the tarball if you want to check the
download before installing it. The package is not on npm (`private: true`), and
the **Source code** archives GitHub attaches to a release are repository
snapshots without `dist/` — they install nothing.

To work on the server instead, build the tarball from your checkout:

```powershell
npm install
npm pack --pack-destination .          # gitlab-readonly-mcp-0.10.1.tgz
npm install -g .\gitlab-readonly-mcp-0.10.1.tgz
```

Either way, `gitlab-readonly-mcp` lands on `PATH` and speaks MCP on stdio from
any directory. To upgrade, install the newer release's tarball. To uninstall,
`npm rm -g gitlab-readonly-mcp`.

Under `nvm`, global packages live inside the active Node version, so switching
versions hides the command until you reinstall it.

## Configuration

All configuration is via environment variables.

| Variable | Required | Description |
|---|---|---|
| `GITLAB_HOST` | yes | Base URL of the instance, e.g. `https://gitlab.example.com` (no trailing slash). |
| `GITLAB_TOKEN` | yes | Read-only token (`read_repository`). Injected per git call via `http.extraheader`; never written to `.git/config`. |
| `GITLAB_DEFAULT_PROJECT` | no | Project path used when a tool call omits `project`, e.g. `group/subgroup/repo`. |
| `GITLAB_MCP_CACHE` | no | Cache root for mirrors. Defaults to `%LOCALAPPDATA%\gitlab-readonly-mcp` (or `~/.cache/...` elsewhere). |
| `GITLAB_CA_INFO` | no | Path to a CA bundle, if the server uses a private CA. |
| `GITLAB_GIT_BIN` | no | Git executable. Defaults to `git`. |
| `GITLAB_FETCH_MODE` | no | Default freshness policy for every project: `ttl` (default) fetches only when a mirror is older than the TTL, `always` fetches before every read, `manual` never fetches by itself. A project can override it in `repos.json`. |
| `GITLAB_FETCH_TTL_SECONDS` | no | Age in seconds after which a mirror counts as stale. Default `600` (10 minutes). `0` behaves like `always`. |

These are the defaults; a project can override its freshness policy in
`repos.json` (see [Repositories and policy](#repositories-and-policy)).

## Wire into OpenCode

```powershell
opencode mcp add gitlab --global `
  --env GITLAB_HOST=https://gitlab.example.com `
  --env 'GITLAB_TOKEN={env:GITLAB_TOKEN}' `
  --env GITLAB_DEFAULT_PROJECT=group/subgroup/repo `
  -- gitlab-readonly-mcp
```

Or add it to configuration by hand (`mcp.servers`):

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "servers": {
      "gitlab": {
        "type": "local",
        "command": ["gitlab-readonly-mcp"],
        "environment": {
          "GITLAB_HOST": "https://gitlab.example.com",
          "GITLAB_TOKEN": "{env:GITLAB_TOKEN}",
          "GITLAB_DEFAULT_PROJECT": "group/subgroup/repo"
        }
      }
    }
  }
}
```

The command is the installed binary, so the entry point no longer depends on
where the checkout lives — a global MCP server's working directory is the
workspace it is launched from, not this project. If the bare name does not
resolve on your machine, `npm prefix -g` gives the prefix;
`<prefix>\gitlab-readonly-mcp.cmd` is the same package by absolute path.

The tools appear as `gitlab_<tool>` — `gitlab_get_file`, `gitlab_log`, and so
on. All sixteen are listed below.

## Wire into Cursor

Cursor reads MCP servers from a `mcpServers` object, in `.cursor/mcp.json` for
one project or `~/.cursor/mcp.json` for every project
(`%USERPROFILE%\.cursor\mcp.json` on Windows). It starts the command from
[Install](#install), so that has to be on `PATH` first.

```jsonc
{
  "mcpServers": {
    "gitlab": {
      "command": "cmd",
      "args": ["/c", "gitlab-readonly-mcp"],
      "env": {
        "GITLAB_HOST": "https://gitlab.example.com",
        "GITLAB_TOKEN": "${env:GITLAB_TOKEN}",
        "GITLAB_DEFAULT_PROJECT": "group/subgroup/repo"
      }
    }
  }
}
```

**On Windows the `cmd /c` wrapper is not optional.** The installed package is a
`.cmd` shim, and Cursor starts the command itself rather than through a shell:
`"command": "gitlab-readonly-mcp"` fails with `ENOENT` and the server never
appears in the list. On macOS and Linux the bare name is correct:

```jsonc
"command": "gitlab-readonly-mcp",
```

Quit Cursor completely and reopen it; a window reload is not always enough. The
server then shows under **Settings → MCP**, and anything it printed on the way
up is in **Output → MCP Logs** — the first place to look when the entry exists
but its tools do not.

Two traps, both silent:

- **The interpolation syntax is not OpenCode's.** Cursor resolves
  `${env:NAME}`; OpenCode resolves `{env:NAME}`. Copy a config from one to the
  other unchanged and the literal string is passed as the token, which GitLab
  rejects.
- **`${env:GITLAB_TOKEN}` reads Cursor's own environment**, not your shell's
  history: a variable set only in the current terminal is invisible to an app
  started from the taskbar. Use the OS-level variable, or point `envFile` at a
  file: `"envFile": "${workspaceFolder}/.env"`. A token pasted literally works
  too, but then `.cursor/mcp.json` must stay out of git.

## What it can do

| Tool | What it answers |
|---|---|
| **Files and trees** | |
| `get_file` | One file at a ref — the whole file or a line range. Accepts a blob, raw or blame link. |
| `list_tree` | A directory at a ref, one level deep or recursive. Accepts a tree link. |
| **Refs** | |
| `list_refs` | Branches and tags, with the commit each points at. |
| `resolve_ref` | What a branch, tag or sha resolves to, with close matches when the name is wrong. |
| **Search** | |
| `search` | Grep a ref's tree, grouped by file — matched lines, file paths, or per-file counts. |
| `pickaxe` | The commits whose diff changed how often a text appears. |
| **History and diffs** | |
| `log` | A ref's history, optionally for one path. Accepts a history link. |
| `get_commit` | One commit: message, parents, author, files, line counts, and the patch on request. Accepts a commit link. |
| `blame` | Who last touched each line, grouped by commit. Accepts a blame link. |
| `compare` | What changed on one ref since it split from another, as a capped file summary. |
| `diff_file` | One file's patch between two refs. |
| **Links** | |
| `parse_gitlab_url` | What a GitLab link names — project, target, ref, path and line range — without reading anything. |
| **Repository policy** | |
| `list_repos` | Every project in use: its source, its policy and its true age. |
| `sync_repo` | Fetch now, ignoring the TTL. |
| `add_repo` | Read a project from a clone of your own instead of a mirror. |
| `remove_repo` | Forget a project. Never deletes your clone. |

## Quickstart

Examples use `group/sub/repo`; set `GITLAB_DEFAULT_PROJECT` to leave `project=`
out. Every result says which commit answered and how fresh it is — see
[What the odd bits mean](#what-the-odd-bits-mean).

**Read a file from a branch you have not checked out.** No clone of your own, no
checkout, no change to your working tree:

```
gitlab_get_file(project="group/sub/repo", ref="fix/PROJ-7608", path="src/index.tsx")
```

```
<file project="group/sub/repo" ref="fix/PROJ-7608" commit="abc123…" path="src/index.tsx" synced_at="2026-10-18T09:12:44.318Z">
…file contents…
</file>
```

**Browse a tree before reading.** Files carry their size, so a small config is
easy to tell from a bundle. `recursive=true` walks the whole subtree:

```
gitlab_list_tree(project="group/sub/repo", ref="fix/PROJ-7608", path="src")
```

```
<tree project="group/sub/repo" ref="fix/PROJ-7608" commit="abc123…" path="src" count="3" synced_at="…">
blob	3047	src/App.tsx
tree	-	src/components
blob	412	src/theme.css
</tree>
```

**Find and confirm a branch.** `list_refs` searches names; `resolve_ref` turns
one into a commit, and suggests close matches when the name is wrong:

```
gitlab_list_refs(project="group/sub/repo", kind="branches", search="PROJ")
```

```
<refs project="group/sub/repo" kind="branches" default_branch="develop" count="1" synced_at="…">
branch	abc123def456	fix/PROJ-7608	PROJ-7608: adjust the header
</refs>
```

**Find where something is used** — no clone, no knowledge of the file:

```
gitlab_search(project="group/sub/repo", ref="fix/PROJ-7608", query="orderTotals")
```

```
<search project="group/sub/repo" ref="fix/PROJ-7608" commit="abc123…" query="orderTotals" count="2" files="1" synced_at="…">
src/reports/OrderTotals.tsx
  22: export const OrderTotalsReport = () => {
  64:   title={translations.reporting.seeOrderTotalsPage}
</search>
```

`output="count"` answers "how many, in which files" without listing every match;
`context=2` adds the lines around each hit. `pickaxe(query=…)` answers the
follow-up: *when* did this text appear?

**Read a pasted link, including the lines it names.** A `#L118-126` anchor
becomes the range:

```
gitlab_get_file(url="https://gitlab.example.com/group/sub/repo/-/blob/fix/PROJ-7608/src/index.tsx#L118-126")
```

```
<file project="group/sub/repo" ref="fix/PROJ-7608" commit="abc123…" path="src/index.tsx" lines="118-126" file_lines="373" synced_at="…">
118: …
</file>
```

Each link kind has one reader: blob, raw and blame links go to `get_file` (or
`blame`), tree links to `list_tree`, `/-/commit/` to `get_commit`, `/-/commits/`
to `log`. A link handed to another tool is refused by name and says which tool
wants it. `parse_gitlab_url(url)` reports what a link names without reading
anything, and `resolve=false` does it offline.

**See the history, and who last touched a line.** `path` is optional and is a git
pathspec, so globs work:

```
gitlab_log(project="group/sub/repo", ref="fix/PROJ-7608", path="src/index.tsx")
```

```
<log project="group/sub/repo" ref="fix/PROJ-7608" commit="abc123…" path="src/index.tsx" count="2" synced_at="…">
abc123def4	2026-10-15T12:32:33+01:00	A. Author	PROJ-7608: adjust the header
def456abc7	2026-10-14T09:15:02+01:00	B. Author	Merge branch 'develop' into fix/PROJ-7608	(merge)
</log>
```

```
gitlab_blame(project="group/sub/repo", ref="fix/PROJ-7608", path="src/index.tsx", lines="118-126")
```

```
<blame project="group/sub/repo" ref="fix/PROJ-7608" commit="abc123…" path="src/index.tsx" lines="118-126" file_lines="373" groups="2" count="9" max_lines="500" synced_at="…">
def456abc789	B. Author	2026-04-23T10:11:50+02:00	118-121
  118: …
abc123def456	A. Author	2026-10-15T12:32:33+01:00	122-126
  122: …
</blame>
```

`get_commit(rev=…)` reads one commit — message, files, line counts, and the
patch with `patch=true`.

**Compare two branches.** `compare` prints the merge base it used, so which diff
ran is never hidden:

```
gitlab_compare(project="group/sub/repo", from="develop", to="fix/PROJ-7608")
```

```
<compare project="group/sub/repo" from="develop" to="fix/PROJ-7608" from_commit="…" to_commit="…" merge_base="def456abc789…" files="1" total_files="1" additions="32" deletions="11" max_files="100" synced_at="…">
M	src/index.tsx	+32 -11
</compare>
```

`diff_file(from=…, to=…, path=…)` is one file's patch; pass the `merge_base` it
printed as `from` for the branch's own view of the file.

## Gotchas

- `search` and `pickaxe` match **literally and case-insensitively**: `foo|bar`
  looks for that text. Pass `regex=true` for alternation, `case_sensitive=true`
  for exact case.
- A plain pathspec's `**` needs a directory level — `src/**/*.tsx` misses
  `src/index.tsx`. Use `:(glob)src/**/*.tsx` for zero or more.
- Omit `ref` to read the default branch, which is what a fresh clone shows you.
- `#L12` and `#L12-20` anchors are 1-based and inclusive.
- `compare` is three-dot by default; `merge_base=false` asks for the raw two-dot
  diff.
- Binary files are refused where the answer would be meaningless (a read, a
  blame); a diff gives git's `Binary files … differ` line instead of a patch.
- Caps are always reported: `truncated="true"`, `total=…` and `[cut: …]` say
  when an answer was shortened, rather than quietly returning less.

## What the odd bits mean

| You see | It means |
|---|---|
| `synced_at="…"` | When the source that answered last fetched successfully. `unknown` means no successful fetch has ever stamped it. |
| `refresh_failed="true"` | A refresh was due and failed, so the answer came from the cache; a `[refresh failed: …]` note says why. |
| `truncated="true"` / `total=…` | The answer was capped. Where the tool can count it, `total` is the real number before the cap. |
| `ref_confirmed="false"` | `parse_gitlab_url` could not match the link's ref against the project's refs — it is offline, or the link names a sha. `commit="…"` is the resolved revision when the link names one. |
| `merge_base="none"` | The two refs share no history, so `compare` ran the raw two-dot diff and said so. |
| `(no matches)` | Nothing matched. That is an answer, not an error. |

## Repositories and policy

A project registers itself the first time it is used — its mirror is cloned on
demand — or explicitly with `add_repo`. The registry is a small `repos.json` in
the cache directory:

```
<repos count="2">
group/sub/repo	source=mirror	freshness=ttl	ttl=600s	present=true	synced_at="2026-10-18T21:44:25.655Z"
group/sub/other	source=local	freshness=ttl	ttl=600s	present=true	local_path=C:\work\other	synced_at="unknown"
</repos>
```

`synced_at` is when **that source** last fetched successfully, or `unknown` when
no fetch has ever stamped it; `present=false` means the project is registered but
its data is not on disk — a mirror never cloned, or a clone since moved. A
project's policy can be overridden by hand (absent fields mean the configured
default):

```jsonc
{
  "version": 1,
  "repos": {
    "group/sub/repo": {
      "source": "mirror",          // "mirror" | "local"
      "localPath": null,           // set when source == "local"
      "cloneStrategy": "full",
      "freshnessMode": "manual",   // "ttl" | "always" | "manual"
      "freshnessTtlSeconds": 3600
    }
  }
}
```

`add_repo` merges into what is already recorded, so re-pointing a project cannot
reset a `freshnessMode` you set by hand. `sync_repo(project)` fetches immediately
and follows the project's source (a mirror, or the clone's own remote) — that is
what makes `manual` usable, and what to call when you want a fresh answer now.
Unlike a read, `sync_repo` reports a failure as an error rather than answering
from the cache.

## Reading from a clone of your own

```
gitlab_add_repo(project="group/sub/repo", local_path="C:\\work\\repo")
```

The path is checked there and then — it must exist and be a git repository,
working clone or bare — so a typo fails at the call that records it. From then on
every read runs against that clone: nothing is cloned, and nothing is written to
your working tree, so uncommitted work is untouched. A branch that is not the
checked-out one is still found through its remote-tracking ref, because that is
where `git clone` keeps every other branch. A clone with no remote still reads
everything it holds; a miss says so plainly instead of pretending the ref does
not exist.

```
gitlab_add_repo(project="group/sub/repo")             # back to a mirror
gitlab_remove_repo(project="group/sub/repo")          # forget it entirely
gitlab_remove_repo(project="group/sub/repo", delete_mirror=True)
```

`remove_repo` never deletes a clone: that repository is yours, and the server
only ever deletes inside its own `mirrors/` directory. Forgetting a
mirror-backed project does not stick, because the next read registers it again;
forgetting a *local* registration does, and is how a project goes back to being
read from a mirror.

## Troubleshooting

A failed read explains itself: the cause, a hint when the failure is one we
recognise, and git's own output as evidence (`git said: …`). No extra logging is
needed to find out what happened.

| What you see | What it means |
|---|---|
| *GitLab rejected the credentials* | `GITLAB_TOKEN` is missing, expired, or lacks `read_repository` access to the project. GitLab reports a project you cannot *see* as a 401 too, so a typo in the project path looks identical. |
| *GitLab could not find this project* | Check the project path (`group/subgroup/repo`) and the token's access to it. |
| *TLS verification failed* | The server uses a private CA; point `GITLAB_CA_INFO` at the CA bundle. |
| *Could not reach GitLab* | Network or VPN. |
| `[refresh failed: …]` in a result | The source could not be refreshed, so the answer came from the cache as of `synced_at`. The note says why; the causes are the same as for a failed read. |
| *The registered clone at … is not a usable git repository* | The path recorded by `add_repo` has been moved, deleted or emptied. Run `add_repo` again with the current path, or `remove_repo` to go back to a mirror. |
| *The registered clone at … has no remote to fetch from* | The clone has no remote configured, so a ref it does not already hold cannot be fetched. Everything it does hold still reads. |
| `search` finds nothing, although you can see the text | Matching is literal and one argument: `foo\|bar` means that text, not `foo` or `bar` — pass `regex=true` for alternation. A plain pathspec's `**` needs a directory level (`src/**/*.tsx` misses `src/index.tsx`; use `:(glob)src/**/*.tsx`). |
| `pickaxe` lists no commits for text you can see | It reports commits whose diff *changed how many times* the text appears (`git log -S`), so text unchanged since the root commit, or brought in by a merge git does not diff, has no commit to list. Matching is literal and case-insensitive; `case_sensitive=true` follows the case exactly. |
| A link is refused: `URL host "…" does not match GITLAB_HOST ("…")` | The link belongs to another GitLab instance. Use a link from the instance `GITLAB_HOST` names; the server will not read a same-named project from elsewhere. |
| A link is refused: *is a file link; use get_file for it* (or *directory*, *commit*, *history*) | The link went to a tool that does not read it. Each kind has one reader: blob/raw/blame → `get_file` (or `blame`), tree → `list_tree`, `/-/commit/` → `get_commit`, `/-/commits/` → `log`. `parse_gitlab_url` says what a link names if it is unclear. |
| A link is refused: *is a merge request link* | Merge requests, issues and other non-code links name no version-control object, so they are refused by name. Take the ref and path from the page and call the tool directly. |
| A `context` call is refused | It needs `output="matches"`; `files` and `count` have no matched line to put context around. |
| `list_repos` prints `clone_strategy=blobless(unsupported)` | A `repos.json` entry asks for a clone strategy this server does not implement, so every read of that project fails with the value named. Set `"cloneStrategy": "full"` if the data on disk is a full clone, or `remove_repo` it with `deleteMirror=true` and use the project again, which clones it in full. |

The credential case is worth one extra note: **a running MCP server keeps the
token it started with.** Editing `GITLAB_TOKEN` does nothing to a server that is
already running, which makes a valid token look broken. Restart the client (for
OpenCode, `opencode service restart`) after changing it.

The first read of a project also clones it, so it is slow exactly once.

## Security

- The token is only ever passed as `-c http.extraheader=…` on the git command
  line, so it never lands in `.git/config`.
- The token is redacted from error output before it reaches a tool result.
- `credential.helper=` is cleared and `GIT_TERMINAL_PROMPT=0` is set, so a bad
  token fails fast instead of prompting or hanging.
- Refs and paths beginning with `-` are rejected to prevent argument injection.
- Git is invoked with an argument array and `shell: false`.

## Read more

- [How it works](docs/internals.md) — where content comes from, freshness, and
  the read-only guarantee.
- [Development](docs/development.md) — build, test, releasing and the source
  layout.
- [Plan](docs/plan.md) — the design decisions and what is next.
- [History](docs/history.md) — milestones, findings, and the evidence from live
  checks.

TDQS

A4.3/5.0

Scored across 16 tools

Disambiguation5/5

Each tool maps to a distinct git or repository-management operation; overlaps like log/get_commit/pickaxe and compare/diff_file are explicitly separated in descriptions. No two tools appear interchangeable.

Naming Consistency4/5

Mostly snake_case with a verb_noun pattern, but several tools are bare git terms (log, blame, compare, search, pickaxe) rather than verb_noun. Casing is consistent and names remain readable, so this is a minor deviation.

Tool Count4/5

16 tools is at the upper end of the ideal 3-15 range, but each adds a distinct capability for git inspection plus mirror administration. Slightly heavy, though still reasonable for the domain.

Completeness4/5

Covers file/tree reading, ref resolution, commit history/detail, blame, diff/compare, search, pickaxe, URL parsing, and mirror registration/sync. Minor gaps: no project discovery on GitLab and no MR/issue support, but the core read-only git surface is complete.

Maintenance

ActivityMaintained
ResponsivenessNo issues