@zereight/mcp-gitlab 2.2.4 → 2.2.6

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
@@ -36,7 +36,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
36
36
 
37
37
  ### 왜 이 GitLab MCP를 사용하나요?
38
38
 
39
- - **274개 도구: toolset 272개 + `execute_graphql` + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화
39
+ - **275개 도구: toolset 273개 + `execute_graphql` + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화
40
40
  - **MR 2단계 리뷰** — `list_merge_request_changed_files` → 배치 `get_merge_request_file_diff`
41
41
  - **Agent Skill 내장** — `skills/gitlab-mcp/` 워크플로우 가이드
42
42
  - **유연한 인증** — Personal Access Token, 로컬 OAuth2 브라우저 플로우, MCP OAuth 프록시, 요청별 원격 인증
@@ -50,7 +50,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며
50
50
  | | @zereight/mcp-gitlab | GitLab MCP A (커뮤니티 CQRS형) | GitLab 공식 MCP 서버 |
51
51
  | --------------- | ---------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
52
52
  | **적합한 경우** | AI 에이전트 워크플로우 | 엔터프라이즈 멀티 인스턴스 / 그룹형 도구 | GitLab에 내장, 별도 설치 없음 |
53
- | **도구 모델** | 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 |
54
54
  | **MR 리뷰** | 2단계 배치 diff | 서버마다 다름 | `get_merge_request`에 diffs |
55
55
  | **Node.js** | >=18.17 | 보통 >=24 | stdio는 `mcp-remote`, Node.js 20+ |
56
56
  | **라이선스** | MIT | 서버마다 다름 | GitLab의 일부 |
@@ -138,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
138
138
 
139
139
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
140
140
 
141
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.2.3`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
141
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.2.5`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
142
142
 
143
143
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
144
144
 
@@ -162,7 +162,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
162
162
  - `--api-url` - GitLab API URL (`GITLAB_API_URL` 대체)
163
163
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); `--permission-mode=readonly`를 사용 (`GITLAB_READ_ONLY_MODE` 대체)
164
164
  - `--permission-mode` - 권한 수준: `readonly`, `modify`(삭제/중단 도구 비활성), `full` (`GITLAB_PERMISSION_MODE` 대체, 기본값 `full`)
165
- - `--toolsets=all` - 지정한 툴셋 활성화 (`GITLAB_TOOLSETS` 대체, 미설정 시 lean `core` 기본값 사용)
165
+ - `--toolsets=all` - 지정한 툴셋 활성화. 명시적 `api` 툴셋은 빠짐 (`GITLAB_TOOLSETS` 대체, 미설정 시 lean `core` 기본값 사용). `gitlab_api_request`는 `--toolsets=api`
166
166
  - `--tools=list_issues` - 개별 도구 추가 (`GITLAB_TOOLS` 대체)
167
167
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); `--toolsets=core,wiki`를 사용 (`USE_GITLAB_WIKI` 대체)
168
168
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); `--toolsets=core,milestones`를 사용 (`USE_MILESTONE` 대체)
@@ -202,6 +202,10 @@ CLI 인자는 환경 변수보다 우선합니다.
202
202
  > [Tools Reference](./docs/tools/index.md#feature-toggles)와
203
203
  > [Environment Variables](./docs/configuration/environment-variables.md)를 참고하세요.
204
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
+
205
209
  - sse
206
210
 
207
211
  변수 이름만 넘기세요. Docker 클라이언트는 export한 `GITLAB_PERSONAL_ACCESS_TOKEN` 값을 컨테이너로 복사합니다.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
41
41
 
42
42
  ### Why use this GitLab MCP?
43
43
 
44
- - **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
45
45
  - **MR 2-step review** — `list_merge_request_changed_files` → batched `get_merge_request_file_diff`
46
46
  - **Agent Skill built in** — workflow guidance in `skills/gitlab-mcp/`
47
47
  - **Flexible auth** — Personal Access Token, local OAuth2 browser flow, MCP OAuth proxy, and per-request remote authorization
@@ -55,7 +55,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization
55
55
  | | @zereight/mcp-gitlab | GitLab MCP A (community CQRS-style) | GitLab official MCP server |
56
56
  | -------------- | ----------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
57
57
  | **Best for** | AI agent workflows | Enterprise multi-instance / grouped tools | Built into GitLab, no separate install |
58
- | **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 |
59
59
  | **MR review** | 2-step batched diff | Varies | `get_merge_request` with diffs |
60
60
  | **Node.js** | >=18.17 | Often >=24 | Node.js 20+ for stdio via `mcp-remote` |
61
61
  | **License** | MIT | Varies | Part of GitLab |
@@ -143,7 +143,7 @@ The store path is pinned by your lock file; update it with `nix flake update git
143
143
 
144
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`.
145
145
 
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.3`. 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.5`. 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`).
147
147
 
148
148
  #### Using CLI Arguments (for clients with env var issues)
149
149
 
@@ -167,7 +167,7 @@ Some MCP clients (like GitHub Copilot CLI) have issues with environment variable
167
167
  - `--api-url` - GitLab API URL (replaces `GITLAB_API_URL`)
168
168
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` instead (replaces `GITLAB_READ_ONLY_MODE`)
169
169
  - `--permission-mode` - Permission level: `readonly`, `modify` (no delete or teardown tools), or `full` (replaces `GITLAB_PERMISSION_MODE`, default `full`)
170
- - `--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`
171
171
  - `--tools=list_issues` - Add individual tools (replaces `GITLAB_TOOLS`)
172
172
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` instead (replaces `USE_GITLAB_WIKI`)
173
173
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` instead (replaces `USE_MILESTONE`)
@@ -208,6 +208,10 @@ The same binary is also a `gh`-style GitLab CLI (`tool <name>` or curated forms
208
208
  > See [Tools Reference](./docs/tools/index.md#feature-toggles) and
209
209
  > [Environment Variables](./docs/configuration/environment-variables.md).
210
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
+
211
215
  - sse
212
216
 
213
217
  Pass the variable name only. The Docker client copies the exported `GITLAB_PERSONAL_ACCESS_TOKEN` into the container.
@@ -918,7 +922,8 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
918
922
  271. `delete_snippet` - Delete a snippet
919
923
  272. `list_project_runners` - List runners available to a project (type, status, paused, tag_list). Omits registration and authentication tokens.
920
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.
921
- 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.
922
927
 
923
928
  <!-- TOOLS-END -->
924
929
 
package/README.pt-BR.md CHANGED
@@ -36,7 +36,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
36
36
 
37
37
  ### Por que usar este GitLab MCP?
38
38
 
39
- - **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
40
40
  - **Revisão de MR em 2 etapas** — `list_merge_request_changed_files` → `get_merge_request_file_diff` em lote
41
41
  - **Agent Skill integrado** — orientação de workflows em `skills/gitlab-mcp/`
42
42
  - **Autenticação flexível** — Personal Access Token, fluxo OAuth2 local no navegador, proxy MCP OAuth e autorização remota por requisição
@@ -50,7 +50,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
50
50
  | | @zereight/mcp-gitlab | GitLab MCP A (comunidade, estilo CQRS) | Servidor MCP oficial do GitLab |
51
51
  | ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
52
  | **Mais indicado para** | Workflows de agentes de IA | Múltiplas instâncias corporativas / ferramentas agrupadas | Integrado ao GitLab, sem instalação separada |
53
- | **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 |
54
54
  | **Revisão de MR** | Diff em lote em 2 etapas | Varia | `get_merge_request` com diffs |
55
55
  | **Node.js** | >=18.17 | Frequentemente >=24 | Node.js 20+ para stdio via `mcp-remote` |
56
56
  | **Licença** | MIT | Varia | Parte do GitLab |
@@ -138,7 +138,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
138
138
 
139
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`.
140
140
 
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.3`. 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.5`. 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`).
142
142
 
143
143
  #### Usando argumentos de CLI (para clientes com problemas com variáveis de ambiente)
144
144
 
@@ -162,7 +162,7 @@ Alguns clientes MCP, como o GitHub Copilot CLI, têm problemas com variáveis de
162
162
  - `--api-url` - URL da API do GitLab (substitui `GITLAB_API_URL`)
163
163
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` (substitui `GITLAB_READ_ONLY_MODE`)
164
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`)
165
- - `--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`
166
166
  - `--tools=list_issues` - adiciona ferramentas individuais (substitui `GITLAB_TOOLS`)
167
167
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` (substitui `USE_GITLAB_WIKI`)
168
168
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` (substitui `USE_MILESTONE`)
@@ -202,6 +202,10 @@ O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool <name>` ou f
202
202
  > Consulte a [Referência de ferramentas](./docs/tools/index.md#feature-toggles) e
203
203
  > [Variáveis de ambiente](./docs/configuration/environment-variables.md).
204
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
+
205
209
  - sse
206
210
 
207
211
  Passe só o nome da variável. O cliente Docker copia o `GITLAB_PERSONAL_ACCESS_TOKEN` exportado para o contêiner.
package/README.zh-CN.md CHANGED
@@ -36,7 +36,7 @@ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN
36
36
 
37
37
  ### 为什么使用这个 GitLab MCP?
38
38
 
39
- - **274 个工具:toolset 中 272 个 + `execute_graphql` + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
39
+ - **275 个工具:toolset 中 273 个 + `execute_graphql` + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
40
40
  - **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff`
41
41
  - **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南
42
42
  - **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权
@@ -50,7 +50,7 @@ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN
50
50
  | | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) | GitLab 官方 MCP 服务器 |
51
51
  | ------------ | -------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
52
52
  | **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 | 内置于 GitLab,无需单独安装 |
53
- | **工具模型** | 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 |
54
54
  | **MR 审查** | 两步批量 diff | 因服务器而异 | `get_merge_request` 带 diffs |
55
55
  | **Node.js** | >=18.17 | 通常 >=24 | stdio 经 `mcp-remote`,需要 Node.js 20+ |
56
56
  | **许可证** | MIT | 因服务器而异 | GitLab 的一部分 |
@@ -138,7 +138,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
138
138
 
139
139
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
140
140
 
141
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.2.3`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
141
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.2.5`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
142
142
 
143
143
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
144
144
 
@@ -162,7 +162,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
162
162
  - `--api-url` - GitLab API URL(替代 `GITLAB_API_URL`)
163
163
  - `--read-only=true` - Deprecated (removed in next major (v3.0.0));请改用 `--permission-mode=readonly`(替代 `GITLAB_READ_ONLY_MODE`)
164
164
  - `--permission-mode` - 权限级别:`readonly`、`modify`(禁用删除/拆除工具)或 `full`(替代 `GITLAB_PERMISSION_MODE`,默认 `full`)
165
- - `--toolsets=all` - 启用指定工具集(替代 `GITLAB_TOOLSETS`;未设置时使用精简 `core` 默认值)
165
+ - `--toolsets=all` - 启用指定工具集,但不包括必须显式列出的 `api`(替代 `GITLAB_TOOLSETS`;未设置时使用精简 `core` 默认值)。`gitlab_api_request` 需要 `--toolsets=api`
166
166
  - `--tools=list_issues` - 添加单个工具(替代 `GITLAB_TOOLS`)
167
167
  - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0));请改用 `--toolsets=core,wiki`(替代 `USE_GITLAB_WIKI`)
168
168
  - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0));请改用 `--toolsets=core,milestones`(替代 `USE_MILESTONE`)
@@ -201,6 +201,10 @@ CLI 参数优先于环境变量。
201
201
  > 参见 [Tools Reference](./docs/tools/index.md#feature-toggles) 和
202
202
  > [Environment Variables](./docs/configuration/environment-variables.md)。
203
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
+
204
208
  - sse
205
209
 
206
210
  只传变量名。Docker 客户端把已 export 的 `GITLAB_PERSONAL_ACCESS_TOKEN` 复制进容器。
@@ -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/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,
@@ -1165,6 +1186,27 @@ function getEffectiveAllowedProjectIds() {
1165
1186
  }
1166
1187
  return GITLAB_ALLOWED_PROJECT_IDS;
1167
1188
  }
1189
+ /**
1190
+ * Keep a dynamic project scope bound to its session
1191
+ * A request that omits X-GitLab-Allowed-Project-Ids keeps the session scope, and a
1192
+ * request naming a different scope is refused (null). An unscoped session may be
1193
+ * narrowed. Re-initialize to change the scope.
1194
+ */
1195
+ function bindSessionProjectScope(requested, sessionScope) {
1196
+ if (!sessionScope || sessionScope.length === 0) {
1197
+ return requested;
1198
+ }
1199
+ if (requested === undefined) {
1200
+ return sessionScope;
1201
+ }
1202
+ const want = new Set(requested);
1203
+ const have = new Set(sessionScope);
1204
+ return want.size === have.size && [...want].every(id => have.has(id)) ? sessionScope : null;
1205
+ }
1206
+ const SESSION_PROJECT_SCOPE_MISMATCH = {
1207
+ error: "Project scope mismatch",
1208
+ message: "X-GitLab-Allowed-Project-Ids does not match the project scope of this session. Re-initialize to change it.",
1209
+ };
1168
1210
  /**
1169
1211
  * Reject tools that cannot be limited to allowed projects when
1170
1212
  * ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
@@ -1239,6 +1281,15 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1239
1281
  logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
1240
1282
  "Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
1241
1283
  }
1284
+ if (enabledToolsets.has("api")) {
1285
+ logger.warn("gitlab_api_request is enabled because GITLAB_TOOLSETS lists api. " +
1286
+ "This tool can bypass tool allowlists (GITLAB_TOOLS, GITLAB_TOOLSETS selection, GITLAB_DENIED_TOOLS_REGEX for other tools), " +
1287
+ "group and project restrictions, and per-tool safety checks. It exposes the full token scope.");
1288
+ }
1289
+ const ignoredExplicitTools = [...individuallyEnabledTools].filter(name => isExplicitToolsetOnlyTool(name) && !isToolInEnabledToolset(name, enabledToolsets));
1290
+ if (ignoredExplicitTools.length > 0) {
1291
+ 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.`);
1292
+ }
1242
1293
  const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
1243
1294
  function toAllowedGitLabApiUrl(value) {
1244
1295
  const trimmed = value.trim();
@@ -8092,6 +8143,29 @@ async function handleToolCall(params) {
8092
8143
  clearTimeout(timeout);
8093
8144
  }
8094
8145
  }
8146
+ case "gitlab_api_request": {
8147
+ try {
8148
+ return await executeGitLabApiRequestAsync({
8149
+ args: params.arguments,
8150
+ apiBaseUrl: getEffectiveApiUrl(),
8151
+ permissionMode: GITLAB_PERMISSION_MODE,
8152
+ allowedProjectIds: getEffectiveAllowedProjectIds(),
8153
+ fetchImpl: fetch,
8154
+ unauthenticatedFetchImpl: undiciFetch,
8155
+ isTrustedRedirectHost: isTrustedGitLabRedirectHost,
8156
+ fetchConfig: getFetchConfig,
8157
+ });
8158
+ }
8159
+ catch (error) {
8160
+ if (error instanceof GitLabApiRequestInputError) {
8161
+ throw new ToolInputError(error.message);
8162
+ }
8163
+ if (error instanceof GitLabApiRequestHttpError) {
8164
+ throw new GitLabApiError(error.message);
8165
+ }
8166
+ throw error;
8167
+ }
8168
+ }
8095
8169
  case "fork_repository": {
8096
8170
  rejectIfProjectScopedDeployment("fork_repository");
8097
8171
  const forkArgs = ForkRepositorySchema.parse(params.arguments);
@@ -11641,6 +11715,9 @@ async function startStreamableHTTPServer() {
11641
11715
  const incomingSid = readMcpSessionIdHeader(req);
11642
11716
  const isInit = isInitializationRequestBody(req.body);
11643
11717
  const headerAuth = parseAuthHeaders(req);
11718
+ const opened = incomingSid && looksLikeStatelessSessionId(incomingSid)
11719
+ ? openSessionId(material, incomingSid, sessionTtlSeconds)
11720
+ : null;
11644
11721
  // In GITLAB_MCP_OAUTH mode, req.auth may be populated by requireBearerAuth.
11645
11722
  // Use it when headerAuth is null (no Private-Token / JOB-TOKEN / Authorization).
11646
11723
  let effective = null;
@@ -11648,7 +11725,6 @@ async function startStreamableHTTPServer() {
11648
11725
  if (headerAuth) {
11649
11726
  let needsUpstreamValidation = isInit || !incomingSid;
11650
11727
  if (!needsUpstreamValidation && incomingSid && looksLikeStatelessSessionId(incomingSid)) {
11651
- const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
11652
11728
  needsUpstreamValidation =
11653
11729
  !opened ||
11654
11730
  opened.h !== headerAuth.header ||
@@ -11695,7 +11771,6 @@ async function startStreamableHTTPServer() {
11695
11771
  let sidPresentedButInvalid = false;
11696
11772
  if (!effective && incomingSid) {
11697
11773
  if (looksLikeStatelessSessionId(incomingSid)) {
11698
- const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
11699
11774
  if (opened) {
11700
11775
  effective = {
11701
11776
  header: opened.h,
@@ -11738,6 +11813,18 @@ async function startStreamableHTTPServer() {
11738
11813
  });
11739
11814
  return;
11740
11815
  }
11816
+ // The project scope sealed into the sid belongs to the session: live headers
11817
+ // may refresh the token but cannot drop or replace the scope.
11818
+ if (ENABLE_DYNAMIC_PROJECT_SCOPE && freshAuthPresent && !isInit && opened) {
11819
+ const allowedProjectIds = bindSessionProjectScope(effective.allowedProjectIds, opened.p);
11820
+ if (allowedProjectIds === null) {
11821
+ metrics.authFailures++;
11822
+ metrics.statelessAuthFailures++;
11823
+ res.status(401).json(SESSION_PROJECT_SCOPE_MISMATCH);
11824
+ return;
11825
+ }
11826
+ effective.allowedProjectIds = allowedProjectIds;
11827
+ }
11741
11828
  // Always mint a fresh sid so the embedded iat advances on every request.
11742
11829
  // This makes OAUTH_STATELESS_SESSION_TTL_SECONDS behave as an inactivity
11743
11830
  // timeout rather than an absolute-age cap — matching the legacy
@@ -12127,7 +12214,13 @@ async function startStreamableHTTPServer() {
12127
12214
  }
12128
12215
  }
12129
12216
  else if (sessionId && authData) {
12130
- const result = await storeValidatedSessionAuth(sessionId, authData, publicBaseUrl, authBySession[sessionId], { skipIfUnchanged: true });
12217
+ const allowedProjectIds = bindSessionProjectScope(authData.allowedProjectIds, authBySession[sessionId].allowedProjectIds);
12218
+ if (allowedProjectIds === null) {
12219
+ metrics.authFailures++;
12220
+ res.status(401).json(SESSION_PROJECT_SCOPE_MISMATCH);
12221
+ return;
12222
+ }
12223
+ const result = await storeValidatedSessionAuth(sessionId, { ...authData, allowedProjectIds }, publicBaseUrl, authBySession[sessionId], { skipIfUnchanged: true });
12131
12224
  if (result === "invalid") {
12132
12225
  metrics.authFailures++;
12133
12226
  res.status(401).json({
@@ -12140,6 +12233,10 @@ async function startStreamableHTTPServer() {
12140
12233
  if (result === "stored") {
12141
12234
  logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
12142
12235
  }
12236
+ else {
12237
+ // Same token: keep the stored auth but let an unscoped session narrow.
12238
+ authBySession[sessionId].allowedProjectIds = allowedProjectIds;
12239
+ }
12143
12240
  }
12144
12241
  else if (sessionId && authBySession[sessionId]) {
12145
12242
  // Existing session with stored auth: update last used time and reset timeout
@@ -12165,7 +12262,13 @@ async function startStreamableHTTPServer() {
12165
12262
  setAuthTimeout(sessionId);
12166
12263
  }
12167
12264
  else {
12168
- authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl, authBySession[sessionId]);
12265
+ const allowedProjectIds = bindSessionProjectScope(headerAuthData.allowedProjectIds, authBySession[sessionId].allowedProjectIds);
12266
+ if (allowedProjectIds === null) {
12267
+ metrics.authFailures++;
12268
+ res.status(401).json(SESSION_PROJECT_SCOPE_MISMATCH);
12269
+ return;
12270
+ }
12271
+ authBySession[sessionId] = withPublicBaseUrl({ ...headerAuthData, allowedProjectIds }, publicBaseUrl, authBySession[sessionId]);
12169
12272
  setAuthTimeout(sessionId);
12170
12273
  }
12171
12274
  }
@@ -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.4",
3
+ "version": "2.2.6",
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": [