@zereight/mcp-gitlab 2.1.69 → 2.1.70

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.pt-BR.md CHANGED
@@ -17,7 +17,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
17
17
 
18
18
  ### Por que usar este GitLab MCP?
19
19
 
20
- - **261 ferramentas + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução
20
+ - **267 ferramentas + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução
21
21
  - **Revisão de MR em 2 etapas** — `list_merge_request_changed_files` → `get_merge_request_file_diff` em lote
22
22
  - **Agent Skill integrado** — orientação de workflows em `skills/gitlab-mcp/`
23
23
  - **Autenticação flexível** — Personal Access Token, fluxo OAuth2 local no navegador, proxy MCP OAuth e autorização remota por requisição
@@ -31,7 +31,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização
31
31
  | | @zereight/mcp-gitlab | GitLab MCP A (comunidade, estilo CQRS) |
32
32
  | ------------------------- | ---------------------------------------------- | --------------------------------------------------------- |
33
33
  | **Mais indicado para** | Workflows de agentes de IA | Múltiplas instâncias corporativas / ferramentas agrupadas |
34
- | **Modelo de ferramentas** | ~261 ferramentas granulares + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` |
34
+ | **Modelo de ferramentas** | ~267 ferramentas granulares + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` |
35
35
  | **Revisão de MR** | Diff em lote em 2 etapas | Varia |
36
36
  | **Node.js** | >=18.17 | Frequentemente >=24 |
37
37
  | **Licença** | MIT | Varia |
@@ -111,7 +111,7 @@ O caminho no store é fixado pelo seu lock file; atualize-o com `nix flake updat
111
111
 
112
112
  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`.
113
113
 
114
- 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.1.68`. 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`).
114
+ 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.1.69`. 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`).
115
115
 
116
116
  #### Usando argumentos de CLI (para clientes com problemas com variáveis de ambiente)
117
117
 
@@ -150,7 +150,7 @@ Os argumentos de CLI têm precedência sobre as variáveis de ambiente.
150
150
 
151
151
  `zereight-mcp-gitlab auth` é um subcomando, não uma flag do servidor MCP. Ele executa o device flow do GitLab e encerra. Veja [Argumentos de CLI](./docs/getting-started/cli-arguments.md#auth).
152
152
 
153
- O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool <name>` ou formas curadas como `mr list`). Use-o para ver a carga completa no terminal. As respostas do MCP permanecem no chat, a menos que você defina `GITLAB_MCP_COMPACT_RESULTS=true` (ou `--compact-results`), o que substitui respostas grandes de ferramentas por uma prévia e um comando CLI. A CLI humana nunca é compactada. Os mesmos filtros de permission mode, toolsets, denied-tools regex e tool-policy se aplicam. Ferramentas destrutivas e ferramentas com `GITLAB_TOOL_POLICY_APPROVE` exigem `--yes`. Consulte [CLI Humana](./docs/getting-started/cli-arguments.md#human-cli).
153
+ O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool <name>` ou formas curadas como `mr list`). **Não é necessário registrar o MCP** — defina um PAT, ou execute `auth` e defina `GITLAB_USE_OAUTH=true`: veja [CLI sem MCP](./docs/getting-started/cli-without-mcp.md). Use-o para ver a carga completa no terminal. As respostas do MCP permanecem no chat, a menos que você defina `GITLAB_MCP_COMPACT_RESULTS=true` (ou `--compact-results`), o que substitui respostas grandes de ferramentas por uma prévia e um comando CLI. A CLI humana nunca é compactada. Os mesmos filtros de permission mode, toolsets, denied-tools regex e tool-policy se aplicam. Ferramentas destrutivas e ferramentas com `GITLAB_TOOL_POLICY_APPROVE` exigem `--yes`. Consulte [CLI Humana](./docs/getting-started/cli-arguments.md#human-cli).
154
154
 
155
155
  > **Filtragem granular de ferramentas:** use `GITLAB_PERMISSION_MODE=modify` para permitir criação/atualização enquanto
156
156
  > bloqueia todas as ferramentas de exclusão e as ferramentas destrutivas de teardown (`cancel_pipeline`,
package/README.zh-CN.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  ### 为什么使用这个 GitLab MCP?
19
19
 
20
- - **266 个工具 + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
20
+ - **267 个工具 + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别
21
21
  - **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff`
22
22
  - **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南
23
23
  - **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权
@@ -31,7 +31,7 @@
31
31
  | | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) |
32
32
  |---|----------------------|------------------------------|
33
33
  | **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 |
34
- | **工具模型** | ~266 个细粒度工具 + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 |
34
+ | **工具模型** | ~267 个细粒度工具 + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 |
35
35
  | **MR 审查** | 两步批量 diff | 因服务器而异 |
36
36
  | **Node.js** | >=18.17 | 通常 >=24 |
37
37
  | **许可证** | MIT | 因服务器而异 |
@@ -111,7 +111,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
111
111
 
112
112
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
113
113
 
114
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.68`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
114
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.69`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
115
115
 
116
116
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
117
117
 
@@ -150,7 +150,7 @@ CLI 参数优先于环境变量。
150
150
 
151
151
  `zereight-mcp-gitlab auth` 是子命令,不是 MCP 服务器参数。它运行 GitLab device flow 后退出。参见 [CLI 参数](./docs/getting-started/cli-arguments.md#auth)。
152
152
 
153
- 同一二进制也是 `gh` 风格的 GitLab CLI(`tool <name>`,或 `mr list` 这类 curated 命令)。完整结果在终端用 CLI 查看。MCP 回复仍进入对话。设置 `GITLAB_MCP_COMPACT_RESULTS=true`(或 `--compact-results`)后,过大的工具回复会变成预览加上 CLI 命令。Human CLI 本身不会被 compact。permission mode、toolsets、denied-tools regex 和 tool-policy 与 MCP 相同。破坏性工具以及 `GITLAB_TOOL_POLICY_APPROVE` 工具需要 `--yes`。参见 [Human CLI](./docs/getting-started/cli-arguments.md#human-cli)。
153
+ 同一二进制也是 `gh` 风格的 GitLab CLI(`tool <name>`,或 `mr list` 这类 curated 命令)。**无需注册 MCP**——设置 PAT,或运行 `auth` 后设置 `GITLAB_USE_OAUTH=true`:参见 [无需 MCP 使用 CLI](./docs/getting-started/cli-without-mcp.md)。完整结果在终端用 CLI 查看。MCP 回复仍进入对话。设置 `GITLAB_MCP_COMPACT_RESULTS=true`(或 `--compact-results`)后,过大的工具回复会变成预览加上 CLI 命令。Human CLI 本身不会被 compact。permission mode、toolsets、denied-tools regex 和 tool-policy 与 MCP 相同。破坏性工具以及 `GITLAB_TOOL_POLICY_APPROVE` 工具需要 `--yes`。参见 [Human CLI](./docs/getting-started/cli-arguments.md#human-cli)。
154
154
 
155
155
  > **细粒度工具过滤:**使用 `GITLAB_PERMISSION_MODE=modify` 允许创建/更新,同时阻止所有删除工具以及
156
156
  > 破坏性的拆除(teardown)工具(`cancel_pipeline`、`cancel_pipeline_job`、`stop_environment`、
package/build/index.js CHANGED
@@ -38,7 +38,8 @@ function buildDownloadUrl(type, params) {
38
38
  header = "JOB-TOKEN";
39
39
  headerValue = String(staticToken);
40
40
  }
41
- else if (IS_OLD) {
41
+ else if (IS_OLD && !OAUTH_ACCESS_TOKEN) {
42
+ // OAuth tokens always use Bearer (see buildAuthHeaders)
42
43
  header = "Private-Token";
43
44
  headerValue = String(staticToken);
44
45
  }
@@ -93,7 +94,7 @@ import { formatPrometheusMetrics } from "./server/metrics.js";
93
94
  import { SERVER_VERSION } from "./server/version.js";
94
95
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
95
96
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
96
- import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "./utils/url.js";
97
+ import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
97
98
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
98
99
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
99
100
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
@@ -356,7 +357,7 @@ function createServer() {
356
357
  durationMs,
357
358
  error: safeError instanceof Error ? safeError.message : String(safeError),
358
359
  }, `tool_call_error: ${toolName} (${durationMs}ms)`);
359
- throw safeError;
360
+ return safeError;
360
361
  };
361
362
  try {
362
363
  const authData = (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId
@@ -547,8 +548,20 @@ function createServer() {
547
548
  return logCompletion(result);
548
549
  }
549
550
  catch (error) {
550
- logError(error);
551
- throw error;
551
+ const isGitLabApiError = error instanceof GitLabApiError;
552
+ const safeError = logError(error);
553
+ if (isGitLabApiError) {
554
+ return {
555
+ content: [
556
+ {
557
+ type: "text",
558
+ text: safeError instanceof Error ? safeError.message : String(safeError),
559
+ },
560
+ ],
561
+ isError: true,
562
+ };
563
+ }
564
+ throw safeError;
552
565
  }
553
566
  });
554
567
  return mcpServer;
@@ -1097,12 +1110,15 @@ function buildAuthHeaders() {
1097
1110
  // OAuth token takes priority over PAT when both are set.
1098
1111
  // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
1099
1112
  // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
1100
- const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
1101
- if (IS_OLD && token) {
1102
- return { "Private-Token": String(token) };
1113
+ // OAuth tokens always travel as `Authorization: Bearer`, even with GITLAB_IS_OLD: the HTTP
1114
+ // client strips `Authorization` on cross-origin redirects but not `Private-Token`.
1115
+ if (OAUTH_ACCESS_TOKEN) {
1116
+ return { Authorization: `Bearer ${OAUTH_ACCESS_TOKEN}` };
1103
1117
  }
1104
- if (token) {
1105
- return { Authorization: `Bearer ${token}` };
1118
+ if (GITLAB_PERSONAL_ACCESS_TOKEN) {
1119
+ return IS_OLD
1120
+ ? { "Private-Token": String(GITLAB_PERSONAL_ACCESS_TOKEN) }
1121
+ : { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
1106
1122
  }
1107
1123
  // Fall back to CI job token
1108
1124
  if (GITLAB_JOB_TOKEN) {
@@ -1325,6 +1341,12 @@ if (!isCliInvocation(process.argv) &&
1325
1341
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
1326
1342
  process.exit(1);
1327
1343
  }
1344
+ class GitLabApiError extends Error {
1345
+ constructor(message) {
1346
+ super(message);
1347
+ this.name = "GitLabApiError";
1348
+ }
1349
+ }
1328
1350
  /**
1329
1351
  * Utility function for handling GitLab API errors
1330
1352
  * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
@@ -1341,12 +1363,12 @@ async function handleGitLabError(response) {
1341
1363
  // before the common tool-call boundary returns/logs it.
1342
1364
  logger.error("GitLab API Rate Limit Exceeded");
1343
1365
  logger.error("User API Key Rate limit exceeded. Please try again later.");
1344
- const error = new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1366
+ const error = new GitLabApiError(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1345
1367
  throw error;
1346
1368
  }
1347
1369
  else {
1348
1370
  // Handle other API errors
1349
- const error = new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1371
+ const error = new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1350
1372
  throw error;
1351
1373
  }
1352
1374
  }
@@ -1411,7 +1433,7 @@ async function forkProject(projectId, namespace) {
1411
1433
  });
1412
1434
  // Handle case where project already exists
1413
1435
  if (response.status === 409) {
1414
- throw new Error("Project already exists in the target namespace");
1436
+ throw new GitLabApiError("Project already exists in the target namespace");
1415
1437
  }
1416
1438
  await handleGitLabError(response);
1417
1439
  const data = await response.json();
@@ -1482,7 +1504,7 @@ async function getFileContents(projectId, filePath, ref) {
1482
1504
  });
1483
1505
  // Handle file not found
1484
1506
  if (response.status === 404) {
1485
- throw new Error(`File not found: ${filePath}`);
1507
+ throw new GitLabApiError(`File not found: ${filePath}`);
1486
1508
  }
1487
1509
  await handleGitLabError(response);
1488
1510
  const data = await response.json();
@@ -1515,7 +1537,7 @@ async function createIssue(projectId, options) {
1515
1537
  // Handle bad request
1516
1538
  if (response.status === 400) {
1517
1539
  const errorBody = await response.text();
1518
- throw new Error(`Invalid request: ${errorBody}`);
1540
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
1519
1541
  }
1520
1542
  await handleGitLabError(response);
1521
1543
  const data = await response.json();
@@ -1751,7 +1773,7 @@ async function executeGraphQL(query, variables = {}) {
1751
1773
  });
1752
1774
  if (!response.ok) {
1753
1775
  const errorBody = await response.text();
1754
- throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
1776
+ throw new GitLabApiError(`GraphQL request failed (${response.status}): ${errorBody}`);
1755
1777
  }
1756
1778
  const json = await response.json();
1757
1779
  if (json.errors && json.errors.length > 0) {
@@ -3200,11 +3222,11 @@ async function createMergeRequest(projectId, options) {
3200
3222
  });
3201
3223
  if (response.status === 400) {
3202
3224
  const errorBody = await response.text();
3203
- throw new Error(`Invalid request: ${errorBody}`);
3225
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
3204
3226
  }
3205
3227
  if (!response.ok) {
3206
3228
  const errorBody = await response.text();
3207
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3229
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3208
3230
  }
3209
3231
  const data = await response.json();
3210
3232
  return GitLabMergeRequestSchema.parse(data);
@@ -3292,7 +3314,7 @@ async function deleteMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
3292
3314
  });
3293
3315
  if (!response.ok) {
3294
3316
  const errorText = await response.text();
3295
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3317
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3296
3318
  }
3297
3319
  }
3298
3320
  /**
@@ -3452,7 +3474,7 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
3452
3474
  });
3453
3475
  if (!response.ok) {
3454
3476
  const errorText = await response.text();
3455
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3477
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3456
3478
  }
3457
3479
  }
3458
3480
  // --- Emoji Reactions (REST) ---
@@ -3623,7 +3645,7 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
3623
3645
  });
3624
3646
  if (!response.ok) {
3625
3647
  const errorBody = await response.text();
3626
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3648
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3627
3649
  }
3628
3650
  const data = await response.json();
3629
3651
  return GitLabCreateUpdateFileResponseSchema.parse(data);
@@ -3652,11 +3674,11 @@ async function createCommit(projectId, message, branch, actions) {
3652
3674
  });
3653
3675
  if (response.status === 400) {
3654
3676
  const errorBody = await response.text();
3655
- throw new Error(`Invalid request: ${errorBody}`);
3677
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
3656
3678
  }
3657
3679
  if (!response.ok) {
3658
3680
  const errorBody = await response.text();
3659
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3681
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3660
3682
  }
3661
3683
  const data = await response.json();
3662
3684
  return GitLabCommitSchema.parse(data);
@@ -3682,7 +3704,7 @@ async function searchProjects(query, page = 1, perPage = 20) {
3682
3704
  });
3683
3705
  if (!response.ok) {
3684
3706
  const errorBody = await response.text();
3685
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3707
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3686
3708
  }
3687
3709
  const unfiltered = (await response.json());
3688
3710
  const projects = filterProjectsByAllowlist(unfiltered);
@@ -3769,7 +3791,7 @@ async function createRepository(options) {
3769
3791
  });
3770
3792
  if (!response.ok) {
3771
3793
  const errorBody = await response.text();
3772
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3794
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3773
3795
  }
3774
3796
  const data = await response.json();
3775
3797
  return GitLabRepositorySchema.parse(data);
@@ -4195,7 +4217,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
4195
4217
  });
4196
4218
  if (!response.ok) {
4197
4219
  const errorBody = await response.text();
4198
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
4220
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
4199
4221
  }
4200
4222
  const data = await response.json();
4201
4223
  return GitLabCompareResultSchema.parse(data);
@@ -4428,7 +4450,7 @@ noteableIid, body) {
4428
4450
  });
4429
4451
  if (!response.ok) {
4430
4452
  const errorText = await response.text();
4431
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4453
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4432
4454
  }
4433
4455
  return await response.json();
4434
4456
  }
@@ -4443,7 +4465,7 @@ async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
4443
4465
  const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/merge_requests/${encodeGitLabPathSegment(merge_request_iid)}/draft_notes/${encodeGitLabPathSegment(draft_note_id)}`, { ...getFetchConfig() });
4444
4466
  if (!response.ok) {
4445
4467
  const errorText = await response.text();
4446
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4468
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4447
4469
  }
4448
4470
  const data = await response.json();
4449
4471
  return GitLabDraftNoteSchema.parse(data);
@@ -4457,7 +4479,7 @@ async function listDraftNotes(projectId, mergeRequestIid) {
4457
4479
  });
4458
4480
  if (!response.ok) {
4459
4481
  const errorText = await response.text();
4460
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4482
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4461
4483
  }
4462
4484
  const data = await response.json();
4463
4485
  return z.array(GitLabDraftNoteSchema).parse(data);
@@ -4492,7 +4514,7 @@ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscus
4492
4514
  });
4493
4515
  if (!response.ok) {
4494
4516
  const errorText = await response.text();
4495
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4517
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4496
4518
  }
4497
4519
  const data = await response.json();
4498
4520
  return GitLabDraftNoteSchema.parse(data);
@@ -4527,7 +4549,7 @@ async function updateDraftNote(projectId, mergeRequestIid, draftNoteId, body, po
4527
4549
  });
4528
4550
  if (!response.ok) {
4529
4551
  const errorText = await response.text();
4530
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4552
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4531
4553
  }
4532
4554
  const data = await response.json();
4533
4555
  return GitLabDraftNoteSchema.parse(data);
@@ -4548,7 +4570,7 @@ async function deleteDraftNote(projectId, mergeRequestIid, draftNoteId) {
4548
4570
  });
4549
4571
  if (!response.ok) {
4550
4572
  const errorText = await response.text();
4551
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4573
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4552
4574
  }
4553
4575
  }
4554
4576
  /**
@@ -4567,7 +4589,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
4567
4589
  });
4568
4590
  if (!response.ok) {
4569
4591
  const errorText = await response.text();
4570
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4592
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4571
4593
  }
4572
4594
  // Handle empty response (204 No Content) or successful response
4573
4595
  const responseText = await response.text();
@@ -4648,7 +4670,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
4648
4670
  });
4649
4671
  if (!response.ok) {
4650
4672
  const errorText = await response.text();
4651
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4673
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4652
4674
  }
4653
4675
  // Handle empty response (204 No Content) or successful response
4654
4676
  const responseText = await response.text();
@@ -4679,7 +4701,7 @@ async function resolveMergeRequestThread(projectId, mergeRequestIid, discussionI
4679
4701
  });
4680
4702
  if (!response.ok) {
4681
4703
  const errorText = await response.text();
4682
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4704
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4683
4705
  }
4684
4706
  }
4685
4707
  /**
@@ -5271,7 +5293,7 @@ async function getPipeline(projectId, pipelineId, signal) {
5271
5293
  signal,
5272
5294
  });
5273
5295
  if (response.status === 404) {
5274
- throw new Error(`Pipeline not found`);
5296
+ throw new GitLabApiError(`Pipeline not found`);
5275
5297
  }
5276
5298
  await handleGitLabError(response);
5277
5299
  const data = await response.json();
@@ -5344,7 +5366,7 @@ async function getDeployment(projectId, deploymentId) {
5344
5366
  ...getFetchConfig(),
5345
5367
  });
5346
5368
  if (response.status === 404) {
5347
- throw new Error(`Deployment not found`);
5369
+ throw new GitLabApiError(`Deployment not found`);
5348
5370
  }
5349
5371
  await handleGitLabError(response);
5350
5372
  const data = await response.json();
@@ -5396,7 +5418,7 @@ async function getEnvironment(projectId, environmentId) {
5396
5418
  ...getFetchConfig(),
5397
5419
  });
5398
5420
  if (response.status === 404) {
5399
- throw new Error(`Environment not found`);
5421
+ throw new GitLabApiError(`Environment not found`);
5400
5422
  }
5401
5423
  await handleGitLabError(response);
5402
5424
  const data = await response.json();
@@ -5449,7 +5471,7 @@ async function listPipelineJobs(projectId, pipelineId, options = {}) {
5449
5471
  ...getFetchConfig(),
5450
5472
  });
5451
5473
  if (response.status === 404) {
5452
- throw new Error(`Pipeline not found`);
5474
+ throw new GitLabApiError(`Pipeline not found`);
5453
5475
  }
5454
5476
  await handleGitLabError(response);
5455
5477
  const data = await response.json();
@@ -5472,7 +5494,7 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
5472
5494
  ...getFetchConfig(),
5473
5495
  });
5474
5496
  if (response.status === 404) {
5475
- throw new Error(`Pipeline not found`);
5497
+ throw new GitLabApiError(`Pipeline not found`);
5476
5498
  }
5477
5499
  await handleGitLabError(response);
5478
5500
  const data = await response.json();
@@ -5486,7 +5508,7 @@ async function getPipelineJob(projectId, jobId, signal) {
5486
5508
  signal,
5487
5509
  });
5488
5510
  if (response.status === 404) {
5489
- throw new Error(`Job not found`);
5511
+ throw new GitLabApiError(`Job not found`);
5490
5512
  }
5491
5513
  await handleGitLabError(response);
5492
5514
  const data = await response.json();
@@ -5514,7 +5536,7 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
5514
5536
  },
5515
5537
  });
5516
5538
  if (response.status === 404) {
5517
- throw new Error(`Job trace not found or job is not finished yet`);
5539
+ throw new GitLabApiError(`Job trace not found or job is not finished yet`);
5518
5540
  }
5519
5541
  await handleGitLabError(response);
5520
5542
  const fullTrace = await response.text();
@@ -5576,7 +5598,7 @@ async function listJobArtifacts(projectId, jobId, options = {}) {
5576
5598
  ...getFetchConfig(),
5577
5599
  });
5578
5600
  if (response.status === 404) {
5579
- throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5601
+ throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5580
5602
  }
5581
5603
  await handleGitLabError(response);
5582
5604
  const data = await response.json();
@@ -5596,7 +5618,7 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5596
5618
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts`);
5597
5619
  const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
5598
5620
  if (response.status === 404) {
5599
- throw new Error(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5621
+ throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5600
5622
  }
5601
5623
  await handleGitLabError(response);
5602
5624
  const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
@@ -5622,7 +5644,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
5622
5644
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
5623
5645
  const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
5624
5646
  if (response.status === 404) {
5625
- throw new Error(`Artifact file not found: ${artifactPath}`);
5647
+ throw new GitLabApiError(`Artifact file not found: ${artifactPath}`);
5626
5648
  }
5627
5649
  await handleGitLabError(response);
5628
5650
  return await response.text();
@@ -6038,10 +6060,10 @@ async function getRepositoryTree(options) {
6038
6060
  queryParams.append("pagination", options.pagination);
6039
6061
  const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
6040
6062
  if (response.status === 404) {
6041
- throw new Error("Repository or path not found");
6063
+ throw new GitLabApiError("Repository or path not found");
6042
6064
  }
6043
6065
  if (!response.ok) {
6044
- throw new Error(`Failed to get repository tree: ${response.statusText}`);
6066
+ throw new GitLabApiError(`Failed to get repository tree: ${response.statusText}`);
6045
6067
  }
6046
6068
  const data = await response.json();
6047
6069
  const items = z.array(GitLabTreeItemSchema).parse(data);
@@ -11987,6 +12009,22 @@ async function initializeServerByTransportMode(mode) {
11987
12009
  }
11988
12010
  }
11989
12011
  }
12012
+ /**
12013
+ * Construct the OAuth client when OAuth is enabled. No network: the token is
12014
+ * acquired lazily on the first tool call via ensureValidOAuthToken, which
12015
+ * avoids blocking startup and opening a browser on transient boot-time errors.
12016
+ * Shared by the MCP server and the human CLI.
12017
+ */
12018
+ function initOAuthClient() {
12019
+ if (!USE_OAUTH) {
12020
+ return;
12021
+ }
12022
+ if (isCleartextRemoteUrl(GITLAB_API_URL)) {
12023
+ throw new Error(`Refusing to send OAuth tokens over cleartext HTTP to ${GITLAB_API_URL}. ` +
12024
+ "Use an https:// GITLAB_API_URL (http:// is only allowed for localhost).");
12025
+ }
12026
+ oauthClient = createGitLabOAuthClient(GITLAB_API_URL.replace(/\/api\/v4$/, ""));
12027
+ }
11990
12028
  /**
11991
12029
  * Initialize and run the server
11992
12030
  * Main entry point for server startup
@@ -11999,12 +12037,7 @@ async function runServer() {
11999
12037
  if (USE_OAUTH) {
12000
12038
  logger.info("Using OAuth authentication...");
12001
12039
  try {
12002
- const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
12003
- // Construct the client synchronously (no network). The token is
12004
- // acquired lazily on the first tool call via ensureValidOAuthToken,
12005
- // by which point the network is ready. This avoids blocking startup
12006
- // and avoids opening a browser on transient boot-time network errors.
12007
- oauthClient = createGitLabOAuthClient(gitlabBaseUrl);
12040
+ initOAuthClient();
12008
12041
  logger.info("OAuth enabled; token acquired lazily on first use.");
12009
12042
  }
12010
12043
  catch (error) {
@@ -12074,6 +12107,7 @@ async function main() {
12074
12107
  process.exit(2);
12075
12108
  }
12076
12109
  try {
12110
+ initOAuthClient();
12077
12111
  const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
12078
12112
  const masked = maskCliToolResult(cli.toolName, result);
12079
12113
  const formatted = formatToolOutput({
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as http from "node:http";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import * as url from "node:url";
8
+ import { describe, it } from "node:test";
9
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
10
+ const CLI_ENTRY = path.resolve(__dirname, "../../build/index.js");
11
+ const CLI_TIMEOUT_MS = 30_000;
12
+ const MR_FIXTURE = {
13
+ id: 1,
14
+ iid: 1,
15
+ project_id: 7,
16
+ title: "Hello MR",
17
+ description: "d",
18
+ state: "opened",
19
+ web_url: "http://example.test/mr/1",
20
+ source_branch: "feature",
21
+ target_branch: "main",
22
+ author: { id: 1, username: "u", name: "U", state: "active", avatar_url: null, web_url: "http://example.test/u" },
23
+ created_at: "2026-01-01T00:00:00Z",
24
+ updated_at: "2026-01-01T00:00:00Z",
25
+ merged_at: null,
26
+ closed_at: null,
27
+ merge_commit_sha: null,
28
+ };
29
+ function buildCleanEnv(extra) {
30
+ const env = {};
31
+ for (const [key, value] of Object.entries(process.env)) {
32
+ if (value !== undefined && !key.startsWith("GITLAB_")) {
33
+ env[key] = value;
34
+ }
35
+ }
36
+ return { ...env, ...extra };
37
+ }
38
+ function startFakeGitLabAsync(onRequest) {
39
+ const server = http.createServer((req, res) => {
40
+ onRequest(req.headers.authorization);
41
+ res.setHeader("content-type", "application/json");
42
+ res.end(JSON.stringify(MR_FIXTURE));
43
+ });
44
+ return new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve(server)));
45
+ }
46
+ function getPort(server) {
47
+ const address = server.address();
48
+ if (address === null || typeof address === "string") {
49
+ throw new Error("fake GitLab server is not listening on a TCP port");
50
+ }
51
+ return address.port;
52
+ }
53
+ function runCliAsync(env) {
54
+ return new Promise((resolve, reject) => {
55
+ const child = spawn(process.execPath, [CLI_ENTRY, "mr", "view", "--project-id", "g/p", "--mr-iid", "1"], {
56
+ env,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ timeout: CLI_TIMEOUT_MS,
59
+ killSignal: "SIGKILL",
60
+ });
61
+ let stdout = "";
62
+ let stderr = "";
63
+ child.stdout.on("data", chunk => {
64
+ stdout += String(chunk);
65
+ });
66
+ child.stderr.on("data", chunk => {
67
+ stderr += String(chunk);
68
+ });
69
+ child.on("error", reject);
70
+ child.on("close", exitCode => resolve({ exitCode, stdout, stderr }));
71
+ });
72
+ }
73
+ function buildOAuthEnv(apiUrl, tmpDir) {
74
+ const tokenPath = path.join(tmpDir, "token.json");
75
+ fs.writeFileSync(tokenPath, JSON.stringify({
76
+ access_token: "oauth-cli-test",
77
+ refresh_token: "refresh",
78
+ expires_in: 7200,
79
+ created_at: Date.now(),
80
+ token_type: "Bearer",
81
+ }));
82
+ return {
83
+ GITLAB_API_URL: apiUrl,
84
+ GITLAB_USE_OAUTH: "true",
85
+ GITLAB_OAUTH_CLIENT_ID: "client-id",
86
+ GITLAB_OAUTH_TOKEN_PATH: tokenPath,
87
+ };
88
+ }
89
+ async function runMrViewAgainstFakeGitLabAsync(buildEnv) {
90
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-cli-credentials-"));
91
+ let authorization;
92
+ const server = await startFakeGitLabAsync(value => {
93
+ authorization = value;
94
+ });
95
+ try {
96
+ const apiUrl = `http://127.0.0.1:${getPort(server)}/api/v4`;
97
+ const result = await runCliAsync(buildCleanEnv(buildEnv(apiUrl, tmpDir)));
98
+ return { ...result, authorization };
99
+ }
100
+ finally {
101
+ server.close();
102
+ fs.rmSync(tmpDir, { recursive: true, force: true });
103
+ }
104
+ }
105
+ describe("When the human CLI runs without an MCP client", () => {
106
+ describe("with a personal access token in the environment", () => {
107
+ it("should send the token as a bearer credential", async () => {
108
+ const run = await runMrViewAgainstFakeGitLabAsync(apiUrl => ({
109
+ GITLAB_API_URL: apiUrl,
110
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat-cli-test",
111
+ }));
112
+ assert.equal(run.exitCode, 0);
113
+ assert.equal(run.authorization, "Bearer glpat-cli-test");
114
+ });
115
+ });
116
+ describe("with an OAuth token stored by the auth command", () => {
117
+ it("should send the stored token as a bearer credential", async () => {
118
+ const run = await runMrViewAgainstFakeGitLabAsync(buildOAuthEnv);
119
+ assert.equal(run.exitCode, 0);
120
+ assert.equal(run.authorization, "Bearer oauth-cli-test");
121
+ });
122
+ });
123
+ describe("with an OAuth token and GITLAB_IS_OLD enabled", () => {
124
+ it("should still send the token as a bearer credential", async () => {
125
+ const run = await runMrViewAgainstFakeGitLabAsync((apiUrl, tmpDir) => ({
126
+ ...buildOAuthEnv(apiUrl, tmpDir),
127
+ GITLAB_IS_OLD: "true",
128
+ }));
129
+ assert.equal(run.exitCode, 0);
130
+ assert.equal(run.authorization, "Bearer oauth-cli-test");
131
+ });
132
+ });
133
+ describe("with OAuth enabled against a cleartext remote URL", () => {
134
+ it("should refuse to run before any network call", async () => {
135
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-cli-credentials-"));
136
+ try {
137
+ const result = await runCliAsync(buildCleanEnv(buildOAuthEnv("http://gitlab.example.invalid/api/v4", tmpDir)));
138
+ assert.equal(result.exitCode, 1);
139
+ assert.match(result.stderr, /cleartext HTTP/);
140
+ }
141
+ finally {
142
+ fs.rmSync(tmpDir, { recursive: true, force: true });
143
+ }
144
+ });
145
+ });
146
+ describe("with no credentials at all", () => {
147
+ it("should exit with a usage error before any network call", async () => {
148
+ const run = await runMrViewAgainstFakeGitLabAsync(apiUrl => ({ GITLAB_API_URL: apiUrl }));
149
+ assert.equal(run.exitCode, 2);
150
+ assert.equal(run.authorization, undefined);
151
+ });
152
+ });
153
+ });