@zereight/mcp-gitlab 2.2.3 → 2.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -12,10 +12,16 @@
12
12
 
13
13
  `zereight-mcp-gitlab` 바이너리를 먼저 설치하세요. `npm install -g @zereight/mcp-gitlab`, 또는 Homebrew `brew tap zereight/gitlab-mcp https://github.com/zereight/gitlab-mcp` 다음 `brew install zereight/gitlab-mcp/zereight-mcp-gitlab`.
14
14
 
15
- | 클라이언트 | 설치 |
16
- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
- | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
18
- | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
15
+ 토큰을 화면에 표시하지 않고 읽은 다음 export 하세요. 셸 기록에는 변수 이름만 남습니다.
16
+
17
+ ```bash
18
+ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN
19
+ ```
20
+
21
+ | 클라이언트 | 설치 |
22
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
+ | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
24
+ | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
19
25
  | Cursor | [Cursor 설정 가이드](./docs/clients/cursor.md) |
20
26
  | VS Code | [VS Code 설정 가이드](./docs/clients/vscode.md) |
21
27
  | JSON 클라이언트 | [JSON 기반 MCP 클라이언트 설정 가이드](./docs/clients/json-clients.md) |
@@ -30,7 +36,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
30
36
 
31
37
  ### 왜 이 GitLab MCP를 사용하나요?
32
38
 
33
- - **274개 도구: toolset 272개 + `execute_graphql` + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화
39
+ - **275개 도구: toolset 273개 + `execute_graphql` + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화
34
40
  - **MR 2단계 리뷰** — `list_merge_request_changed_files` → 배치 `get_merge_request_file_diff`
35
41
  - **Agent Skill 내장** — `skills/gitlab-mcp/` 워크플로우 가이드
36
42
  - **유연한 인증** — Personal Access Token, 로컬 OAuth2 브라우저 플로우, MCP OAuth 프록시, 요청별 원격 인증
@@ -44,7 +50,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
44
50
  | | @zereight/mcp-gitlab | GitLab MCP A (커뮤니티 CQRS형) | GitLab 공식 MCP 서버 |
45
51
  | --------------- | ---------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
46
52
  | **적합한 경우** | AI 에이전트 워크플로우 | 엔터프라이즈 멀티 인스턴스 / 그룹형 도구 | GitLab에 내장, 별도 설치 없음 |
47
- | **도구 모델** | 274개 도구: toolset 272개 + `execute_graphql` + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 | HTTP 헤더로 고르는 toolset |
53
+ | **도구 모델** | 275개 도구: toolset 273개 + `execute_graphql` + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 | HTTP 헤더로 고르는 toolset |
48
54
  | **MR 리뷰** | 2단계 배치 diff | 서버마다 다름 | `get_merge_request`에 diffs |
49
55
  | **Node.js** | >=18.17 | 보통 >=24 | stdio는 `mcp-remote`, Node.js 20+ |
50
56
  | **라이선스** | MIT | 서버마다 다름 | GitLab의 일부 |
@@ -84,7 +90,7 @@ GitLab은 [공식 MCP 서버](https://docs.gitlab.com/user/model_context_protoco
84
90
 
85
91
  **로컬/데스크톱 사용**(가장 일반적):
86
92
 
87
- 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — 가장 단순한 설정
93
+ 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` 또는 `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — 가장 단순한 설정
88
94
  2. **OAuth2 — 로컬 브라우저** (`GITLAB_USE_OAUTH`) — 더 나은 보안을 위해 권장
89
95
 
90
96
  **서버/원격 배포**:
@@ -132,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
132
138
 
133
139
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
134
140
 
135
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.2.2`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
141
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.2.4`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
136
142
 
137
143
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
138
144
 
@@ -156,7 +162,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
156
162
  - `--api-url` - GitLab API URL (`GITLAB_API_URL` 대체)
157
163
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); `--permission-mode=readonly`를 사용 (`GITLAB_READ_ONLY_MODE` 대체)
158
164
  - `--permission-mode` - 권한 수준: `readonly`, `modify`(삭제/중단 도구 비활성), `full` (`GITLAB_PERMISSION_MODE` 대체, 기본값 `full`)
159
- - `--toolsets=all` - 지정한 툴셋 활성화 (`GITLAB_TOOLSETS` 대체, 미설정 시 lean `core` 기본값 사용)
165
+ - `--toolsets=all` - 지정한 툴셋 활성화. 명시적 `api` 툴셋은 빠짐 (`GITLAB_TOOLSETS` 대체, 미설정 시 lean `core` 기본값 사용). `gitlab_api_request`는 `--toolsets=api`
160
166
  - `--tools=list_issues` - 개별 도구 추가 (`GITLAB_TOOLS` 대체)
161
167
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); `--toolsets=core,wiki`를 사용 (`USE_GITLAB_WIKI` 대체)
162
168
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); `--toolsets=core,milestones`를 사용 (`USE_MILESTONE` 대체)
@@ -196,12 +202,18 @@ CLI 인자는 환경 변수보다 우선합니다.
196
202
  > [Tools Reference](./docs/tools/index.md#feature-toggles)와
197
203
  > [Environment Variables](./docs/configuration/environment-variables.md)를 참고하세요.
198
204
 
205
+ > [!CAUTION]
206
+ > `gitlab_api_request`(`api` 툴셋)는 `GITLAB_ALLOWED_GROUPS`와 그 밖의 그룹 제한, 프로젝트 허용 목록, 도구 단위 제한(`GITLAB_TOOLS`, 다른 도구에 걸린 `GITLAB_DENIED_TOOLS_REGEX`), 개별 도구 안전 검사를 우회합니다. 토큰 권한 범위 전체가 노출됩니다. 프롬프트 인젝션이 이 도구를 호출할 수 있습니다. 이런 제한에 의존하는 운영자는 켜면 안 됩니다.
207
+ > `GITLAB_TOOLSETS`에 `api`를 직접 적을 때만 켜집니다 (`GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, 레거시 `USE_*`, `GITLAB_TOOL_PROFILE`로는 안 됩니다). `GITLAB_ALLOWED_PROJECT_IDS`는 `/projects/:id` 경로만 검사합니다. `/search`, `/users`, `/groups/:id`, `/runners` 같은 엔드포인트는 제한되지 않습니다. `GITLAB_PROJECT_ID`와 OAuth 그룹 허용 목록은 이 도구가 검사하지 않습니다.
208
+
199
209
  - sse
200
210
 
211
+ 변수 이름만 넘기세요. Docker 클라이언트는 export한 `GITLAB_PERSONAL_ACCESS_TOKEN` 값을 컨테이너로 복사합니다.
212
+
201
213
  ```shell
202
214
  docker run -i --rm \
203
215
  -e HOST=0.0.0.0 \
204
- -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
216
+ -e GITLAB_PERSONAL_ACCESS_TOKEN \
205
217
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
206
218
  -e GITLAB_PERMISSION_MODE=readonly \
207
219
  -e GITLAB_TOOLSETS=core,wiki,milestones,pipelines \
package/README.md CHANGED
@@ -17,10 +17,16 @@ Install `@zereight/mcp-gitlab` with the recommended local personal-access-token
17
17
 
18
18
  Install the `zereight-mcp-gitlab` binary first with `npm install -g @zereight/mcp-gitlab`, or Homebrew `brew tap zereight/gitlab-mcp https://github.com/zereight/gitlab-mcp` and `brew install zereight/gitlab-mcp/zereight-mcp-gitlab`.
19
19
 
20
- | Client | Install |
21
- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
22
- | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
23
- | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
20
+ Read the token without echo, then export it. The shell history keeps the variable name.
21
+
22
+ ```bash
23
+ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN
24
+ ```
25
+
26
+ | Client | Install |
27
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
28
+ | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
29
+ | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
24
30
  | Cursor | [Cursor setup guide](./docs/clients/cursor.md) |
25
31
  | VS Code | [VS Code setup guide](./docs/clients/vscode.md) |
26
32
  | JSON clients | [JSON-based MCP clients setup guide](./docs/clients/json-clients.md) |
@@ -35,7 +41,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
35
41
 
36
42
  ### Why use this GitLab MCP?
37
43
 
38
- - **274 tools: 272 in toolsets + `execute_graphql` + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping
44
+ - **275 tools: 273 in toolsets + `execute_graphql` + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping
39
45
  - **MR 2-step review** — `list_merge_request_changed_files` → batched `get_merge_request_file_diff`
40
46
  - **Agent Skill built in** — workflow guidance in `skills/gitlab-mcp/`
41
47
  - **Flexible auth** — Personal Access Token, local OAuth2 browser flow, MCP OAuth proxy, and per-request remote authorization
@@ -49,7 +55,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
49
55
  | | @zereight/mcp-gitlab | GitLab MCP A (community CQRS-style) | GitLab official MCP server |
50
56
  | -------------- | ----------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
51
57
  | **Best for** | AI agent workflows | Enterprise multi-instance / grouped tools | Built into GitLab, no separate install |
52
- | **Tool model** | 274 tools: 272 in toolsets + `execute_graphql` + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools | Toolsets chosen with an HTTP header |
58
+ | **Tool model** | 275 tools: 273 in toolsets + `execute_graphql` + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools | Toolsets chosen with an HTTP header |
53
59
  | **MR review** | 2-step batched diff | Varies | `get_merge_request` with diffs |
54
60
  | **Node.js** | >=18.17 | Often >=24 | Node.js 20+ for stdio via `mcp-remote` |
55
61
  | **License** | MIT | Varies | Part of GitLab |
@@ -89,7 +95,7 @@ The server supports four authentication methods:
89
95
 
90
96
  **For local/desktop use** (most common):
91
97
 
92
- 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — simplest setup
98
+ 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — simplest setup
93
99
  2. **OAuth2 — Local Browser** (`GITLAB_USE_OAUTH`) — recommended for better security
94
100
 
95
101
  **For server/remote deployments**:
@@ -137,7 +143,7 @@ The store path is pinned by your lock file; update it with `nix flake update git
137
143
 
138
144
  The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
139
145
 
140
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.2.2`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
146
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.2.4`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
141
147
 
142
148
  #### Using CLI Arguments (for clients with env var issues)
143
149
 
@@ -161,7 +167,7 @@ Some MCP clients (like GitHub Copilot CLI) have issues with environment variable
161
167
  - `--api-url` - GitLab API URL (replaces `GITLAB_API_URL`)
162
168
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` instead (replaces `GITLAB_READ_ONLY_MODE`)
163
169
  - `--permission-mode` - Permission level: `readonly`, `modify` (no delete or teardown tools), or `full` (replaces `GITLAB_PERMISSION_MODE`, default `full`)
164
- - `--toolsets=all` - Enable named toolsets (replaces `GITLAB_TOOLSETS`; unset uses the lean `core` default)
170
+ - `--toolsets=all` - Enable named toolsets except the explicit-only `api` toolset (replaces `GITLAB_TOOLSETS`; unset uses the lean `core` default). `gitlab_api_request` needs `--toolsets=api`
165
171
  - `--tools=list_issues` - Add individual tools (replaces `GITLAB_TOOLS`)
166
172
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` instead (replaces `USE_GITLAB_WIKI`)
167
173
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` instead (replaces `USE_MILESTONE`)
@@ -202,12 +208,18 @@ The same binary is also a `gh`-style GitLab CLI (`tool <name>` or curated forms
202
208
  > See [Tools Reference](./docs/tools/index.md#feature-toggles) and
203
209
  > [Environment Variables](./docs/configuration/environment-variables.md).
204
210
 
211
+ > [!CAUTION]
212
+ > `gitlab_api_request` (toolset `api`) bypasses `GITLAB_ALLOWED_GROUPS` and other group restrictions, project allowlists, tool-level restrictions (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` on other tools), and individual tool safety checks. The full token permission scope is exposed. Prompt injection can drive this tool. Operators who rely on these restrictions must NOT enable it.
213
+ > Enable it only by listing `api` in `GITLAB_TOOLSETS` (`GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, legacy `USE_*` flags, and `GITLAB_TOOL_PROFILE` do not). `GITLAB_ALLOWED_PROJECT_IDS` is checked only for `/projects/:id` paths. Endpoints such as `/search`, `/users`, `/groups/:id`, and `/runners` are not constrained. `GITLAB_PROJECT_ID` and OAuth group allowlists are not enforced.
214
+
205
215
  - sse
206
216
 
217
+ Pass the variable name only. The Docker client copies the exported `GITLAB_PERSONAL_ACCESS_TOKEN` into the container.
218
+
207
219
  ```shell
208
220
  docker run -i --rm \
209
221
  -e HOST=0.0.0.0 \
210
- -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
222
+ -e GITLAB_PERSONAL_ACCESS_TOKEN \
211
223
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
212
224
  -e GITLAB_PERMISSION_MODE=readonly \
213
225
  -e GITLAB_TOOLSETS=core,wiki,milestones,pipelines \
@@ -910,7 +922,8 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
910
922
  271. `delete_snippet` - Delete a snippet
911
923
  272. `list_project_runners` - List runners available to a project (type, status, paused, tag_list). Omits registration and authentication tokens.
912
924
  273. `get_runner` - Get a runner's tag_list, run_untagged, locked, and access_level. When a project allowlist is active, the runner must be available to an allowed project. Omits registration and authentication tokens.
913
- 274. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response.
925
+ 274. `gitlab_api_request` - Send one GitLab REST call under /api/v4. Enabled only when GITLAB_TOOLSETS lists api. Readonly allows GET; modify rejects DELETE.
926
+ 275. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response.
914
927
 
915
928
  <!-- TOOLS-END -->
916
929
 
package/README.pt-BR.md CHANGED
@@ -12,10 +12,16 @@ Instalação local recomendada com PAT para `@zereight/mcp-gitlab`. Os argumento
12
12
 
13
13
  Instale o binário `zereight-mcp-gitlab` antes. `npm install -g @zereight/mcp-gitlab`, ou Homebrew `brew tap zereight/gitlab-mcp https://github.com/zereight/gitlab-mcp` e `brew install zereight/gitlab-mcp/zereight-mcp-gitlab`.
14
14
 
15
- | Cliente | Instalação |
16
- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
- | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
18
- | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
15
+ Leia o token sem eco e exporte. O histórico do shell fica só com o nome da variável.
16
+
17
+ ```bash
18
+ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN
19
+ ```
20
+
21
+ | Cliente | Instalação |
22
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
+ | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
24
+ | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
19
25
  | Cursor | [Guia de configuração do Cursor](./docs/clients/cursor.md) |
20
26
  | VS Code | [Guia de configuração do VS Code](./docs/clients/vscode.md) |
21
27
  | Clientes JSON | [Guia para clientes MCP baseados em JSON](./docs/clients/json-clients.md) |
@@ -30,7 +36,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
30
36
 
31
37
  ### Por que usar este GitLab MCP?
32
38
 
33
- - **274 ferramentas: 272 em toolsets + `execute_graphql` + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução
39
+ - **275 ferramentas: 273 em toolsets + `execute_graphql` + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução
34
40
  - **Revisão de MR em 2 etapas** — `list_merge_request_changed_files` → `get_merge_request_file_diff` em lote
35
41
  - **Agent Skill integrado** — orientação de workflows em `skills/gitlab-mcp/`
36
42
  - **Autenticação flexível** — Personal Access Token, fluxo OAuth2 local no navegador, proxy MCP OAuth e autorização remota por requisição
@@ -44,7 +50,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
44
50
  | | @zereight/mcp-gitlab | GitLab MCP A (comunidade, estilo CQRS) | Servidor MCP oficial do GitLab |
45
51
  | ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
52
  | **Mais indicado para** | Workflows de agentes de IA | Múltiplas instâncias corporativas / ferramentas agrupadas | Integrado ao GitLab, sem instalação separada |
47
- | **Modelo de ferramentas** | 274 ferramentas: 272 em toolsets + `execute_graphql` + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` | Toolsets escolhidos por cabeçalho HTTP |
53
+ | **Modelo de ferramentas** | 275 ferramentas: 273 em toolsets + `execute_graphql` + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` | Toolsets escolhidos por cabeçalho HTTP |
48
54
  | **Revisão de MR** | Diff em lote em 2 etapas | Varia | `get_merge_request` com diffs |
49
55
  | **Node.js** | >=18.17 | Frequentemente >=24 | Node.js 20+ para stdio via `mcp-remote` |
50
56
  | **Licença** | MIT | Varia | Parte do GitLab |
@@ -84,7 +90,7 @@ O servidor suporta quatro métodos de autenticação.
84
90
 
85
91
  **Para uso local/desktop** (mais comum):
86
92
 
87
- 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — configuração mais simples
93
+ 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` ou `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — configuração mais simples
88
94
  2. **OAuth2 — navegador local** (`GITLAB_USE_OAUTH`) — recomendado para maior segurança
89
95
 
90
96
  **Para implantações em servidor/remotas**:
@@ -132,7 +138,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
132
138
 
133
139
  Os exemplos usam `zereight-mcp-gitlab`, um alias com menor chance de conflito que o binário legado `mcp-gitlab`. Se o seu cliente MCP não o encontrar, use o caminho absoluto retornado por `which zereight-mcp-gitlab`.
134
140
 
135
- Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.2.2`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
141
+ Não quer instalar globalmente? Fixe o `npx` na versão estável anterior (a versão recomendada por esta documentação), por exemplo `npx -y @zereight/mcp-gitlab@2.2.4`. Se quiser sempre a versão mais recente, use `npx -y @zereight/mcp-gitlab@latest`. O servidor exibe um aviso no stderr durante a inicialização quando existe uma versão mais nova disponível (desative com `GITLAB_DISABLE_VERSION_CHECK=true`).
136
142
 
137
143
  #### Usando argumentos de CLI (para clientes com problemas com variáveis de ambiente)
138
144
 
@@ -156,7 +162,7 @@ Alguns clientes MCP, como o GitHub Copilot CLI, têm problemas com variáveis de
156
162
  - `--api-url` - URL da API do GitLab (substitui `GITLAB_API_URL`)
157
163
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` (substitui `GITLAB_READ_ONLY_MODE`)
158
164
  - `--permission-mode` - nível de permissão: `readonly`, `modify` (sem ferramentas de exclusão ou teardown) ou `full` (substitui `GITLAB_PERMISSION_MODE`, padrão `full`)
159
- - `--toolsets=all` - ativa toolsets nomeados (substitui `GITLAB_TOOLSETS`; sem valor usa o padrão enxuto `core`)
165
+ - `--toolsets=all` - ativa toolsets nomeados, exceto o toolset explícito `api` (substitui `GITLAB_TOOLSETS`; sem valor usa o padrão enxuto `core`). `gitlab_api_request` exige `--toolsets=api`
160
166
  - `--tools=list_issues` - adiciona ferramentas individuais (substitui `GITLAB_TOOLS`)
161
167
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` (substitui `USE_GITLAB_WIKI`)
162
168
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` (substitui `USE_MILESTONE`)
@@ -196,12 +202,18 @@ O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool <name>` ou f
196
202
  > Consulte a [Referência de ferramentas](./docs/tools/index.md#feature-toggles) e
197
203
  > [Variáveis de ambiente](./docs/configuration/environment-variables.md).
198
204
 
205
+ > [!CAUTION]
206
+ > `gitlab_api_request` (toolset `api`) ignora `GITLAB_ALLOWED_GROUPS` e outras restrições de grupo, allowlists de projeto, restrições por ferramenta (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` nas outras ferramentas) e as checagens de segurança de cada ferramenta. O escopo completo do token fica exposto. Prompt injection pode acionar esta ferramenta. Operadores que dependem dessas restrições NÃO devem habilitá-la.
207
+ > Ela só liga quando `api` está listado em `GITLAB_TOOLSETS` (`GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, flags legadas `USE_*` e `GITLAB_TOOL_PROFILE` não ligam). `GITLAB_ALLOWED_PROJECT_IDS` vale só para caminhos `/projects/:id`. Endpoints como `/search`, `/users`, `/groups/:id` e `/runners` não são restringidos. `GITLAB_PROJECT_ID` e allowlists de grupo OAuth não são aplicados por esta ferramenta.
208
+
199
209
  - sse
200
210
 
211
+ Passe só o nome da variável. O cliente Docker copia o `GITLAB_PERSONAL_ACCESS_TOKEN` exportado para o contêiner.
212
+
201
213
  ```shell
202
214
  docker run -i --rm \
203
215
  -e HOST=0.0.0.0 \
204
- -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
216
+ -e GITLAB_PERSONAL_ACCESS_TOKEN \
205
217
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
206
218
  -e GITLAB_PERMISSION_MODE=readonly \
207
219
  -e GITLAB_TOOLSETS=core,wiki,milestones,pipelines \
package/README.zh-CN.md CHANGED
@@ -12,10 +12,16 @@
12
12
 
13
13
  先安装 `zereight-mcp-gitlab` 二进制。`npm install -g @zereight/mcp-gitlab`,或 Homebrew `brew tap zereight/gitlab-mcp https://github.com/zereight/gitlab-mcp` 然后 `brew install zereight/gitlab-mcp/zereight-mcp-gitlab`。
14
14
 
15
- | 客户端 | 安装 |
16
- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
- | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
18
- | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN=glpat-your-token --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
15
+ 先不回显地读取令牌,再 export。shell 历史里只保留变量名。
16
+
17
+ ```bash
18
+ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN
19
+ ```
20
+
21
+ | 客户端 | 安装 |
22
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
23
+ | Claude Code | `claude mcp add gitlab --transport stdio --scope local --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
24
+ | Codex | `codex mcp add gitlab --env GITLAB_PERSONAL_ACCESS_TOKEN="$GITLAB_PERSONAL_ACCESS_TOKEN" --env GITLAB_API_URL=https://gitlab.com/api/v4 -- zereight-mcp-gitlab` |
19
25
  | Cursor | [Cursor 设置指南](./docs/clients/cursor.md) |
20
26
  | VS Code | [VS Code 设置指南](./docs/clients/vscode.md) |
21
27
  | JSON 客户端 | [基于 JSON 的 MCP 客户端设置指南](./docs/clients/json-clients.md) |
@@ -30,7 +36,7 @@
30
36
 
31
37
  ### 为什么使用这个 GitLab MCP?
32
38
 
33
- - **274 个工具:toolset 中 272 个 + `execute_graphql` + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
39
+ - **275 个工具:toolset 中 273 个 + `execute_graphql` + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
34
40
  - **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff`
35
41
  - **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南
36
42
  - **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权
@@ -44,7 +50,7 @@
44
50
  | | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) | GitLab 官方 MCP 服务器 |
45
51
  | ------------ | -------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
46
52
  | **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 | 内置于 GitLab,无需单独安装 |
47
- | **工具模型** | 274 个工具:toolset 中 272 个 + `execute_graphql` + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 | 用 HTTP 头选择 toolset |
53
+ | **工具模型** | 275 个工具:toolset 中 273 个 + `execute_graphql` + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 | 用 HTTP 头选择 toolset |
48
54
  | **MR 审查** | 两步批量 diff | 因服务器而异 | `get_merge_request` 带 diffs |
49
55
  | **Node.js** | >=18.17 | 通常 >=24 | stdio 经 `mcp-remote`,需要 Node.js 20+ |
50
56
  | **许可证** | MIT | 因服务器而异 | GitLab 的一部分 |
@@ -84,7 +90,7 @@ GitLab 还提供[官方 MCP 服务器](https://docs.gitlab.com/user/model_contex
84
90
 
85
91
  **本地/桌面使用**(最常见):
86
92
 
87
- 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN`) — 最简单的设置
93
+ 1. **Personal Access Token** (`GITLAB_PERSONAL_ACCESS_TOKEN` 或 `GITLAB_PERSONAL_ACCESS_TOKEN_FILE`) — 最简单的设置
88
94
  2. **OAuth2 — 本地浏览器** (`GITLAB_USE_OAUTH`) — 推荐用于更好的安全性
89
95
 
90
96
  **服务器/远程部署**:
@@ -132,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
132
138
 
133
139
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
134
140
 
135
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.2.2`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
141
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.2.4`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
136
142
 
137
143
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
138
144
 
@@ -156,7 +162,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
156
162
  - `--api-url` - GitLab API URL(替代 `GITLAB_API_URL`)
157
163
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0));请改用 `--permission-mode=readonly`(替代 `GITLAB_READ_ONLY_MODE`)
158
164
  - `--permission-mode` - 权限级别:`readonly`、`modify`(禁用删除/拆除工具)或 `full`(替代 `GITLAB_PERMISSION_MODE`,默认 `full`)
159
- - `--toolsets=all` - 启用指定工具集(替代 `GITLAB_TOOLSETS`;未设置时使用精简 `core` 默认值)
165
+ - `--toolsets=all` - 启用指定工具集,但不包括必须显式列出的 `api`(替代 `GITLAB_TOOLSETS`;未设置时使用精简 `core` 默认值)。`gitlab_api_request` 需要 `--toolsets=api`
160
166
  - `--tools=list_issues` - 添加单个工具(替代 `GITLAB_TOOLS`)
161
167
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0));请改用 `--toolsets=core,wiki`(替代 `USE_GITLAB_WIKI`)
162
168
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0));请改用 `--toolsets=core,milestones`(替代 `USE_MILESTONE`)
@@ -195,12 +201,18 @@ CLI 参数优先于环境变量。
195
201
  > 参见 [Tools Reference](./docs/tools/index.md#feature-toggles) 和
196
202
  > [Environment Variables](./docs/configuration/environment-variables.md)。
197
203
 
204
+ > [!CAUTION]
205
+ > `gitlab_api_request`(`api` 工具集)会绕过 `GITLAB_ALLOWED_GROUPS` 及其他群组限制、项目允许列表、工具级限制(`GITLAB_TOOLS`、作用在其他工具上的 `GITLAB_DENIED_TOOLS_REGEX`)以及各个工具自己的安全检查。令牌的完整权限范围都会暴露。提示注入可以驱动这个工具。依赖这些限制的运维人员不得启用它。
206
+ > 只有在 `GITLAB_TOOLSETS` 中显式写入 `api` 时才会启用(`GITLAB_TOOLSETS=all`、`discover_tools`、`GITLAB_TOOLS`、旧版 `USE_*`、`GITLAB_TOOL_PROFILE` 都不会)。`GITLAB_ALLOWED_PROJECT_IDS` 只检查 `/projects/:id` 路径。`/search`、`/users`、`/groups/:id`、`/runners` 等端点不受约束。`GITLAB_PROJECT_ID` 和 OAuth 群组允许列表不会被此工具强制执行。
207
+
198
208
  - sse
199
209
 
210
+ 只传变量名。Docker 客户端把已 export 的 `GITLAB_PERSONAL_ACCESS_TOKEN` 复制进容器。
211
+
200
212
  ```shell
201
213
  docker run -i --rm \
202
214
  -e HOST=0.0.0.0 \
203
- -e GITLAB_PERSONAL_ACCESS_TOKEN=your_gitlab_token \
215
+ -e GITLAB_PERSONAL_ACCESS_TOKEN \
204
216
  -e GITLAB_API_URL="https://gitlab.com/api/v4" \
205
217
  -e GITLAB_PERMISSION_MODE=readonly \
206
218
  -e GITLAB_TOOLSETS=core,wiki,milestones,pipelines \
@@ -1,6 +1,6 @@
1
1
  import { GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_TOOLS_RAW, GITLAB_TOOLSETS_RAW, getConfig, } from "../config.js";
2
2
  import { compileDeniedToolsRegex } from "../tools/denied-regex.js";
3
- import { buildFeatureFlagOverrides, isToolInEnabledToolset, parseEnabledToolsets, parseIndividualTools, } from "../tools/registry.js";
3
+ import { buildFeatureFlagOverrides, isExplicitToolsetOnlyTool, isToolInEnabledToolset, parseEnabledToolsets, parseIndividualTools, } from "../tools/registry.js";
4
4
  export function parseToolNameSet(raw) {
5
5
  if (!raw || raw.trim() === "") {
6
6
  return new Set();
@@ -20,6 +20,9 @@ export function isToolExposed(input) {
20
20
  if (isToolInEnabledToolset(input.toolName, input.enabledToolsets)) {
21
21
  return true;
22
22
  }
23
+ if (isExplicitToolsetOnlyTool(input.toolName)) {
24
+ return false;
25
+ }
23
26
  if (input.individuallyEnabledTools.has(input.toolName)) {
24
27
  return true;
25
28
  }
@@ -35,6 +38,9 @@ export function exposureRefusalMessage(input) {
35
38
  if (isToolExposed(input)) {
36
39
  return undefined;
37
40
  }
41
+ if (isExplicitToolsetOnlyTool(input.toolName)) {
42
+ return `${input.toolName} is enabled only by listing its toolset in GITLAB_TOOLSETS. GITLAB_TOOLS, GITLAB_TOOLSETS=all, and discover_tools do not enable it.`;
43
+ }
38
44
  return `${input.toolName} is not enabled by GITLAB_TOOLSETS, GITLAB_TOOLS, or legacy wiki/milestone/pipeline flags`;
39
45
  }
40
46
  export function liveCliExposure() {
package/build/config.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { parseCliArgs } from "./cli-boolean-flags.js";
2
2
  import { deprecatedEnvInputFromSources } from "./deprecated-env.js";
3
+ import { readPersonalAccessTokenFile, resolvePersonalAccessToken, } from "./personal-access-token.js";
3
4
  import { parseToolProfile } from "./tools/tool-profile.js";
4
5
  import { parseCompactToolAllowlist } from "./utils/compact-tool-allowlist.js";
6
+ import { createLogger, flushStartupLogs } from "./utils/logger.js";
5
7
  // Parse CLI arguments before anything else
6
8
  export const cliArgs = parseCliArgs(process.argv.slice(2));
7
9
  export function getConfig(cliKey, envKey, defaultValue) {
@@ -10,7 +12,19 @@ export function getConfig(cliKey, envKey, defaultValue) {
10
12
  // ---------------------------------------------------------------------------
11
13
  // Authentication
12
14
  // ---------------------------------------------------------------------------
13
- export const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
15
+ function warnIgnoredPersonalAccessTokenFile(message) {
16
+ const startupLogger = createLogger();
17
+ startupLogger.warn(message);
18
+ flushStartupLogs(startupLogger);
19
+ }
20
+ // Read once at module load. Later auth checks use this constant and do not reopen the file.
21
+ export const GITLAB_PERSONAL_ACCESS_TOKEN = resolvePersonalAccessToken({
22
+ cliToken: cliArgs.token,
23
+ envToken: process.env.GITLAB_PERSONAL_ACCESS_TOKEN,
24
+ filePath: process.env.GITLAB_PERSONAL_ACCESS_TOKEN_FILE,
25
+ readFile: readPersonalAccessTokenFile,
26
+ warn: warnIgnoredPersonalAccessTokenFile,
27
+ });
14
28
  export const GITLAB_JOB_TOKEN = getConfig("job-token", "GITLAB_JOB_TOKEN");
15
29
  export const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
16
30
  export const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
package/build/index.js CHANGED
@@ -109,7 +109,8 @@ import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsername
109
109
  import { applyJmespathToToolResult, getJmespathSyntaxError, JMESPATH_TOOL_ARGUMENT, JMESPATH_TOOL_ARGUMENT_DESCRIPTION, omitJmespathArgument, readJmespathExpression, } from "./utils/jmespath-tool-result.js";
110
110
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
111
111
  import { GitLabClientPool } from "./gitlab-client-pool.js";
112
- import { advertisesReadOnly, allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, findSmallestToolsetForTool, isToolsetFullyActive, listDiscoverableCategories, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
112
+ import { advertisesReadOnly, allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, findSmallestToolsetForTool, isExplicitOnlyToolsetId, isExplicitToolsetOnlyTool, isToolsetFullyActive, listDiscoverableCategories, TOOLSET_DEFINITIONS, DISCOVERABLE_TOOLSET_IDS, } from "./tools/registry.js";
113
+ import { executeGitLabApiRequestAsync, GitLabApiRequestHttpError, GitLabApiRequestInputError, } from "./tools/gitlab-api-request.js";
113
114
  import { buildUnavailableToolMessage, isSkippedByDiscoverActivation, } from "./tools/discover-activation.js";
114
115
  import { selectExposedTools } from "./tools/exposed-tools.js";
115
116
  import { createListedToolsCache, decorateToolsForList, } from "./tools/listed-tools.js";
@@ -192,12 +193,14 @@ const MCP_PROMPTS = [
192
193
  * Regex-denied tools stay blocked, and slim-excluded tools are not activated.
193
194
  */
194
195
  function buildToolUnavailableMessage(toolName) {
196
+ const toolset = findSmallestToolsetForTool(toolName);
195
197
  return buildUnavailableToolMessage({
196
198
  toolName,
197
- toolset: findSmallestToolsetForTool(toolName),
199
+ toolset,
198
200
  deniedByRegex: GITLAB_DENIED_TOOLS_REGEX?.test(toolName) === true,
199
201
  excludedBySlimProfile: isExcludedFromToolsetListing(toolName),
200
202
  enabledByCurrentToolsets: isToolInEnabledToolset(toolName, enabledToolsets),
203
+ explicitToolsetOnly: toolset !== undefined && isExplicitOnlyToolsetId(toolset),
201
204
  });
202
205
  }
203
206
  /**
@@ -216,6 +219,7 @@ function createServer() {
216
219
  deniedToolsRegex: GITLAB_DENIED_TOOLS_REGEX,
217
220
  hiddenToolNames: hiddenToolSet,
218
221
  applySlimProfile,
222
+ isAdditiveEnablementAllowed: toolName => !isExplicitToolsetOnlyTool(toolName),
219
223
  });
220
224
  const listedToolDecoration = {
221
225
  readOnlyToolNames: new Set([...readOnlyTools].filter(toolName => advertisesReadOnly(toolName))),
@@ -402,12 +406,29 @@ function createServer() {
402
406
  ],
403
407
  });
404
408
  }
405
- if (!ALL_TOOLSET_IDS.has(category)) {
409
+ if (isExplicitOnlyToolsetId(category)) {
406
410
  return logCompletion({
407
411
  content: [
408
412
  {
409
413
  type: "text",
410
- text: `Unknown category "${category}". Available: ${[...ALL_TOOLSET_IDS].join(", ")}`,
414
+ text: `Category "${category}" cannot be activated with discover_tools. Add "${category}" to GITLAB_TOOLSETS. GITLAB_TOOLSETS=all does not enable it.`,
415
+ },
416
+ ],
417
+ isError: true,
418
+ });
419
+ }
420
+ let discoverableCategory = false;
421
+ for (const id of DISCOVERABLE_TOOLSET_IDS) {
422
+ if (id === category) {
423
+ discoverableCategory = true;
424
+ }
425
+ }
426
+ if (!discoverableCategory) {
427
+ return logCompletion({
428
+ content: [
429
+ {
430
+ type: "text",
431
+ text: `Unknown category "${category}". Available: ${[...DISCOVERABLE_TOOLSET_IDS].join(", ")}`,
411
432
  },
412
433
  ],
413
434
  isError: true,
@@ -743,7 +764,8 @@ function validateConfiguration() {
743
764
  // Validate auth configuration
744
765
  const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
745
766
  const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
746
- const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
767
+ // getConfig() would ignore GITLAB_PERSONAL_ACCESS_TOKEN_FILE.
768
+ const hasToken = !!GITLAB_PERSONAL_ACCESS_TOKEN;
747
769
  const hasJobToken = !!getConfig("job-token", "GITLAB_JOB_TOKEN");
748
770
  const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
749
771
  const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
@@ -1238,6 +1260,15 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1238
1260
  logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
1239
1261
  "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
1240
1262
  }
1263
+ if (enabledToolsets.has("api")) {
1264
+ logger.warn("gitlab_api_request is enabled because GITLAB_TOOLSETS lists api. " +
1265
+ "This tool can bypass tool allowlists (GITLAB_TOOLS, GITLAB_TOOLSETS selection, GITLAB_DENIED_TOOLS_REGEX for other tools), " +
1266
+ "group and project restrictions, and per-tool safety checks. It exposes the full token scope.");
1267
+ }
1268
+ const ignoredExplicitTools = [...individuallyEnabledTools].filter(name => isExplicitToolsetOnlyTool(name) && !isToolInEnabledToolset(name, enabledToolsets));
1269
+ if (ignoredExplicitTools.length > 0) {
1270
+ logger.warn(`GITLAB_TOOLS names ${ignoredExplicitTools.join(", ")}, but that tool is enabled only when its toolset is listed in GITLAB_TOOLSETS. The GITLAB_TOOLS entry is ignored.`);
1271
+ }
1241
1272
  const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
1242
1273
  function toAllowedGitLabApiUrl(value) {
1243
1274
  const trimmed = value.trim();
@@ -8091,6 +8122,29 @@ async function handleToolCall(params) {
8091
8122
  clearTimeout(timeout);
8092
8123
  }
8093
8124
  }
8125
+ case "gitlab_api_request": {
8126
+ try {
8127
+ return await executeGitLabApiRequestAsync({
8128
+ args: params.arguments,
8129
+ apiBaseUrl: getEffectiveApiUrl(),
8130
+ permissionMode: GITLAB_PERMISSION_MODE,
8131
+ allowedProjectIds: getEffectiveAllowedProjectIds(),
8132
+ fetchImpl: fetch,
8133
+ unauthenticatedFetchImpl: undiciFetch,
8134
+ isTrustedRedirectHost: isTrustedGitLabRedirectHost,
8135
+ fetchConfig: getFetchConfig,
8136
+ });
8137
+ }
8138
+ catch (error) {
8139
+ if (error instanceof GitLabApiRequestInputError) {
8140
+ throw new ToolInputError(error.message);
8141
+ }
8142
+ if (error instanceof GitLabApiRequestHttpError) {
8143
+ throw new GitLabApiError(error.message);
8144
+ }
8145
+ throw error;
8146
+ }
8147
+ }
8094
8148
  case "fork_repository": {
8095
8149
  rejectIfProjectScopedDeployment("fork_repository");
8096
8150
  const forkArgs = ForkRepositorySchema.parse(params.arguments);
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ const EMPTY_FILE_CODE = "EMPTY";
3
+ export class PersonalAccessTokenFileError extends Error {
4
+ code;
5
+ filePath;
6
+ constructor(filePath, code) {
7
+ super(`Cannot load GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} (${code})`);
8
+ this.name = "PersonalAccessTokenFileError";
9
+ this.filePath = filePath;
10
+ this.code = code;
11
+ }
12
+ }
13
+ function presentInlineToken(value) {
14
+ if (value === undefined || value.length === 0) {
15
+ return undefined;
16
+ }
17
+ return value;
18
+ }
19
+ function configuredFilePath(filePath) {
20
+ if (filePath === undefined) {
21
+ return undefined;
22
+ }
23
+ const trimmed = filePath.trim();
24
+ if (trimmed.length === 0) {
25
+ return undefined;
26
+ }
27
+ return trimmed;
28
+ }
29
+ function errnoCode(error) {
30
+ if (typeof error !== "object" || error === null || !("code" in error)) {
31
+ return "EIO";
32
+ }
33
+ const code = error.code;
34
+ if (typeof code !== "string" || code.length === 0) {
35
+ return "EIO";
36
+ }
37
+ return code;
38
+ }
39
+ export function readPersonalAccessTokenFile(filePath) {
40
+ return readFileSync(filePath, { encoding: "utf8" });
41
+ }
42
+ function ignoredFileWarning(filePath) {
43
+ return `Ignoring GITLAB_PERSONAL_ACCESS_TOKEN_FILE at ${filePath} because an inline personal access token is set`;
44
+ }
45
+ function readTokenFile(source, filePath) {
46
+ try {
47
+ return source.readFile(filePath);
48
+ }
49
+ catch (error) {
50
+ throw new PersonalAccessTokenFileError(filePath, errnoCode(error));
51
+ }
52
+ }
53
+ /**
54
+ * Resolve the personal access token once.
55
+ * `--token` wins, then a non-empty GITLAB_PERSONAL_ACCESS_TOKEN, then the file.
56
+ * Job tokens, OAuth, cookies, and remote authorization are not inputs.
57
+ */
58
+ export function resolvePersonalAccessToken(source) {
59
+ const inline = presentInlineToken(source.cliToken) ?? presentInlineToken(source.envToken);
60
+ const filePath = configuredFilePath(source.filePath);
61
+ if (inline !== undefined) {
62
+ if (filePath !== undefined) {
63
+ source.warn(ignoredFileWarning(filePath));
64
+ }
65
+ return inline;
66
+ }
67
+ if (filePath === undefined) {
68
+ return undefined;
69
+ }
70
+ const token = readTokenFile(source, filePath).trim();
71
+ if (token.length === 0) {
72
+ throw new PersonalAccessTokenFileError(filePath, EMPTY_FILE_CODE);
73
+ }
74
+ return token;
75
+ }
@@ -27,6 +27,9 @@ export function buildUnavailableToolMessage(input) {
27
27
  if (input.excludedBySlimProfile) {
28
28
  return `${base}. ${slimExclusionGuidance(input.toolName, toolset, input.enabledByCurrentToolsets)}`;
29
29
  }
30
+ if (input.explicitToolsetOnly) {
31
+ return `${base}. The "${toolset}" toolset is not enabled. discover_tools cannot activate it. Add "${toolset}" to GITLAB_TOOLSETS. GITLAB_TOOLSETS=all, GITLAB_TOOLS, and GITLAB_TOOL_PROFILE do not enable it.`;
32
+ }
30
33
  return `${base}. It belongs to the "${toolset}" toolset, which is not enabled: call discover_tools with category "${toolset}" or add "${toolset}" to GITLAB_TOOLSETS.`;
31
34
  }
32
35
  function slimExclusionGuidance(toolName, toolset, enabledByCurrentToolsets) {
@@ -1,15 +1,20 @@
1
1
  import { isExcludedBySlimProfile } from "./tool-profile.js";
2
2
  export function selectExposedTools(input) {
3
+ const additiveAllowed = input.isAdditiveEnablementAllowed ?? (() => true);
3
4
  const toolsAfterToolsets = input.tools.filter(tool => input.isInEnabledToolset(tool.name));
4
5
  const toolsetToolNames = new Set(toolsAfterToolsets.map(tool => tool.name));
5
6
  const toolsAfterIndividual = [
6
7
  ...toolsAfterToolsets,
7
- ...input.tools.filter(tool => input.individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
8
+ ...input.tools.filter(tool => input.individuallyEnabledTools.has(tool.name) &&
9
+ !toolsetToolNames.has(tool.name) &&
10
+ additiveAllowed(tool.name)),
8
11
  ];
9
12
  const afterIndividualNames = new Set(toolsAfterIndividual.map(tool => tool.name));
10
13
  const toolsAfterLegacy = [
11
14
  ...toolsAfterIndividual,
12
- ...input.tools.filter(tool => input.featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
15
+ ...input.tools.filter(tool => input.featureFlagOverrides.has(tool.name) &&
16
+ !afterIndividualNames.has(tool.name) &&
17
+ additiveAllowed(tool.name)),
13
18
  ];
14
19
  const toolsAfterPermission = toolsAfterLegacy.filter(tool => input.isAllowedByPermissionMode(tool.name));
15
20
  const deniedToolsRegex = input.deniedToolsRegex;
@@ -0,0 +1,256 @@
1
+ import { z } from "zod";
2
+ import { redactSensitiveGitLabFields, redactSensitiveGitLabText, } from "../utils/redact-sensitive.js";
3
+ import { fetchWithValidatedRedirects, UnsafeRedirectError, } from "../utils/safe-redirect-fetch.js";
4
+ import { normalizeGitLabApiRelativePath, readProjectIdFromApiRelativePath, resolveGitLabApiRequestUrl, } from "../utils/url.js";
5
+ /**
6
+ * Methods this tool will send. HEAD is omitted: callers do not need it, and
7
+ * a smaller allowlist is easier to gate. DELETE stays in the schema so
8
+ * `modify` can reject it at call time instead of hiding the whole tool.
9
+ */
10
+ export const GITLAB_API_REQUEST_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
11
+ const QUERY_SCALAR = z.union([z.string(), z.number(), z.boolean()]);
12
+ export const GitLabApiRequestSchema = z.object({
13
+ method: z
14
+ .enum(GITLAB_API_REQUEST_METHODS)
15
+ .optional()
16
+ .describe("HTTP method. Defaults to GET. Non-GET requires a permission mode that allows writes. DELETE is rejected in modify mode."),
17
+ path: z
18
+ .string()
19
+ .min(1)
20
+ .describe("Path relative to /api/v4, such as projects/1 or /projects/1/issues. Absolute URLs are rejected."),
21
+ query: z
22
+ .record(z.string(), z.union([QUERY_SCALAR, z.array(QUERY_SCALAR)]))
23
+ .optional()
24
+ .describe("Query string parameters. Arrays are repeated as separate keys."),
25
+ body: z
26
+ .unknown()
27
+ .optional()
28
+ .describe("JSON request body for POST, PUT, PATCH, and DELETE. Rejected on GET."),
29
+ });
30
+ export class GitLabApiRequestInputError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "GitLabApiRequestInputError";
34
+ }
35
+ }
36
+ export class GitLabApiRequestHttpError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = "GitLabApiRequestHttpError";
40
+ }
41
+ }
42
+ const REQUEST_TIMEOUT_MS = 45_000;
43
+ export function assertGitLabApiRequestMethodAllowed(method, permissionMode) {
44
+ if (method === "GET") {
45
+ return;
46
+ }
47
+ if (permissionMode === "readonly") {
48
+ throw new GitLabApiRequestInputError("gitlab_api_request allows only GET when GITLAB_READ_ONLY_MODE=true or GITLAB_PERMISSION_MODE=readonly");
49
+ }
50
+ if (permissionMode === "modify" && method === "DELETE") {
51
+ throw new GitLabApiRequestInputError("gitlab_api_request rejects DELETE when GITLAB_PERMISSION_MODE=modify (delete operations are disabled)");
52
+ }
53
+ }
54
+ function assertBodyAllowed(method, body) {
55
+ if (method === "GET" && body !== undefined) {
56
+ throw new GitLabApiRequestInputError("gitlab_api_request rejects a body on GET");
57
+ }
58
+ }
59
+ function projectAllowed(projectId, rawSegment, allowlist) {
60
+ const candidates = new Set([projectId, rawSegment]);
61
+ for (const entry of allowlist) {
62
+ if (candidates.has(entry)) {
63
+ return true;
64
+ }
65
+ try {
66
+ if (candidates.has(decodeURIComponent(entry))) {
67
+ return true;
68
+ }
69
+ }
70
+ catch {
71
+ // An undecodable allowlist entry cannot match this path.
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+ function apiRelativePathFromRedirect(apiBaseUrl, target, trustedAlternateHost) {
77
+ const base = new URL(apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`);
78
+ if (target.origin !== base.origin && !trustedAlternateHost) {
79
+ return undefined;
80
+ }
81
+ const basePrefix = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;
82
+ if (!target.pathname.startsWith(basePrefix)) {
83
+ return undefined;
84
+ }
85
+ return target.pathname.slice(basePrefix.length);
86
+ }
87
+ function assertRedirectedApiPathAllowed(apiBaseUrl, allowlist, target, trustedAlternateHost) {
88
+ const relative = apiRelativePathFromRedirect(apiBaseUrl, target, trustedAlternateHost);
89
+ const apiBase = new URL(apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`);
90
+ // Same-origin hops and trusted alternate hosts keep credential headers. A path
91
+ // outside the API base must be refused before the allowlist short-circuit,
92
+ // including when the allowlist is empty.
93
+ if (relative === undefined && (target.origin === apiBase.origin || trustedAlternateHost)) {
94
+ throw new GitLabApiRequestInputError("Redirect target must remain under the GitLab API base path");
95
+ }
96
+ if (allowlist.length === 0) {
97
+ return;
98
+ }
99
+ if (relative === undefined || relative.length === 0) {
100
+ return;
101
+ }
102
+ let normalized;
103
+ try {
104
+ normalized = normalizeGitLabApiRelativePath(relative);
105
+ }
106
+ catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ throw new GitLabApiRequestInputError(message);
109
+ }
110
+ assertProjectAllowlist(normalized, allowlist);
111
+ }
112
+ function assertProjectAllowlist(relativePath, allowlist) {
113
+ if (allowlist.length === 0) {
114
+ return;
115
+ }
116
+ let projectId;
117
+ try {
118
+ projectId = readProjectIdFromApiRelativePath(relativePath);
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ throw new GitLabApiRequestInputError(message);
123
+ }
124
+ if (projectId === undefined) {
125
+ return;
126
+ }
127
+ const rawSegment = relativePath.split("/")[1] ?? projectId;
128
+ if (!projectAllowed(projectId, rawSegment, allowlist)) {
129
+ throw new GitLabApiRequestInputError(`Access denied: Project ${projectId} is not in the allowed project list: ${allowlist.join(", ")}`);
130
+ }
131
+ }
132
+ function isHeaderList(headers) {
133
+ return !Array.isArray(headers) && "forEach" in headers && typeof headers.forEach === "function";
134
+ }
135
+ function headerRecord(headers) {
136
+ if (!headers) {
137
+ return {};
138
+ }
139
+ if (Array.isArray(headers)) {
140
+ const record = {};
141
+ for (const [name, value] of headers) {
142
+ if (typeof value === "string") {
143
+ record[name] = value;
144
+ }
145
+ }
146
+ return record;
147
+ }
148
+ if (isHeaderList(headers)) {
149
+ const record = {};
150
+ headers.forEach((value, key) => {
151
+ record[key] = value;
152
+ });
153
+ return record;
154
+ }
155
+ const record = {};
156
+ for (const [key, value] of Object.entries(headers)) {
157
+ if (typeof value === "string") {
158
+ record[key] = value;
159
+ }
160
+ }
161
+ return record;
162
+ }
163
+ function appendQuery(url, query) {
164
+ if (!query) {
165
+ return;
166
+ }
167
+ for (const [key, value] of Object.entries(query)) {
168
+ if (Array.isArray(value)) {
169
+ for (const item of value) {
170
+ url.searchParams.append(key, String(item));
171
+ }
172
+ continue;
173
+ }
174
+ url.searchParams.append(key, String(value));
175
+ }
176
+ }
177
+ function formatHttpError(status, statusText, rawBody) {
178
+ const trimmed = rawBody.trim();
179
+ if (trimmed.length === 0) {
180
+ return `GitLab API error: ${status} ${statusText}`;
181
+ }
182
+ try {
183
+ const parsed = JSON.parse(rawBody);
184
+ redactSensitiveGitLabFields(parsed);
185
+ return `GitLab API error: ${status} ${statusText}\n${JSON.stringify(parsed)}`;
186
+ }
187
+ catch {
188
+ return `GitLab API error: ${status} ${statusText}\n${redactSensitiveGitLabText(rawBody)}`;
189
+ }
190
+ }
191
+ function formatSuccessBody(rawBody) {
192
+ const trimmed = rawBody.trim();
193
+ if (trimmed.length === 0) {
194
+ return "";
195
+ }
196
+ try {
197
+ const parsed = JSON.parse(rawBody);
198
+ redactSensitiveGitLabFields(parsed);
199
+ return JSON.stringify(parsed, null, 2);
200
+ }
201
+ catch {
202
+ return redactSensitiveGitLabText(rawBody);
203
+ }
204
+ }
205
+ export async function executeGitLabApiRequestAsync(input) {
206
+ const args = GitLabApiRequestSchema.parse(input.args);
207
+ const method = args.method ?? "GET";
208
+ assertGitLabApiRequestMethodAllowed(method, input.permissionMode);
209
+ assertBodyAllowed(method, args.body);
210
+ let url;
211
+ let relative;
212
+ try {
213
+ relative = normalizeGitLabApiRelativePath(args.path);
214
+ url = resolveGitLabApiRequestUrl(input.apiBaseUrl, args.path);
215
+ }
216
+ catch (error) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ throw new GitLabApiRequestInputError(message);
219
+ }
220
+ assertProjectAllowlist(relative, input.allowedProjectIds);
221
+ appendQuery(url, args.query);
222
+ const config = input.fetchConfig();
223
+ const headers = headerRecord(config.headers);
224
+ const body = method === "GET" || args.body === undefined ? undefined : JSON.stringify(args.body);
225
+ const controller = new AbortController();
226
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
227
+ try {
228
+ const response = await fetchWithValidatedRedirects(url.toString(), {
229
+ headers,
230
+ dispatcher: config.dispatcher,
231
+ signal: controller.signal,
232
+ method,
233
+ ...(body === undefined ? {} : { body }),
234
+ fetchImpl: input.fetchImpl,
235
+ unauthenticatedFetchImpl: input.unauthenticatedFetchImpl,
236
+ isTrustedRedirectHost: input.isTrustedRedirectHost,
237
+ validateRedirectTarget: target => {
238
+ assertRedirectedApiPathAllowed(input.apiBaseUrl, input.allowedProjectIds, target, input.isTrustedRedirectHost?.(target.host) === true);
239
+ },
240
+ });
241
+ const rawBody = await response.text();
242
+ if (!response.ok) {
243
+ throw new GitLabApiRequestHttpError(formatHttpError(response.status, response.statusText, rawBody));
244
+ }
245
+ return { content: [{ type: "text", text: formatSuccessBody(rawBody) }] };
246
+ }
247
+ catch (error) {
248
+ if (error instanceof UnsafeRedirectError) {
249
+ throw new GitLabApiRequestHttpError(error.message);
250
+ }
251
+ throw error;
252
+ }
253
+ finally {
254
+ clearTimeout(timeout);
255
+ }
256
+ }
@@ -2,6 +2,7 @@ import { toJSONSchema } from "../utils/schema.js";
2
2
  import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, SSE, STREAMABLE_HTTP } from "../config.js";
3
3
  import { getToolDescription } from "./tool-descriptions.js";
4
4
  import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, ErasePipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWebhookSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, ProtectTagSchema, UnprotectBranchSchema, UnprotectTagSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWebhookSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, CreateDeploymentSchema, UpdateDeploymentSchema, DeploymentApprovalSchema, ListDeploymentMergeRequestsSchema, GetDraftNoteSchema, GetEnvironmentSchema, UpdateEnvironmentSchema, StopEnvironmentSchema, StopStaleEnvironmentsSchema, DeleteReviewAppEnvironmentsSchema, PipelineTriggerIdSchema, ListPipelineTriggersSchema, CreatePipelineTriggerSchema, UpdatePipelineTriggerSchema, TriggerPipelineSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestDiscussionSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetRunnerSchema, GetPipelineVariablesSchema, UpdatePipelineMetadataSchema, DeletePipelineSchema, PipelineReportSchema, WaitForPipelineSchema, WaitForPipelineJobSchema, PlayPipelineJobsSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetProtectedTagSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupMilestonesSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListPipelinesSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, ListGroupMembersSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListProjectRunnersSchema, ListReleasesSchema, ListTagsSchema, ListProtectedTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, ListSnippetsSchema, GetSnippetSchema, CreateSnippetSchema, UpdateSnippetSchema, DeleteSnippetSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWebhookSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, } from "../schemas.js";
5
+ import { GitLabApiRequestSchema } from "./gitlab-api-request.js";
5
6
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
6
7
  // Define all available tools
7
8
  export const allTools = [
@@ -1396,6 +1397,12 @@ export const allTools = [
1396
1397
  description: "Get one runner by ID",
1397
1398
  inputSchema: toJSONSchema(GetRunnerSchema),
1398
1399
  },
1400
+ // --- Explicit opt-in raw API. Not in `all` and not activatable via discover_tools. ---
1401
+ {
1402
+ name: "gitlab_api_request",
1403
+ description: "DANGER: bypasses group and project restrictions, tool allowlists (GITLAB_TOOLS and GITLAB_DENIED_TOOLS_REGEX on other tools), and individual tool safety checks. The full token scope is exposed.",
1404
+ inputSchema: toJSONSchema(GitLabApiRequestSchema),
1405
+ },
1399
1406
  // --- Meta tool: Dynamic tool discovery ---
1400
1407
  {
1401
1408
  name: "discover_tools",
@@ -1420,6 +1427,8 @@ export const readOnlyTools = new Set([
1420
1427
  "search_project_code",
1421
1428
  "search_group_code",
1422
1429
  "execute_graphql",
1430
+ // Mixed-access: listed in readonly so GET still works. Writes are rejected in the handler.
1431
+ "gitlab_api_request",
1423
1432
  "get_file_contents",
1424
1433
  "get_merge_request",
1425
1434
  "get_merge_request_diffs",
@@ -1560,7 +1569,7 @@ export const readOnlyTools = new Set([
1560
1569
  ]);
1561
1570
  // Stays in readOnlyTools so readonly mode can still run queries, but mutations
1562
1571
  // are allowed in other permission modes and rejected only at call time.
1563
- const MIXED_ACCESS_TOOLS = new Set(["execute_graphql"]);
1572
+ const MIXED_ACCESS_TOOLS = new Set(["execute_graphql", "gitlab_api_request"]);
1564
1573
  export function advertisesReadOnly(toolName) {
1565
1574
  return readOnlyTools.has(toolName) && !MIXED_ACCESS_TOOLS.has(toolName);
1566
1575
  }
@@ -1611,6 +1620,9 @@ export const destructiveTools = new Set([
1611
1620
  "delete_pipeline_schedule",
1612
1621
  "delete_pipeline_schedule_variable",
1613
1622
  "purge_dependency_proxy_cache",
1623
+ // Can DELETE (and other writes). Hint is true; modify mode still lists the tool and
1624
+ // the handler rejects DELETE. Putting it in deleteTools would hide GET/POST too.
1625
+ "gitlab_api_request",
1614
1626
  ]);
1615
1627
  // Tools blocked in "modify" permission mode: permanent deletions plus destructive
1616
1628
  // teardown verbs that do not start with `delete_` (see the comment inside).
@@ -2189,6 +2201,12 @@ export const TOOLSET_DEFINITIONS = [
2189
2201
  isDefault: false,
2190
2202
  tools: new Set(["orbit_query", "orbit_get_schema", "orbit_get_status", "orbit_list_tools"]),
2191
2203
  },
2204
+ {
2205
+ id: "api",
2206
+ isDefault: false,
2207
+ explicitOnly: true,
2208
+ tools: new Set(["gitlab_api_request"]),
2209
+ },
2192
2210
  ];
2193
2211
  // Derived lookup: tool name → toolset IDs. Most tools belong to one category.
2194
2212
  // The default `core` toolset intentionally overlaps with the full opt-in
@@ -2204,6 +2222,24 @@ for (const def of TOOLSET_DEFINITIONS) {
2204
2222
  }
2205
2223
  export const DEFAULT_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(d => d.isDefault).map(d => d.id));
2206
2224
  export const ALL_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.map(d => d.id));
2225
+ /** Toolsets `GITLAB_TOOLSETS=all` and `discover_tools` may enable. Excludes explicitOnly. */
2226
+ export const DISCOVERABLE_TOOLSET_IDS = new Set(TOOLSET_DEFINITIONS.filter(definition => definition.explicitOnly !== true).map(definition => definition.id));
2227
+ export function isExplicitOnlyToolsetId(id) {
2228
+ return TOOLSET_DEFINITIONS.some(definition => definition.id === id && definition.explicitOnly === true);
2229
+ }
2230
+ /** True when every toolset that contains the tool is explicit-only. */
2231
+ export function isExplicitToolsetOnlyTool(toolName) {
2232
+ const toolsetIds = TOOLSETS_BY_TOOL_NAME.get(toolName);
2233
+ if (toolsetIds === undefined || toolsetIds.size === 0) {
2234
+ return false;
2235
+ }
2236
+ for (const toolsetId of toolsetIds) {
2237
+ if (!isExplicitOnlyToolsetId(toolsetId)) {
2238
+ return false;
2239
+ }
2240
+ }
2241
+ return true;
2242
+ }
2207
2243
  /**
2208
2244
  * Smallest toolset containing the tool, i.e. the cheapest category to activate for it.
2209
2245
  * Returns undefined for tools outside every toolset (e.g. `execute_graphql`) and unknown names.
@@ -2216,7 +2252,7 @@ export function findSmallestToolsetForTool(toolName) {
2216
2252
  // Update discover_tools description with all known categories (must be after TOOLSET_DEFINITIONS)
2217
2253
  const discoverTool = allTools.find(t => t.name === "discover_tools");
2218
2254
  if (discoverTool) {
2219
- discoverTool.description = `Discover and activate additional tool categories for this session. Available categories: ${[...ALL_TOOLSET_IDS].join(", ")}. Already-active categories are listed in the response.`;
2255
+ discoverTool.description = `Discover and activate additional tool categories for this session. Available categories: ${[...DISCOVERABLE_TOOLSET_IDS].join(", ")}. Already-active categories are listed in the response. Explicit-only toolsets are omitted; list them in GITLAB_TOOLSETS.`;
2220
2256
  }
2221
2257
  for (const tool of allTools) {
2222
2258
  tool.description = getToolDescription(tool.name, tool.description, readOnlyTools.has(tool.name), destructiveTools.has(tool.name));
@@ -2227,7 +2263,7 @@ export function parseEnabledToolsets(raw) {
2227
2263
  }
2228
2264
  const trimmed = raw.trim().toLowerCase();
2229
2265
  if (trimmed === "all") {
2230
- return ALL_TOOLSET_IDS;
2266
+ return DISCOVERABLE_TOOLSET_IDS;
2231
2267
  }
2232
2268
  const selected = new Set(trimmed
2233
2269
  .split(",")
@@ -2299,7 +2335,7 @@ export function isToolsetFullyActive(tools, currentToolNames, isExcluded) {
2299
2335
  }
2300
2336
  /** Category listing returned by `discover_tools` when no category argument is given. */
2301
2337
  export function listDiscoverableCategories(currentToolNames, isExcluded) {
2302
- return TOOLSET_DEFINITIONS.map(def => ({
2338
+ return TOOLSET_DEFINITIONS.filter(def => def.explicitOnly !== true).map(def => ({
2303
2339
  id: def.id,
2304
2340
  toolCount: def.tools.size,
2305
2341
  active: isToolsetFullyActive(def.tools, currentToolNames, isExcluded),
@@ -36,6 +36,7 @@ const TOOL_GUIDANCE = {
36
36
  delete_issue_link: "Use this to remove an existing relationship between two issues; use `list_issue_links` or `get_issue_link` to verify the link first. The operation changes issue relationships, requires issue-edit permission, and returns the result or an error when the link is missing or access is denied.",
37
37
  download_job_artifacts: "Use this to retrieve a pipeline job's artifact archive; remote HTTP mode returns a download URL while local mode saves the archive to a local path. It is read-only but may create a local file in stdio mode, requires job/project access, and returns the download result or an artifact/permission error.",
38
38
  download_attachment: "Use this to retrieve a previously uploaded project attachment; remote mode returns inline base64 for images or a download URL, while local mode can save to a path. It is read-only with respect to GitLab, requires project access, and returns the file content or an attachment/permission error.",
39
+ gitlab_api_request: "Send one GitLab REST call under /api/v4. Readonly mode allows GET only, and modify mode rejects DELETE. Enable it only by listing api in GITLAB_TOOLSETS; all, discover_tools, and GITLAB_TOOLS do not. Prompt injection can drive this tool, so operators who rely on group, project, or tool restrictions must not enable it. GITLAB_ALLOWED_PROJECT_IDS is enforced only for /projects/:id paths; other endpoints are not constrained.",
39
40
  execute_graphql: "Use this only when a supported GitLab REST tool does not cover the requested operation; prefer a typed tool when one exists. The query is sent directly to GitLab and can include mutations when permission allows, so callers must treat it as potentially state-changing and handle GraphQL errors in the returned response.",
40
41
  discover_tools: "Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data.",
41
42
  health_check: "Use this to verify server connectivity and authentication before making GitLab requests; use `whoami` when the authenticated user's identity is the goal. A 403 from GET /user with an insufficient_scope error means the token is accepted but User API scope is missing (read_user or fine-grained User:Read); the check stays ok, sets user_api_warning, and omits version metadata. Rate-limit and authentication-ban 403 responses stay unauthenticated. It does not mutate GitLab state and returns server/authentication status plus GitLab version details when available. Connection failures with no HTTP response return status error, authenticated false, and an error object (kind tls, dns, timeout, or network, plus code and message) instead of a protocol error.",
@@ -36,3 +36,14 @@ export function redactSensitiveGitLabFields(data) {
36
36
  }
37
37
  return data;
38
38
  }
39
+ const SENSITIVE_ASSIGNMENT_PATTERN = /(["']?\b(?:runners_token|import_url|token)\b["']?\s*[:=]\s*)(?:"(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*'|\S+)/gi;
40
+ const GITLAB_TOKEN_PATTERN = /\bgl(?:pat|dt|rt|pt|ft|oas|soat)-[A-Za-z0-9._-]+/gi;
41
+ /**
42
+ * Redacts the same secret fields when GitLab returns a non-JSON body.
43
+ * JSON responses keep using {@link redactSensitiveGitLabFields}.
44
+ */
45
+ export function redactSensitiveGitLabText(text) {
46
+ return text
47
+ .replace(SENSITIVE_ASSIGNMENT_PATTERN, "$1[REDACTED]")
48
+ .replace(GITLAB_TOKEN_PATTERN, "[REDACTED]");
49
+ }
@@ -220,8 +220,17 @@ async function assertRedirectTargetAllowed(target, trusted, options) {
220
220
  "Add the host to GITLAB_ALLOWED_HOSTS if the redirect target is trusted.");
221
221
  }
222
222
  }
223
+ function methodAfterRedirect(status, method) {
224
+ if ((status === 301 || status === 302) && method === "POST") {
225
+ return { method: "GET", dropBody: true };
226
+ }
227
+ if (status === 303 && method !== "GET" && method !== "HEAD") {
228
+ return { method: "GET", dropBody: true };
229
+ }
230
+ return { method, dropBody: false };
231
+ }
223
232
  /**
224
- * Perform a GET request that validates every redirect hop before following it.
233
+ * Perform a request that validates every redirect hop before following it.
225
234
  *
226
235
  * The HTTP client default follows redirects without re-validating the destination,
227
236
  * which lets an upstream response redirect the server to an arbitrary host
@@ -261,6 +270,8 @@ export async function fetchWithValidatedRedirects(url, options) {
261
270
  let currentUrl = url;
262
271
  let currentProtocol = new URL(url).protocol;
263
272
  let currentHeaders = options.headers;
273
+ let currentMethod = (options.method ?? "GET").toUpperCase();
274
+ let currentBody = options.body;
264
275
  // Set once a hop outside the origin has been followed without the credentials, so a
265
276
  // later hop back to the origin cannot re-attach them.
266
277
  let credentialsWithheld = false;
@@ -268,8 +279,9 @@ export async function fetchWithValidatedRedirects(url, options) {
268
279
  // A stripped hop must not go through a client that can add credentials again.
269
280
  const hopFetch = credentialsWithheld ? unauthenticatedFetch : authenticatedFetch;
270
281
  const response = await hopFetch(currentUrl, {
271
- method: "GET",
282
+ method: currentMethod,
272
283
  headers: currentHeaders,
284
+ ...(currentBody === undefined ? {} : { body: currentBody }),
273
285
  dispatcher: options.dispatcher,
274
286
  signal: options.signal,
275
287
  redirect: "manual",
@@ -314,6 +326,12 @@ export async function fetchWithValidatedRedirects(url, options) {
314
326
  credentialsWithheld ||= !trusted;
315
327
  currentHeaders = credentialsWithheld ? withoutCredentialHeaders(options) : options.headers;
316
328
  }
329
+ await options.validateRedirectTarget?.(target);
330
+ const rewritten = methodAfterRedirect(response.status, currentMethod);
331
+ currentMethod = rewritten.method;
332
+ if (rewritten.dropBody) {
333
+ currentBody = undefined;
334
+ }
317
335
  currentUrl = target.toString();
318
336
  currentProtocol = target.protocol;
319
337
  }
@@ -134,3 +134,91 @@ export function encodeGitLabPath(value) {
134
134
  }
135
135
  return segments.map(encodeGitLabPathSegment).join("/");
136
136
  }
137
+ const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
138
+ function looksLikeAbsoluteApiReference(value) {
139
+ return value.includes("://") || value.startsWith("//") || ABSOLUTE_URL_PATTERN.test(value);
140
+ }
141
+ /**
142
+ * Normalize a caller path so it can only name a resource under `/api/v4`.
143
+ * Rejects absolute URLs, protocol-relative hosts, backslashes, dot segments
144
+ * (including percent-encoded ones), and control characters. A single leading
145
+ * or trailing slash is removed so `/projects/1` and `projects/1` are the same
146
+ * relative path.
147
+ */
148
+ export function normalizeGitLabApiRelativePath(rawPath) {
149
+ const trimmed = rawPath.trim();
150
+ if (trimmed.length === 0) {
151
+ throw new Error("GitLab API path is empty");
152
+ }
153
+ if (CONTROL_CHARACTER_PATTERN.test(trimmed) ||
154
+ trimmed.includes("\\") ||
155
+ looksLikeAbsoluteApiReference(trimmed)) {
156
+ throw new Error("GitLab API path must be a relative path under /api/v4 (absolute URLs, protocol-relative hosts, backslashes, and control characters are rejected)");
157
+ }
158
+ const withoutLeadingSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
159
+ const relative = withoutLeadingSlash.endsWith("/")
160
+ ? withoutLeadingSlash.slice(0, -1)
161
+ : withoutLeadingSlash;
162
+ if (relative.length === 0 || relative.startsWith("/")) {
163
+ throw new Error("GitLab API path must be a relative path under /api/v4");
164
+ }
165
+ const decoded = decodeFully(relative);
166
+ if (decoded === null ||
167
+ CONTROL_CHARACTER_PATTERN.test(decoded) ||
168
+ decoded.includes("\\") ||
169
+ looksLikeAbsoluteApiReference(decoded)) {
170
+ throw new Error("GitLab API path must be a relative path under /api/v4 (absolute URLs, protocol-relative hosts, backslashes, and control characters are rejected)");
171
+ }
172
+ // encodeGitLabPath rejects `.` / `..` segments, including percent-encoded forms.
173
+ encodeGitLabPath(relative);
174
+ return relative;
175
+ }
176
+ /**
177
+ * Resolve `rawPath` against the configured GitLab API base (`.../api/v4`).
178
+ * The result is the same origin and stays under that base path.
179
+ */
180
+ export function resolveGitLabApiRequestUrl(apiBase, rawPath) {
181
+ const relative = normalizeGitLabApiRelativePath(rawPath);
182
+ const encoded = encodeGitLabPath(relative);
183
+ const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
184
+ const resolved = new URL(encoded, base);
185
+ if (resolved.origin !== base.origin || resolved.username !== "" || resolved.password !== "") {
186
+ throw new Error("GitLab API path escapes the configured API origin");
187
+ }
188
+ const basePath = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`;
189
+ const staysUnderBase = resolved.pathname === base.pathname.replace(/\/$/, "") ||
190
+ resolved.pathname.startsWith(basePath);
191
+ if (!staysUnderBase || resolved.search !== "" || resolved.hash !== "") {
192
+ throw new Error("GitLab API path escapes the configured /api/v4 base");
193
+ }
194
+ return resolved;
195
+ }
196
+ function decodeApiPathSegment(segment) {
197
+ const decoded = decodeFully(segment);
198
+ if (decoded === null) {
199
+ throw new Error("GitLab API path has an invalid project id encoding");
200
+ }
201
+ return decoded;
202
+ }
203
+ /**
204
+ * Project id from `projects/:id` or `projects/:id/...`.
205
+ *
206
+ * Segments are percent-decoded until stable, the same way the outgoing request
207
+ * is built. An encoded `projects` prefix still selects this route. A slash
208
+ * encoded inside the id (`group%2Fproj`) stays part of that one id.
209
+ * Other paths return undefined.
210
+ */
211
+ export function readProjectIdFromApiRelativePath(relativePath) {
212
+ const segments = relativePath.split("/");
213
+ if (segments.length < 2) {
214
+ return undefined;
215
+ }
216
+ if (decodeApiPathSegment(segments[0]) !== "projects") {
217
+ return undefined;
218
+ }
219
+ const projectId = decodeApiPathSegment(segments[1]);
220
+ if (projectId.length === 0) {
221
+ return undefined;
222
+ }
223
+ return projectId;
224
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [