@mohou/gitlab-mcp
v1.0.1
Published
MCP server and CLI for a self-hosted GitLab CE 11.3 instance - merge request workflow: create, review, line-level comments, resolve, merge
Maintainers
Readme
@mohou/gitlab-mcp
An MCP server and a CLI for a self-hosted GitLab CE 11.3.0 instance, built around the merge-request workflow: create, read the diff, comment on a line, reply, resolve, merge.
Two front ends, one core. The CLI has no tool list of its own - its subcommands and flags are derived from the same Zod schemas the MCP tools declare, so the two cannot drift.
Why this exists instead of glab or an existing MCP server
GitLab 11.3.0 was released in September 2018. Every maintained tool has moved on:
| Tool | Why it does not work here |
| --- | --- |
| glab (official CLI) | Officially supports GitLab 16.0+; 15.x and earlier are explicitly unsupported |
| GitLab's official MCP server | Requires GitLab 18.3+ (/api/v4/mcp and OAuth dynamic client registration) |
| Community GitLab MCP servers | Written against the current API and GraphQL; against 11.3 they 404 or silently misbehave |
The REST API v4 does exist on 11.3 and is complete enough for review work. What it lacks is everything newer, and this client is written against the 11.3 API documentation rather than against today's docs - see What 11.3 does not have.
Requirements
- Node.js 22.6+ (the sources are TypeScript, run directly by Node's type stripping - there is no build step)
- A GitLab personal access token with the
apiscope
Install
# as a dependency of something else
npm install @mohou/gitlab-mcp
# the MCP client launches the server through npx, so nothing must be installed first:
npx -y @mohou/gitlab-mcp
# the CLI, without installing anything globally:
npx -y --package @mohou/gitlab-mcp mohou-gitlab toolsThe package ships two binaries: gitlab-mcp (the MCP server, named to match the package so
that npx @mohou/gitlab-mcp finds it) and mohou-gitlab (the CLI).
From a source checkout - there is no build step, Node runs the TypeScript directly:
cd gitlab-mcp
npm install
npm test # 89 offline tests against a mock GitLab 11.3Configure
| Variable | Meaning |
| --- | --- |
| GITLAB_BASE_URL | instance root, e.g. http://gitlab.example.com (not /api/v4) |
| GITLAB_TOKEN | personal access token, sent as the PRIVATE-TOKEN header |
| GITLAB_READ_ONLY | true registers only read-only tools, and the CLI refuses write subcommands |
| GITLAB_TIMEOUT_MS | request timeout, default 30000 |
| GITLAB_MAX_RETRIES | retries for 429/502/503/504, default 2 |
| GITLAB_TLS_REJECT_UNAUTHORIZED | false for a self-signed certificate |
The token must be treated as a write credential: read_api does not exist before GitLab
12.10, so api is the narrowest scope that can read the API at all. Use a dedicated account,
not a personal one.
Registering the MCP server
Any MCP client works; the server speaks stdio.
{
"mcpServers": {
"gitlab": {
"command": "npx",
"args": ["-y", "@mohou/gitlab-mcp"],
"env": {
"GITLAB_BASE_URL": "http://gitlab.example.com",
"GITLAB_TOKEN": "glpat-xxxxxxxxxxxx"
}
}
}
}From a source checkout, point command at node and args at the absolute path to
bin/gitlab-server.mjs instead.
In this harness (dsh), register it with the mini-app MCP tool instead of editing a file:
mini_app_mcp_add id=gitlab command=node args=["/path/gitlab-mcp/bin/gitlab-server.mjs"] env={GITLAB_BASE_URL:..., GITLAB_TOKEN:...}Two things to know about that path:
${credential:NAME}/${env:NAME}placeholders are resolved for the one-off check but not for the live MCP client until the host restarts. Pass literal values, or register the row and restart once, then verify withmini_app_mcp_list.- The harness reaches the host over VPN. If the VPN is down, the first call fails with
network error: fetch failed - is the VPN connected and the host reachable?.
CLI
export GITLAB_BASE_URL=http://gitlab.example.com
export GITLAB_TOKEN=glpat-xxxx
# installed: `mohou-gitlab ...` | without installing: `npx -y --package @mohou/gitlab-mcp mohou-gitlab ...`
node bin/gitlab.mjs tools # every tool, read-only ones marked
node bin/gitlab.mjs describe mr_comment_on_line # purpose and flags
node bin/gitlab.mjs whoami # proves base URL + token
node bin/gitlab.mjs mr_list --project group/app --state opened --per-page 10
node bin/gitlab.mjs mr_changes --project group/app --iid 12 --max-patch-chars 2000
node bin/gitlab.mjs mr_comment_on_line --project group/app --iid 12 \
--path src/app.ts --line 42 --body "this branch is unreachable"
node bin/gitlab.mjs raw_api --method GET --path /projects/group%2Fapp/merge_requests- Tool names may be given in full or without the
gitlab_prefix (mr_list=gitlab_mr_list). - Flags are dashed and accept the schema spelling too:
--source-branchand--source_branchare the same flag.--no-include-systemsets a boolean false. - Output is JSON on stdout, diagnostics on stderr. Exit codes:
0ok,1the call failed,2bad usage. --host,--token,--token-fileoverride the environment for one call (the token is removed fromargv, so it does not appear inps);--dry-runprints the request instead of sending it.
A review, end to end
gitlab_mr_list {project, state:"opened"} # which MR
gitlab_mr_get {project, iid} # state, merge_status, work_in_progress
gitlab_mr_changes {project, iid, maxPatchChars:4000} # files, counts, capped patches
gitlab_mr_discussions {project, iid, onlyUnresolved:true} # what is still open
gitlab_mr_comment_on_line {project, iid, path, line, side, body}
gitlab_mr_reply {project, iid, discussion_id, body}
gitlab_mr_resolve {project, iid, discussion_id}
gitlab_mr_pipelines {project, iid} # the merge gate
gitlab_mr_merge {project, iid, sha} # sha pins the source HEADgitlab_mr_comment_on_line is the tool that earns its keep. GitLab does not accept "file +
line": a diff note needs the merge request's base/start/head SHAs and the old-side line
number. This tool reads the newest diff version, reconstructs the hunk numbering, fills both line
numbers in, and refuses locally when the line is not in the diff - naming the ranges that would
have worked:
cannot place a comment at src/app.ts:99 (new side). src/app.ts: no commentable new-side line 99
(commentable new-side lines: 10-18). Lines that exist in the diff can be commented on;
use gitlab_mr_changes to see the diff.Tools
Core tools (code, src/entities/):
| Tool | R/W | Purpose |
| --- | --- | --- |
| gitlab_whoami | ro | the authenticated user - the cheapest way to prove the token |
| gitlab_server_version | ro | which GitLab version is answering |
| gitlab_project_get / gitlab_project_search | ro | resolve a project path or id |
| gitlab_labels_list / gitlab_users_search | ro | label names, and the numeric user ids this API takes |
| gitlab_branches_list / gitlab_file_get | ro | branches; one file at a ref, capped |
| gitlab_mr_list / gitlab_mr_get | ro | find and read merge requests |
| gitlab_mr_changes | ro | per-file diff summary with capped patches |
| gitlab_mr_discussions | ro | threads, with each diff comment's file and line |
| gitlab_mr_pipelines / gitlab_mr_versions | ro | the merge gate; diff versions (debugging) |
| gitlab_mr_create / gitlab_mr_update | rw | create and edit, including the draft translation |
| gitlab_mr_comment | rw | a comment on the merge request itself |
| gitlab_mr_comment_on_line | rw | a diff comment on one line |
| gitlab_mr_reply / gitlab_mr_resolve | rw | continue a thread; resolve it |
| gitlab_mr_merge | rw | merge, with the failure modes explained |
| gitlab_raw_api | rw | any endpoint - the escape hatch |
Declared extras (tools.d/gitlab-extras.json, read-only, no code): issues, members, project
pipelines, pipeline jobs, commits, tags, group projects. See
tools.d/README.md for the declaration DSL and how to add more.
npm run tools:describe -- <filter> prints the contract as the model sees it, asked of the
server itself over stdio.
What 11.3 does not have
Every entry here is a trap a tool written against current documentation would fall into.
| Missing | Consequence here |
| --- | --- |
| Merge request approvals API | There is no approve endpoint on CE 11.3 (doc/api/merge_request_approvals.md does not exist in the 11.3 tree). Review state is carried by diff comments and by resolving threads. |
| reviewers attribute | Assignment is the only way to route a merge request; gitlab_mr_list has no reviewer filter. |
| draft parameter | A draft merge request is one whose title starts with WIP: , reported back as work_in_progress: true. gitlab_mr_create/gitlab_mr_update take draft and translate it. |
| read_api scope | Tokens are all-or-nothing write credentials. |
| Global code search | /search has no blobs scope at the top level; code search is per project. |
| Keyset pagination | Offset pagination only; every list tool reports pagination.nextPage. |
| squash on merge | Squash is set on the merge request when it is created or updated, not at merge time. |
| rules/workflow/needs in CI | 12.x features. CI config for this instance must use only/except. |
| /projects/:id/users (unverified) | Not used here; user lookup goes through the global /users endpoint. |
| GraphQL | Experimental at best on 11.0+; this client is REST v4 only. |
Two error messages exist purely because of this table: a 400 mentioning reviewer_ids,
draft or approval rules, and a 401 explaining the scope situation.
Reading the right documentation
Do not read today's API docs when changing this code. Read the version-matched ones from the source tag that produced the instance:
https://gitlab.com/gitlab-org/gitlab-foss/-/raw/v11.3.0/doc/api/merge_requests.md
https://gitlab.com/gitlab-org/gitlab-foss/-/raw/v11.3.0/doc/api/discussions.mdThat is where the parameters in this client were checked, including the detail that a diff
version reports head_commit_sha / base_commit_sha / start_commit_sha while a comment
position wants head_sha / base_sha / start_sha.
Verification
npm test runs against test/mock-gitlab.mjs, which is faithful to the 11.3 shapes (the
*_commit_sha naming, hunk numbering, the 405/406/409 merge failures, nested validation errors)
rather than to GitLab's behaviour. Only a real instance can settle the rest:
GITLAB_BASE_URL=http://gitlab.example.com GITLAB_TOKEN=... \
npm run verify:live -- --project <group/app> --iid <mr> # read-only
GITLAB_BASE_URL=... GITLAB_TOKEN=... \
npm run verify:live -- --project <group/app> --iid <mr> --write # + create/delete a diff noteIt reports PASS/FAIL/SKIP per assumption and exits non-zero on any failure. In --write mode the
only mutation is one probe comment, which it deletes again.
Result against a live GitLab CE 11.3.0 instance (revision 17bd59a): 14 passed, 0 failed,
in both modes. What only that run could establish, and did:
PRIVATE-TOKENauthentication works on 11.3.- A project path arrives correctly percent-encoded.
- Pagination headers are present and usable (
184merge requests,nextPage=2). GET /versionsnames its SHAsbase_commit_sha/start_commit_sha/head_commit_sha, which is not what a comment position calls them - the mapping the client performs is correct.- Real diff text parses, and a real line resolves to a commentable position.
- A nested
positionobject is accepted when creating a diff note - the single assumption the offline suite could never test, since it was written from the same reading of the docs. - The created note comes back carrying its position, appears in the discussions list, toggles through resolve/unresolve, and deletes cleanly.
- A merged merge request still reports
merge_status: can_be_merged, sostateis the field that decides whether a merge is possible. The tool descriptions say so.
Still unverified live, and deliberately listed rather than glossed over:
| Area | Why, and how to close it |
| --- | --- |
| The draft positive case (WIP: title -> work_in_progress: true) | Needs a merge request that is actually a draft; the verified project had none among 100. |
| gitlab_mr_create / gitlab_mr_update / gitlab_mr_merge | Needs a throwaway branch in a project where creating a merge request is acceptable; 11.3 has no API to delete a merge request, so the residue is one closed or merged one. |
Everything else in this file that is not one of those two lines has been exercised against the real instance.
Extending
- A new endpoint with no logic - add a declaration to
tools.d/*.json. No code, no restart of anything but the server. - A new endpoint with logic, or a non-JSON response - add a tool in
src/entities/. Text endpoints (job traces, raw files) cannot be expressed in the JSON layer, because that layer parses every response as JSON. - Both front ends pick it up automatically: the MCP tool and the CLI subcommand appear together.
Layout: src/gitlab.ts (transport, pagination, error translation) - src/diff.ts (unified diff →
position) - src/entities/ (tools) - src/jsonTools.ts (the declaration DSL) - src/cli.ts +
src/args.ts (CLI) - src/index.ts (MCP server).
Maintainer: publishing
Prerequisites, in order:
- Node 22.6+ locally, and
npm testgreen (prepublishOnlyenforces it). npm login(orNPM_TOKENin the environment) for the account that may publish.- The
@mohouscope must exist and you must be able to publish to it. A scoped name is not optional: npm rejectsmohou/gitlab-mcp, so the package is@mohou/gitlab-mcp. If@mohouis not yet an npm organization, create it on npmjs.com and add yourself as an owner - or, for an internal registry, map the scope instead:
That# .npmrc (safe to commit - no token in it) @mohou:registry=https://your-internal-registry/.npmrcalso decides wherenpm publishgoes, so set it before publishing. publishConfig.access = "public"is already set inpackage.json; without it a scoped package publishes as restricted and fails on a free plan.
Then:
npm test # 93 offline tests
npm run pack:check # the file list that would ship - verify dist/ and tools.d/ are in it
npm run verify:package # pack, install into a temp dir, run both binaries, speak MCP to them
npm publish --dry-run # the whole publish path, including prepublishOnly, without publishing
npm version patch # or minor/major; updates package.json + git tag
npm publish # prepublishOnly runs both gates aboveDevelopment needs no build; the published package does
This is the one place where the package departs from a plain "run the TypeScript" layout, and the reason is a hard Node restriction:
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPINGNode strips types for the application's own sources but refuses to do so for anything under
node_modules. So src/*.ts runs fine in a checkout (and every test relies on that) and cannot
run at all once installed as a dependency. The package therefore ships dist/*.js, built by
scripts/build.mjs (esbuild, dependencies kept external).
The bin entries resolve the entry source first, bundle second:
bin/gitlab-server.mjs -> src/index.ts if it exists, else dist/index.js
bin/gitlab.mjs -> src/cli.ts if it exists, else dist/cli.jsSources first, not bundle first, and the order is not cosmetic: preferring dist/ means that
after any build a checkout silently runs the stale bundle, which once hid a real bug in the
sibling jira-mcp project during exactly this kind of verification. The published tarball
contains no src/ at all, so an install takes the bundle path; npm run verify:package fails if
src/ ever appears in the tarball, because shipping it would send an install down the source path
into ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
prepack runs the build, so npm pack and npm publish always emit a fresh bundle; dist/ is
gitignored. Editing a tool means editing the TypeScript, and nothing else - tests run the sources
and publishing rebuilds.
Other notes specific to this package:
tools.d/must ship. The server loadstools.d/*.jsonfrom the package root at startup; if that directory were dropped fromfiles, every JSON-declared tool would disappear and startup would fail. Bothpack:checkandverify:packagecheck for it.enginesrequires Node 22.6+ because the fallback path and the test suite use type stripping. The bundle itself would run on older Node.- No provenance attestation.
npm publish --provenanceneeds a public source repository on a supported CI provider; this repository is self-hosted, so provenance is not available. - Run
npm publish --dry-runbefore the real one. npm exports its configuration to lifecycle scripts, so a dry run reachesprepublishOnlywithnpm_config_dry_run=true- which once madeverify:packagefail, becausenpm packprints a filename without writing the file and the install that follows hits ENOENT. The script neutralises that variable; the dry run is the check that it stays neutralised. prepublishOnlyruns the offline suite and the package verification only. The live checks (npm run verify:live) need VPN access and a token, so they are not part of publishing.
