# Development Guide
> **Diátaxis type**: How-to
> **Audience**: 🔧 Developers, contributors
> **Prerequisites**: Go 1.26+, Node.js 22+, GitLab instance with PAT, Git, Make
---
## Prerequisites
- **Go 1.26+** ([download](https://go.dev/dl/))
- **Node.js 22+ with Corepack** for the documentation site and MCP Inspector. The site uses `pnpm@11.8.0`; keep pnpm configuration in `site/pnpm-workspace.yaml` rather than the `pnpm` field in `package.json`.
- **GitLab instance** with Personal Access Token (`api` scope)
- **Git** for version control
- **Make** for build automation (optional but recommended)
## Project Structure
> See [cmd-utilities.md](cmd-utilities.md) for the full CLI reference of every `cmd/` binary (flags, usage, Make targets).
```text
gitlab-mcp-server/
├── cmd/
│ ├── server/ # MCP server entry point
│ │ ├── main.go # Signal handling, transport selection
│ │ └── main_test.go # Server startup and HTTP handler tests
│ ├── audit_1to1/ # Consolidated 1:1 SDK↔API parity audit (-scope structs|actions|metadata)
│ ├── audit_catalog_first/ # ActionSpec catalog coverage inventory
│ ├── audit_discovery_completeness/ # Discovery metadata audit with cluster-aware severity (META-001)
│ ├── audit_doc_coverage/ # docs/reference/tools/*.md vs catalog coverage gaps (DOC-002)
│ ├── audit_dynamic_aliases/ # Dynamic alias collision governance
│ ├── audit_edition_tier/ # Doc-grounded Free/Premium/Ultimate tier audit
│ ├── audit_metrics/ # MCP tool/resource/prompt metrics summary
│ ├── audit_surface_quality/ # Surface quality audit (-view metadata|output|all)
│ ├── audit_test_names/ # Test function naming convention compliance
│ ├── audit_tokens/ # Token overhead audit (+ --compare-schemas sizing spike)
│ ├── eval_mcp_surfaces/ # Model-facing MCP surface evaluation harness
│ ├── audit_string_dupes/ # Finds duplicated string literals missing constants
│ ├── format_md_tables/ # Normalizes Markdown pipe tables
│ ├── gen_action_catalog_manifest/ # Generates ActionSpec manifest
│ ├── gen_docker_tools/ # Generates Docker MCP Registry tools.json
│ ├── gen_llms/ # Generates llms.txt and llms-full.txt
│ ├── gen_stats/ # Regenerates README stats section
│ ├── gen_testing_docs/ # Regenerates testing.md managed sections
│ └── godoc_tool/ # Go doc auditor + fixer (audit/fix subcommands)
├── internal/
│ ├── config/ # Environment variable loading and validation
│ ├── gitlab/ # GitLab API client wrapper with TLS support
│ ├── completions/ # Autocomplete handler for 17 argument types
│ ├── progress/ # Progress notification tracker
│ ├── elicitation/ # Interactive user input client
│ ├── toolutil/ # Shared tool utilities (errors, pagination, markdown, logging)
│ ├── testutil/ # Shared test helpers (NewTestClient, RespondJSON)
│ ├── tools/ # Tool orchestration layer + ~175 internal/tools packages (166 with action_specs.go)
│ │ ├── register.go # RegisterAll() — catalog-backed individual tool projection
│ │ ├── register_meta.go # RegisterAllMeta() — catalog-backed meta-tool groups and standalone surfaces
│ │ ├── metatool.go # Local helpers addMetaTool/addReadOnlyMetaTool wrapping toolutil.DeriveAnnotations + route wrappers
│ │ ├── markdown.go # markdownForResult delegator to toolutil.MarkdownForResult
│ │ ├── branches/ # Branch management tools (example sub-package)
│ │ ├── issues/ # Issue CRUD tools
│ │ ├── mergerequests/ # MR lifecycle tools
│ │ └── ... # ~175 internal/tools packages total
│ ├── resources/ # 45 MCP resource handlers
│ └── prompts/ # 37 MCP prompt handlers
├── test/e2e/ # End-to-end integration tests (suite/ + infra)
├── docs/ # Documentation (this directory)
├── plan/ # Implementation plans
├── VERSION # Single source of truth for project version
├── Makefile # Build automation
└── .env # Local secrets (gitignored)
```
Meta-tool counts are additive: 32 base tools, 16 Enterprise/Premium-specific meta-tools for 48 on self-managed GitLab, plus the GitLab.com-only Orbit meta-tool for 49 when Orbit is available.
## Architecture
```mermaid
graph TD
MAIN[cmd/server/main.go] -->|loads| CFG[config.Load]
MAIN -->|creates| GL[gitlab.NewClient]
MAIN -->|creates| SRV[mcp.NewServer]
MAIN -->|selects surface| SURFACE{TOOL_SURFACE}
SPECS[CollectActionSpecs
domain ActionSpecs] --> CATALOG[BuildActionCatalog]
MAIN -->|builds| CATALOG
CATALOG --> IND[individual projection
tools.RegisterAll]
CATALOG --> META[meta projection
tools.RegisterAllMeta]
CATALOG --> DYN[dynamic projection
dynamic.RegisterCatalogFindExecuteTools]
STANDALONE[StandaloneSurfaceToolSpecs
project discovery + interactive flows] -.->|dynamic route injection| DYN
SURFACE -->|individual| IND
SURFACE -->|meta| META
SURFACE -->|dynamic| DYN
IND --> PROJECTION[Catalog-backed ActionRoute handlers]
META --> PROJECTION
DYN --> PROJECTION
MAIN -->|registers standalone| STANDALONE
MAIN -->|registers| RES[resources.Register]
MAIN -->|registers| PROMPTS[prompts.Register]
MAIN -->|setup| COMP[completions]
SRV -->|runs| STDIO[StdioTransport]
SRV -->|runs| HTTP[StreamableHTTPHandler]
PROJECTION --> GL
PROJECTION --> PROG[progress]
STANDALONE --> ELIC[elicitation]
ELIC --> GL
RES --> GL
PROMPTS --> GL
```
1. **Config** loads settings from `.env` + environment variables
2. **GitLab Client** wraps the official `gitlab.com/gitlab-org/api/client-go/v2`
3. **Tools** are projected from domain-local `ActionSpecs` through the canonical action catalog
4. **Meta-tools** group catalog actions into 32 base tools (48 on self-managed Enterprise/Premium, 49 on GitLab.com Enterprise/Premium with Orbit) (via ADR-0005)
5. **Resources** register read-only data via `AddResource()` / `AddResourceTemplate()`
6. **Prompts** register AI-optimized interactions via `AddPrompt()`
7. **Capabilities** provide completions, progress, and elicitation
8. **Server** runs over stdio (default) or HTTP (`--http`)
See [Architecture Overview](../concepts/architecture.md) for detailed diagrams and component descriptions.
## Version Management
The project version is defined in the `VERSION` file at the repository root.
```text
VERSION # Contains e.g. "1.1.7" — no "v" prefix, no trailing newline
├─ Makefile # Reads VERSION → passes via -ldflags to go build
├─ .gitlab-ci # Reads VERSION → prefers CI_COMMIT_TAG if set
└─ binary # Receives version at build time via -X main.version
```
```bash
make version # Print version from VERSION file
./dist/gitlab-mcp-server.exe --version # Print from compiled binary
```
## Building
### Local build
```bash
make build
# Output: dist/gitlab-mcp-server.exe (Windows) or dist/gitlab-mcp-server (Linux)
```
Manual build:
```bash
go build -ldflags="-X main.version=$(cat VERSION) -X main.commit=$(git rev-parse --short HEAD)" -o dist/gitlab-mcp-server ./cmd/server
```
### Cross-compilation
```bash
make build-all
# Produces: linux-amd64, linux-arm64, windows-amd64, windows-arm64
```
### Docker
#### Build image from source
```bash
make docker-build
# Or with explicit version
docker build \
--build-arg VERSION=$(cat VERSION) \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t gitlab-mcp-server .
```
#### Run locally
```bash
make docker-run
# or, for self-managed GitLab:
make docker-run GITLAB_URL=https://gitlab.example.com
```
#### Development with live builds
Use the build override to compile from source inside Docker Compose instead of pulling the pre-built image:
```bash
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d
```
#### Publish to Container Registry
Publish via Makefile or manually:
```bash
# Via Makefile
make docker-push
# Or manually
docker login ghcr.io -u "$GITHUB_USER" --password-stdin <<< "$GITHUB_TOKEN"
docker push ghcr.io/jmrplens/gitlab-mcp-server:1.7.1
docker push ghcr.io/jmrplens/gitlab-mcp-server:latest
```
## Testing
### Unit Tests
Unit tests live alongside the code in each sub-package. They use `net/http/httptest` to simulate GitLab API responses — **no real GitLab instance needed**.
```bash
make test # Standard tests with coverage
make test-race # Tests with race detector
go test ./internal/... -count=1 # Run all unit tests (~124 packages)
go test ./internal/tools/branches/ -count=1 -v # Run one domain verbose
go test ./internal/tools/ -run TestBranch -count=1 # Run specific tests
```
#### Test pattern (sub-package style)
Each sub-package has its own `*_test.go` with table-driven tests:
```go
// internal/tools/branches/branches_test.go
func TestCreate_Success(t *testing.T) {
client, mux := testutil.NewTestClient(t)
mux.HandleFunc("/api/v4/projects/1/repository/branches", func(w http.ResponseWriter, r *http.Request) {
testutil.RespondJSON(w, http.StatusOK, `{"name":"feature-x","commit":{"id":"abc123"}}`)
})
out, err := Create(context.Background(), client, CreateInput{
ProjectID: "1",
Branch: "feature-x",
Ref: "main",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Name != "feature-x" {
t.Errorf("Name = %q, want %q", out.Name, "feature-x")
}
}
```
#### Shared helpers (`internal/testutil/`)
| Helper | Purpose |
| -------------------------------------- | -------------------------------------------- |
| `testutil.NewTestClient(t)` | Creates mock GitLab client + httptest mux |
| `testutil.RespondJSON(w, code, body)` | Writes JSON response with status code |
| `testutil.RespondJSONWithPagination()` | Writes JSON response with pagination headers |
### End-to-End Tests
E2E tests run against a real GitLab instance via in-memory MCP transport (build tag `e2e`):
```bash
make test-e2e
# or: go test -v -tags e2e -timeout 300s ./test/e2e/suite/
# Compile-only check (no GitLab instance needed)
go test -tags e2e -c -o NUL ./test/e2e/suite/ # Windows
go test -tags e2e -c -o /dev/null ./test/e2e/suite/ # Linux
```
#### Docker Mode (Ephemeral GitLab)
Run the full E2E suite against an ephemeral GitLab CE container. Requires Docker and ~4 GB RAM. This mode also enables pipeline/job tests that need a CI runner.
```bash
make test-e2e-docker
```
This single command handles the full lifecycle: start GitLab CE container, wait for readiness, create test user/token, register CI runner, run tests, and tear down.
For manual step-by-step execution, see [E2E Docker Mode](testing/testing.md#docker-mode) in the testing guide.
#### E2E Prerequisites
```env
GITLAB_URL=https://gitlab.example.com
GITLAB_TOKEN=glpat-your-token
GITLAB_SKIP_TLS_VERIFY=true
```
#### E2E Test Structure
| File | Description |
| ----------------------------------- | ----------------------------------------------------- |
| `test/e2e/suite/setup_test.go` | Shared state, MCP server setup, helpers, drainSidekiq |
| `test/e2e/suite/fixture_ce_test.go` | Self-contained GitLab CE resource builders |
| `test/e2e/suite/fixture_ee_test.go` | Self-contained GitLab EE resource builders |
| `test/e2e/suite/*_test.go` | 137 domain-specific test files (individual + meta) |
## MCP Inspector
The [MCP Inspector](https://modelcontextprotocol.io/docs/reference/tools/inspector) provides a web UI for interactively testing MCP tools, resources, and prompts against a running server.
```bash
make inspector # Compile fresh binary to /tmp, launch Inspector via stdio
make inspector-stop # Stop Inspector processes and clean up temp binary
```
This compiles the server to a temporary binary (`/tmp/gitlab-mcp-server-inspector`), reads credentials from `.env`, and launches the Inspector at `http://127.0.0.1:6274/`. The temporary binary is automatically cleaned up on exit.
**Prerequisites**: Node.js >= 22, `.env` file with `GITLAB_TOKEN`. Add `GITLAB_URL` for self-managed instances.
## Linting & Formatting
```bash
make lint # golangci-lint config, format diff, and run
make fmt # apply configured Go formatters through golangci-lint
make analyze-fix # apply supported Go and Markdown fixes
```
## Error Handling in Tool Handlers
All error wrapping functions live in `internal/toolutil/errors.go`. Choose the right function based on this decision tree:
```mermaid
flowchart TD
start{Is the operation read-only?}
start -->|list / get / search| wrapErr[WrapErr]
start -->|create / update / delete| hasHint{Known corrective action?}
hasHint -->|No| wrapMsg[WrapErrWithMessage]
hasHint -->|Yes| statusHint{Hint applies to one HTTP status?}
statusHint -->|Yes| wrapStatus[WrapErrWithStatusHint]
statusHint -->|No| checkStatus[Check IsHTTPStatus]
checkStatus --> wrapHint[WrapErrWithHint]
```
### Quick reference
| Function | When to use | Includes GitLab detail | Includes hint |
| ----------------------- | ------------------------------------------------------------------ | ---------------------- | ------------------------- |
| `WrapErr` | Read-only operations | No | No |
| `WrapErrWithMessage` | Mutating operations (default) | Yes | No |
| `WrapErrWithHint` | Specific error with known fix | Yes | Yes |
| `WrapErrWithStatusHint` | Status-specific hint (combines `IsHTTPStatus` + `WrapErrWithHint`) | Yes | Yes (for matching status) |
### Pattern: Status-specific hints
```go
if toolutil.IsHTTPStatus(err, 409) {
return Output{}, toolutil.WrapErrWithHint("labelCreate", err,
"label with this name already exists — use gitlab_label_update to modify it")
}
return Output{}, toolutil.WrapErrWithMessage("labelCreate", err)
```
### Pattern: Single-status hint (shorthand)
```go
// Equivalent to the above but in a single call — returns WrapErrWithMessage for non-409 errors
return Output{}, toolutil.WrapErrWithStatusHint("labelCreate", err, 409,
"label with this name already exists — use gitlab_label_update to modify it")
```
### Helpers
- `IsHTTPStatus(err, code)` — checks if the error chain contains a `gl.ErrorResponse` with the given HTTP status
- `ContainsAny(err, substrs...)` — checks if `err.Error()` contains any of the given substrings
- `ExtractGitLabMessage(err)` — extracts the specific message from `gl.ErrorResponse.Message`
See [Error Handling](../concepts/error-handling.md) for the full architecture.
## Adding a New Tool
With the catalog-first modular sub-package architecture:
1. **Create sub-package**: `internal/tools/{domain}/`
2. **Create handler file**: `{domain}.go` with typed input/output structs (no domain prefix — package provides namespace)
3. **Create test file**: `{domain}_test.go` with table-driven tests using `testutil.NewTestClient`
4. **Create ActionSpecs**: define `ActionSpecs(client, ...)` or update the owning aggregation builder with typed `ActionRoute` constructors and individual projection metadata
5. **Create markdown formatters**: register output formatters from the sub-package with `toolutil.RegisterMarkdown` or `toolutil.RegisterMarkdownResult`
6. **Regenerate catalog manifest**: run `make gen-action-catalog-manifest` when the source-defined builder set changes, then run `make check-action-catalog-manifest`
7. **Update documentation**: `docs/reference/tools/{domain}.md` and `docs/reference/tools/README.md`
Meta-tools and the dynamic toolset share the canonical action catalog built by `internal/tools/action_catalog.go`.
When adding a normal GitLab operation, define the route once inside the owning `ActionSpec` with typed `ActionRoute` constructors (`RouteAction`, `DestructiveAction`, `RouteActionWithRequest`, and void variants). The same catalog entry then powers the individual tool projection, visible meta-tool action, `gitlab_find_action`, `gitlab_execute_action`, the `gitlab://tools` manifest, generated LLM files, and audit commands. Do not create package-local `RegisterTools` functions or dynamic-only copies of ordinary GitLab actions.
See [Tool Surfaces And Canonical Action Core](tool-surfaces-and-action-core.md) for the ownership rules across individual tools, meta-tools, dynamic mode, and the canonical action catalog.
### Orbit live-test fixtures
The `orbitlive` build-tagged live tests in `test/e2e/orbit/live_test.go` exercise the real `https://gitlab.com/api/v4/orbit/*` endpoints. They expect two projects in the configured namespace (`kg-fixtures` and `security-fixtures`) with a specific shape, plus optional mirror data. The reproduction script, the expected fixture layout, and the indexer caveat (transient `error` state) are documented in [Orbit Live Test Fixtures](orbit-fixtures.md).
To run the full flow against GitLab.com — token validation, idempotent fixture provisioning, indexer catch-up wait, then the four live test suites (41 subtests) — use the orchestrated target:
```bash
# Add a Personal Access Token (api scope) to .env first
echo 'GITLAB_COM_TOKEN=glpat-...' >> .env
# Provision fixtures in your own namespace and run the live tests
make test-e2e-gitlab-com ORBIT_FIXTURES_NAMESPACE=acme-research
# When fixtures are already provisioned, skip setup and run only the tests
GITLAB_COM_TOKEN=glpat-... \
go test -tags orbitlive -count=1 -v -timeout 300s ./test/e2e/orbit/
```
`make test-e2e-gitlab-com` chains four sub-targets: `orbit-ensure-token` (validates `GITLAB_COM_TOKEN` is exported), `orbit-setup-fixtures` (runs `scripts/setup-orbit-fixtures.sh`), `orbit-wait-indexer` (polls `/api/v4/orbit/graph_status` until the indexer reports the projects as indexed), and `orbit-run-live-tests` (runs `go test -tags orbitlive ...`). Each sub-target is independently runnable.
### Example: Adding a tools sub-package
```go
// internal/tools/branches/branches.go
package branches
type CreateInput struct {
ProjectID string `json:"project_id" jsonschema:"Project ID or URL-encoded path"`
Branch string `json:"branch" jsonschema:"Branch name to create"`
Ref string `json:"ref" jsonschema:"Source branch or commit SHA"`
}
type Output struct {
Name string `json:"name"`
Commit string `json:"commit"`
WebURL string `json:"web_url"`
}
func Create(ctx context.Context, client *gitlabclient.Client, input CreateInput) (Output, error) {
if err := ctx.Err(); err != nil {
return Output{}, err
}
// GitLab API call...
return Output{}, nil
}
```
```go
// internal/tools/branches/action_specs.go
package branches
func ActionSpecs(client *gitlabclient.Client) []toolutil.ActionSpec {
route := toolutil.RouteAction(client, Create).
WithUsage("Use to create a branch from an existing branch, tag, or commit SHA.")
return []toolutil.ActionSpec{
toolutil.NewActionSpec("create", route, toolutil.ActionSpecOptions{
ReadOnly: false,
Idempotent: false,
OwnerPackage: "branches",
IndividualTool: toolutil.IndividualToolSpec{
Name: "gitlab_create_branch",
Title: "Create branch",
Description: "Create a new branch in a GitLab project.",
},
}),
}
}
```
## Environment Setup
### Local development
1. Clone the repository
2. Create `.env` with your GitLab credentials
3. Run `go mod download`
4. Build: `make build`
5. Run: `dist/gitlab-mcp-server`
### IDE setup (VS Code)
Install the Go extension and add to `.vscode/mcp.json`:
```json
{
"servers": {
"gitlab-dev": {
"type": "stdio",
"command": "${workspaceFolder}/dist/gitlab-mcp-server.exe",
"env": {
"GITLAB_URL": "https://your-gitlab",
"GITLAB_TOKEN": "glpat-your-token",
"GITLAB_SKIP_TLS_VERIFY": "true",
"TOOL_SURFACE": "meta"
}
}
}
}
```
## Git Workflow
- **Conventional commits**: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`
- **Feature branches**: `feature/tool-name`, `fix/description`
- **Main branch**: Protected, merge via pull requests
## Dependencies
| Dependency | Version | Purpose |
| ---------------------------------------- | ------- | ------------------------------- |
| `github.com/modelcontextprotocol/go-sdk` | v1.6.1 | MCP server framework |
| `gitlab.com/gitlab-org/api/client-go/v2` | v2.51.0 | Official GitLab REST API client |
| `github.com/joho/godotenv` | v1.5.1 | .env file loading for dev |
## External References
| Resource | URL |
| ------------------------------ | ----------------------------------------------------------- |
| MCP Specification (2025-11-25) | |
| MCP Go SDK (pkg.go.dev) | |
| MCP Go SDK Repository | |
| GitLab REST API v4 | |
| GitLab Go Client (pkg.go.dev) | |
| GitLab Go Client Repository | |