Skip to main content
Glama
gitlab-readonly-mcp
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)
[![Release](https://img.shields.io/github/v/release/igordev96/gitlab-readonly-mcp?label=Release)](https://github.com/igordev96/gitlab-readonly-mcp/releases/latest)

A small, read-only, git-backed MCP server for GitLab (self-managed or gitlab.com).

It lets an AI agent read files from **any branch, tag, or commit**, including
branches that are not checked out locally, without 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.

The install is one command, with no checkout and no build:

```powershell
npm install -g gitlab-readonly-mcp
```

[Install notes](#install): Windows, `nvm`, the form that needs no install at all,
and how to uninstall.

## Who this is for

**Use this when**

- **the only token you can get is a project access token or a legacy PAT.** The
  official GitLab MCP server rejects both. It needs OAuth with the `mcp` scope,
  or a fine-grained PAT with the MCP tool resource. This server needs only
  `read_repository`, and has no write path that could use more;
- **your instance has no Advanced Search**, either because it is Free or because
  a self-managed instance does not configure it. The REST API's code search
  needs the `blobs` scope. Search, blame, pickaxe and compare run over git and
  cost nothing, so one fetch per TTL serves every read instead of an API call
  per tool;
- **the official server is unavailable**: your GitLab is older than 18.3, or the
  instance admin (on gitlab.com, a top-level group Owner) has not enabled it.
  This server works on self-managed and gitlab.com alike, as long as the
  repository can be cloned over HTTPS with a read-only token;
- **you need a branch that is not checked out**, or a file from a commit that
  never reached your machine, and you do not want to disturb the working tree
  you have.

**Not this when**

- **you can use the official GitLab MCP server and want its full feature set.**
  It covers more, and it is the better first choice when your token and
  permissions allow it;
- **the agent has to change something**: write a file, comment, open a merge
  request. This server is read-only on purpose;
- **you need merge requests, issues, pipelines or CI logs.** It reads version
  control, not the tracker.

## What it is not

- **One GitLab instance per server.** `GITLAB_HOST` names a single host; a
  second instance means a second server.
- **No write path.** No configuration enables writing: there is no tool that
  writes, and a `read_repository` token could not use one if there were.
- **Mirrors are full clones.** A project costs its repository size on disk.
  `blobless` is refused, and the reason is in [the risks
  table](docs/plan.md#8-risks). A repository you already have costs nothing:
  register it with `add_repo` and it is read from there.
- **Not a git client.** It never checks anything out, never writes to your
  working tree, and never pushes.

> **Evidence.** Each release was verified against a live GitLab. The milestones
> table in [`docs/history.md`](docs/history.md) records what was checked, what
> failed, and what is still unverified.

## 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. Without Advanced Search, the REST API is weak at most of them.
[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

From [npm](https://www.npmjs.com/package/gitlab-readonly-mcp):

```powershell
npm install -g gitlab-readonly-mcp
```

If the machine cannot reach the npm registry, install the tarball attached to
the [latest
release](https://github.com/igordev96/gitlab-readonly-mcp/releases/latest)
instead. No checkout, no build, no registry:

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

Each release carries `SHA256SUMS` beside the tarball if you want to check the
download before installing it. The **Source code** archives GitHub attaches to a
release are repository snapshots without `dist/`, so 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.11.0.tgz
npm install -g .\gitlab-readonly-mcp-0.11.0.tgz
```

Either way, `gitlab-readonly-mcp` lands on `PATH` and speaks MCP on stdio from
any directory. To upgrade, `npm install -g gitlab-readonly-mcp@latest`, or
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.

**Without a global install.** `npx` runs the published package from npm's cache:

```powershell
npx -y gitlab-readonly-mcp
```

On a machine with no registry access, point `npx` at the release tarball
instead:

```powershell
npx -y --package https://github.com/igordev96/gitlab-readonly-mcp/releases/download/v0.11.0/gitlab-readonly-mcp-0.11.0.tgz gitlab-readonly-mcp
```

The first start downloads it; later starts take about a second. In a client
config the npm form is `"command": ["npx", "-y", "gitlab-readonly-mcp"]`; the
tarball form adds `--package <tarball URL>` before the name.

## Configuration

All configuration is via environment variables.

| Variable | Required | Description |
|---|---|---|
| `GITLAB_HOST` | yes | Base URL of the instance, e.g. `https://gitlab.example.com` or `https://gitlab.com` (no trailing slash). Project paths are not unique per instance, so the host must be explicit. |
| `GITLAB_TOKEN` | yes | Read-only token (`read_repository`). Works with project access tokens and legacy PATs that the official GitLab MCP server rejects. 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 does not depend 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.

`["npx", "-y", "gitlab-readonly-mcp"]` also works, installed or not: `npx` uses
the binary on `PATH` when there is one, and otherwise downloads the package from
npm. If the machine cannot reach the registry, use the tarball form from
[Install](#install).

The tools appear as `gitlab_<tool>`, such as `gitlab_get_file` and
`gitlab_log`. 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 a command you have
installed, or `npx -y gitlab-readonly-mcp` if you have not.

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

The form above was tested on Windows, where it starts the server through the
`npx` shim. **If Cursor reports `ENOENT`, wrap it in `cmd /c`.** Clients that
start the command without a shell cannot execute a `.cmd` file, so the server
never appears and `MCP Logs` is the only place that says so:

```jsonc
"command": "cmd",
"args": ["/c", "npx", "-y", "gitlab-readonly-mcp"],
```

Once the package is installed, `"command": "gitlab-readonly-mcp"` is equivalent
to the `npx` form on every platform. `npx` adds a wrapper process that resolves
the same binary, from `PATH` when it is installed and from the npm registry when
it is not. If the machine cannot reach the registry, use the `--package` form
from [Install](#install) instead, where `args` becomes `["-y", "--package",
"<tarball URL>", "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 are 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, which
[What the odd bits mean](#what-the-odd-bits-mean) explains.

**Read a file from a branch you have not checked out.** There is no clone of
your own to make, and your working tree stays as it is:

```
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** without a clone or a file path:

```
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, so `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.

## 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, when 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, because a mirror was never cloned or a clone has
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 when you call it: 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 stick, and is how a project goes back to
being read from a mirror.

## Troubleshooting

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

| 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 takes 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`.
- [SECURITY.md](SECURITY.md) has the longer account: what the token can reach,
  what the server writes to disk, and the risks it cannot remove.

## 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

Every tool targets a distinct git/GitLab operation, and the descriptions actively disambiguate the risky pairs: `compare` explicitly never returns a patch and points to `diff_file`; `search` points to `pickaxe` for 'when' vs 'where'; `log`/`get_commit`/`blame` each own a different history view. Repo lifecycle tools (add/remove/sync/list_repos) and ref tools (list_refs/resolve_ref) also have clear, non-overlapping roles.

Naming Consistency4/5

The dominant pattern is verb_noun (get_file, list_tree, get_commit, list_refs, list_repos, add_repo, remove_repo, sync_repo, resolve_ref, diff_file, parse_gitlab_url), which is predictable. The deviations (log, blame, compare, search, pickaxe) are deliberate git-subcommand parity, so they are still guessable, but it is a mixed convention rather than a single one.

Tool Count4/5

16 tools is at the upper end of the comfortable range, but for a read-only git surface each one maps to a real, non-redundant operation (files, refs, history, diffing, repo registration). Nothing feels padded, though a few could arguably be folded (e.g. resolve_ref into list_refs).

Completeness4/5

The read-only domain is well covered end to end: registration/sync, ref discovery and resolution, tree/file reads, grep, pickaxe, log, commit, blame, and both branch-level and file-level diffs. Minor gaps remain — no way to discover projects on the GitLab host without knowing the path, and no merge request/issue/CI reads (explicitly refused), which limits use as a general GitLab API client.

Maintenance

ActivityMaintained
ResponsivenessNo recent community issues