@zereight/mcp-gitlab 2.1.68 → 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.
Files changed (40) hide show
  1. package/README.ko.md +11 -3
  2. package/README.md +237 -232
  3. package/README.pt-BR.md +8 -4
  4. package/README.zh-CN.md +7 -3
  5. package/build/cli/args.js +298 -0
  6. package/build/cli/compact-mcp-result.js +87 -0
  7. package/build/cli/curated.js +243 -0
  8. package/build/cli/exposure.js +72 -0
  9. package/build/cli/help.js +108 -0
  10. package/build/cli/output.js +65 -0
  11. package/build/cli/replay-command.js +53 -0
  12. package/build/cli/router.js +158 -0
  13. package/build/cli/tables.js +117 -0
  14. package/build/cli-boolean-flags.js +38 -0
  15. package/build/cli-command.js +14 -4
  16. package/build/config.js +30 -7
  17. package/build/index.js +176 -90
  18. package/build/oauth.js +93 -10
  19. package/build/scripts/generate-tool-docs.js +2 -1
  20. package/build/test/cli/args.test.js +175 -0
  21. package/build/test/cli/boolean-flags.test.js +106 -0
  22. package/build/test/cli/compact-mcp-result.test.js +93 -0
  23. package/build/test/cli/credentials.test.js +153 -0
  24. package/build/test/cli/exposure.test.js +104 -0
  25. package/build/test/cli/load-config-snapshot.js +5 -0
  26. package/build/test/cli/output.test.js +74 -0
  27. package/build/test/cli/replay-command.test.js +37 -0
  28. package/build/test/cli/router.test.js +183 -0
  29. package/build/test/gitlab-api-tool-errors.test.js +112 -0
  30. package/build/test/oauth-device-flow-tests.js +10 -0
  31. package/build/test/oauth-download-url.test.js +157 -0
  32. package/build/test/oauth-startup.test.js +204 -0
  33. package/build/test/test-dynamic-project-scope.js +2 -0
  34. package/build/test/test-geteffectiveprojectid.js +20 -26
  35. package/build/test/test-permission-mode.js +86 -0
  36. package/build/test/test-pipeline-schedules.js +3 -1
  37. package/build/test/utils/url.test.js +14 -1
  38. package/build/tools/denied-regex.js +38 -0
  39. package/build/utils/url.js +15 -0
  40. package/package.json +1 -1
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
2
+ import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, } from "./config.js";
3
3
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
4
4
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
5
  const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
@@ -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
  }
@@ -74,9 +75,13 @@ import path from "node:path";
74
75
  import { CookieJar, parse as parseCookie } from "tough-cookie";
75
76
  import { URL } from "node:url";
76
77
  import { z } from "zod";
77
- import { initializeOAuthClient } from "./oauth.js";
78
+ import { createGitLabOAuthClient, ensureOAuthToken } from "./oauth.js";
78
79
  import { getPositionalCliCommand } from "./cli-command.js";
79
80
  import { runAuthCommandAsync } from "./auth-cli.js";
81
+ import { isCliInvocation, resolveCli } from "./cli/router.js";
82
+ import { formatCliError, formatToolOutput } from "./cli/output.js";
83
+ import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
84
+ import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
80
85
  import { createGitLabOAuthProvider } from "./oauth-proxy.js";
81
86
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
82
87
  import rateLimit, { ipKeyGenerator } from "express-rate-limit";
@@ -89,7 +94,7 @@ import { formatPrometheusMetrics } from "./server/metrics.js";
89
94
  import { SERVER_VERSION } from "./server/version.js";
90
95
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
91
96
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
92
- import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "./utils/url.js";
97
+ import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
93
98
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
94
99
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
95
100
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
@@ -179,6 +184,7 @@ const MCP_PROMPTS = [
179
184
  * Each transport connection gets its own Server instance to prevent
180
185
  * cross-client data leakage (GHSA-345p-7cg4-v4c7).
181
186
  */
187
+ /** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
182
188
  function createServer() {
183
189
  // Precompute filtered tool list once at server creation (Steps 1–5 are static)
184
190
  // Step 1: Toolset filter — keep tools in enabled toolsets
@@ -329,11 +335,18 @@ function createServer() {
329
335
  ? (value) => maskingEngine.maskValue(value)
330
336
  : undefined,
331
337
  });
332
- return maskingEngine
338
+ const masked = maskingEngine
333
339
  ? maskingEngine.maskToolResult(filtered, {
334
340
  textFormat: isPlainTextResult ? "plain" : "json",
335
341
  })
336
342
  : filtered;
343
+ return compactMcpToolResult({
344
+ result: masked,
345
+ enabled: GITLAB_MCP_COMPACT_RESULTS,
346
+ maxChars: GITLAB_MCP_COMPACT_RESULT_CHARS,
347
+ toolName,
348
+ args: readOwnRecord(request.params.arguments),
349
+ });
337
350
  };
338
351
  const logError = (error) => {
339
352
  const durationMs = Date.now() - start;
@@ -344,7 +357,7 @@ function createServer() {
344
357
  durationMs,
345
358
  error: safeError instanceof Error ? safeError.message : String(safeError),
346
359
  }, `tool_call_error: ${toolName} (${durationMs}ms)`);
347
- throw safeError;
360
+ return safeError;
348
361
  };
349
362
  try {
350
363
  const authData = (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId
@@ -398,6 +411,16 @@ function createServer() {
398
411
  if (jmespathSyntaxError) {
399
412
  return logCompletion({ content: [{ type: "text", text: jmespathSyntaxError }], isError: true }, { skipJmespath: true });
400
413
  }
414
+ // Enforce list-time filtering on direct calls: clients can call names that tools/list omits.
415
+ // filteredTools is extended by discover_tools; hidden-policy tools stay callable by design,
416
+ // but GITLAB_DENIED_TOOLS_REGEX always wins over the hidden exemption.
417
+ // Permission-mode denials fall through so handleToolCall reports its mode-specific error.
418
+ const hiddenAndNotDenied = hiddenToolSet.has(toolName) && !GITLAB_DENIED_TOOLS_REGEX?.test(toolName);
419
+ if (isToolAllowedByPermissionMode(toolName) &&
420
+ !hiddenAndNotDenied &&
421
+ !filteredTools.some(t => t.name === toolName)) {
422
+ throw new Error(`Tool "${toolName}" is not available on this server`);
423
+ }
401
424
  // Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
402
425
  if (toolName === "discover_tools") {
403
426
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
@@ -525,7 +548,20 @@ function createServer() {
525
548
  return logCompletion(result);
526
549
  }
527
550
  catch (error) {
528
- logError(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;
529
565
  }
530
566
  });
531
567
  return mcpServer;
@@ -846,15 +882,10 @@ export function hasStatelessSessionId(req) {
846
882
  * This avoids background timers that cause issues with multiple instances.
847
883
  */
848
884
  async function ensureValidOAuthToken() {
849
- if (!oauthClient)
850
- return;
851
- if (oauthClient.hasValidToken())
852
- return;
853
885
  try {
854
- logger.info("OAuth token expired or missing, refreshing...");
855
- const freshToken = await oauthClient.getAccessToken();
856
- OAUTH_ACCESS_TOKEN = freshToken;
857
- logger.info("OAuth token refreshed successfully");
886
+ await ensureOAuthToken(oauthClient, OAUTH_ACCESS_TOKEN, token => {
887
+ OAUTH_ACCESS_TOKEN = token;
888
+ });
858
889
  }
859
890
  catch (error) {
860
891
  logger.error({ err: error }, "Failed to refresh OAuth token");
@@ -873,33 +904,11 @@ function isToolAllowedByPermissionMode(toolName) {
873
904
  }
874
905
  }
875
906
  const GITLAB_DENIED_TOOLS_REGEX = (() => {
876
- const pattern = getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX");
877
- if (!pattern)
878
- return undefined;
879
- // Reject patterns that are too long (potential ReDoS vector)
880
- const MAX_PATTERN_LENGTH = 200;
881
- if (pattern.length > MAX_PATTERN_LENGTH) {
882
- logger.error(`GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`);
883
- return undefined;
884
- }
885
- // Reject patterns with nested quantifiers that can cause catastrophic backtracking (ReDoS)
886
- // e.g., (a+)+, (a*)+, (a+)*, (a{1,})+
887
- // Note: lookahead (?!), (?=), lookbehind (?<), and named groups (?<name>) are safe and allowed
888
- const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
889
- if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
890
- logger.error(`GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.`);
891
- return undefined;
892
- }
893
- try {
894
- const regex = new RegExp(pattern);
895
- // Dry-run against a sample string to catch immediate issues
896
- regex.test("sample_tool_name");
897
- return regex;
898
- }
899
- catch {
900
- logger.error(`Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`);
901
- return undefined;
907
+ const compiled = compileDeniedToolsRegex(getConfig("denied-tools-regex", "GITLAB_DENIED_TOOLS_REGEX"));
908
+ if (compiled.error) {
909
+ logger.error(compiled.error);
902
910
  }
911
+ return compiled.regex;
903
912
  })();
904
913
  // ---------------------------------------------------------------------------
905
914
  // Tool policy: approve / hidden sets
@@ -1101,12 +1110,15 @@ function buildAuthHeaders() {
1101
1110
  // OAuth token takes priority over PAT when both are set.
1102
1111
  // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
1103
1112
  // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
1104
- const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN;
1105
- if (IS_OLD && token) {
1106
- 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}` };
1107
1117
  }
1108
- if (token) {
1109
- 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}` };
1110
1122
  }
1111
1123
  // Fall back to CI job token
1112
1124
  if (GITLAB_JOB_TOKEN) {
@@ -1317,7 +1329,7 @@ if (GITLAB_MCP_OAUTH) {
1317
1329
  }
1318
1330
  logger.info("MCP OAuth enabled: GitLab OAuth proxy active (Private-Token/JOB-TOKEN headers bypass OAuth)");
1319
1331
  }
1320
- if (getPositionalCliCommand(process.argv) !== "auth" &&
1332
+ if (!isCliInvocation(process.argv) &&
1321
1333
  !REMOTE_AUTHORIZATION &&
1322
1334
  !GITLAB_MCP_OAUTH &&
1323
1335
  !USE_OAUTH &&
@@ -1329,6 +1341,12 @@ if (getPositionalCliCommand(process.argv) !== "auth" &&
1329
1341
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
1330
1342
  process.exit(1);
1331
1343
  }
1344
+ class GitLabApiError extends Error {
1345
+ constructor(message) {
1346
+ super(message);
1347
+ this.name = "GitLabApiError";
1348
+ }
1349
+ }
1332
1350
  /**
1333
1351
  * Utility function for handling GitLab API errors
1334
1352
  * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
@@ -1345,12 +1363,12 @@ async function handleGitLabError(response) {
1345
1363
  // before the common tool-call boundary returns/logs it.
1346
1364
  logger.error("GitLab API Rate Limit Exceeded");
1347
1365
  logger.error("User API Key Rate limit exceeded. Please try again later.");
1348
- const error = new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1366
+ const error = new GitLabApiError(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1349
1367
  throw error;
1350
1368
  }
1351
1369
  else {
1352
1370
  // Handle other API errors
1353
- 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}`);
1354
1372
  throw error;
1355
1373
  }
1356
1374
  }
@@ -1415,7 +1433,7 @@ async function forkProject(projectId, namespace) {
1415
1433
  });
1416
1434
  // Handle case where project already exists
1417
1435
  if (response.status === 409) {
1418
- throw new Error("Project already exists in the target namespace");
1436
+ throw new GitLabApiError("Project already exists in the target namespace");
1419
1437
  }
1420
1438
  await handleGitLabError(response);
1421
1439
  const data = await response.json();
@@ -1486,7 +1504,7 @@ async function getFileContents(projectId, filePath, ref) {
1486
1504
  });
1487
1505
  // Handle file not found
1488
1506
  if (response.status === 404) {
1489
- throw new Error(`File not found: ${filePath}`);
1507
+ throw new GitLabApiError(`File not found: ${filePath}`);
1490
1508
  }
1491
1509
  await handleGitLabError(response);
1492
1510
  const data = await response.json();
@@ -1519,7 +1537,7 @@ async function createIssue(projectId, options) {
1519
1537
  // Handle bad request
1520
1538
  if (response.status === 400) {
1521
1539
  const errorBody = await response.text();
1522
- throw new Error(`Invalid request: ${errorBody}`);
1540
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
1523
1541
  }
1524
1542
  await handleGitLabError(response);
1525
1543
  const data = await response.json();
@@ -1755,7 +1773,7 @@ async function executeGraphQL(query, variables = {}) {
1755
1773
  });
1756
1774
  if (!response.ok) {
1757
1775
  const errorBody = await response.text();
1758
- throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
1776
+ throw new GitLabApiError(`GraphQL request failed (${response.status}): ${errorBody}`);
1759
1777
  }
1760
1778
  const json = await response.json();
1761
1779
  if (json.errors && json.errors.length > 0) {
@@ -3204,11 +3222,11 @@ async function createMergeRequest(projectId, options) {
3204
3222
  });
3205
3223
  if (response.status === 400) {
3206
3224
  const errorBody = await response.text();
3207
- throw new Error(`Invalid request: ${errorBody}`);
3225
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
3208
3226
  }
3209
3227
  if (!response.ok) {
3210
3228
  const errorBody = await response.text();
3211
- 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}`);
3212
3230
  }
3213
3231
  const data = await response.json();
3214
3232
  return GitLabMergeRequestSchema.parse(data);
@@ -3296,7 +3314,7 @@ async function deleteMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
3296
3314
  });
3297
3315
  if (!response.ok) {
3298
3316
  const errorText = await response.text();
3299
- 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}`);
3300
3318
  }
3301
3319
  }
3302
3320
  /**
@@ -3456,7 +3474,7 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
3456
3474
  });
3457
3475
  if (!response.ok) {
3458
3476
  const errorText = await response.text();
3459
- 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}`);
3460
3478
  }
3461
3479
  }
3462
3480
  // --- Emoji Reactions (REST) ---
@@ -3627,7 +3645,7 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
3627
3645
  });
3628
3646
  if (!response.ok) {
3629
3647
  const errorBody = await response.text();
3630
- 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}`);
3631
3649
  }
3632
3650
  const data = await response.json();
3633
3651
  return GitLabCreateUpdateFileResponseSchema.parse(data);
@@ -3656,11 +3674,11 @@ async function createCommit(projectId, message, branch, actions) {
3656
3674
  });
3657
3675
  if (response.status === 400) {
3658
3676
  const errorBody = await response.text();
3659
- throw new Error(`Invalid request: ${errorBody}`);
3677
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
3660
3678
  }
3661
3679
  if (!response.ok) {
3662
3680
  const errorBody = await response.text();
3663
- 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}`);
3664
3682
  }
3665
3683
  const data = await response.json();
3666
3684
  return GitLabCommitSchema.parse(data);
@@ -3686,7 +3704,7 @@ async function searchProjects(query, page = 1, perPage = 20) {
3686
3704
  });
3687
3705
  if (!response.ok) {
3688
3706
  const errorBody = await response.text();
3689
- 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}`);
3690
3708
  }
3691
3709
  const unfiltered = (await response.json());
3692
3710
  const projects = filterProjectsByAllowlist(unfiltered);
@@ -3773,7 +3791,7 @@ async function createRepository(options) {
3773
3791
  });
3774
3792
  if (!response.ok) {
3775
3793
  const errorBody = await response.text();
3776
- 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}`);
3777
3795
  }
3778
3796
  const data = await response.json();
3779
3797
  return GitLabRepositorySchema.parse(data);
@@ -4199,7 +4217,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
4199
4217
  });
4200
4218
  if (!response.ok) {
4201
4219
  const errorBody = await response.text();
4202
- 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}`);
4203
4221
  }
4204
4222
  const data = await response.json();
4205
4223
  return GitLabCompareResultSchema.parse(data);
@@ -4432,7 +4450,7 @@ noteableIid, body) {
4432
4450
  });
4433
4451
  if (!response.ok) {
4434
4452
  const errorText = await response.text();
4435
- 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}`);
4436
4454
  }
4437
4455
  return await response.json();
4438
4456
  }
@@ -4447,7 +4465,7 @@ async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
4447
4465
  const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/merge_requests/${encodeGitLabPathSegment(merge_request_iid)}/draft_notes/${encodeGitLabPathSegment(draft_note_id)}`, { ...getFetchConfig() });
4448
4466
  if (!response.ok) {
4449
4467
  const errorText = await response.text();
4450
- 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}`);
4451
4469
  }
4452
4470
  const data = await response.json();
4453
4471
  return GitLabDraftNoteSchema.parse(data);
@@ -4461,7 +4479,7 @@ async function listDraftNotes(projectId, mergeRequestIid) {
4461
4479
  });
4462
4480
  if (!response.ok) {
4463
4481
  const errorText = await response.text();
4464
- 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}`);
4465
4483
  }
4466
4484
  const data = await response.json();
4467
4485
  return z.array(GitLabDraftNoteSchema).parse(data);
@@ -4496,7 +4514,7 @@ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscus
4496
4514
  });
4497
4515
  if (!response.ok) {
4498
4516
  const errorText = await response.text();
4499
- 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}`);
4500
4518
  }
4501
4519
  const data = await response.json();
4502
4520
  return GitLabDraftNoteSchema.parse(data);
@@ -4531,7 +4549,7 @@ async function updateDraftNote(projectId, mergeRequestIid, draftNoteId, body, po
4531
4549
  });
4532
4550
  if (!response.ok) {
4533
4551
  const errorText = await response.text();
4534
- 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}`);
4535
4553
  }
4536
4554
  const data = await response.json();
4537
4555
  return GitLabDraftNoteSchema.parse(data);
@@ -4552,7 +4570,7 @@ async function deleteDraftNote(projectId, mergeRequestIid, draftNoteId) {
4552
4570
  });
4553
4571
  if (!response.ok) {
4554
4572
  const errorText = await response.text();
4555
- 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}`);
4556
4574
  }
4557
4575
  }
4558
4576
  /**
@@ -4571,7 +4589,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
4571
4589
  });
4572
4590
  if (!response.ok) {
4573
4591
  const errorText = await response.text();
4574
- 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}`);
4575
4593
  }
4576
4594
  // Handle empty response (204 No Content) or successful response
4577
4595
  const responseText = await response.text();
@@ -4652,7 +4670,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
4652
4670
  });
4653
4671
  if (!response.ok) {
4654
4672
  const errorText = await response.text();
4655
- 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}`);
4656
4674
  }
4657
4675
  // Handle empty response (204 No Content) or successful response
4658
4676
  const responseText = await response.text();
@@ -4683,7 +4701,7 @@ async function resolveMergeRequestThread(projectId, mergeRequestIid, discussionI
4683
4701
  });
4684
4702
  if (!response.ok) {
4685
4703
  const errorText = await response.text();
4686
- 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}`);
4687
4705
  }
4688
4706
  }
4689
4707
  /**
@@ -5275,7 +5293,7 @@ async function getPipeline(projectId, pipelineId, signal) {
5275
5293
  signal,
5276
5294
  });
5277
5295
  if (response.status === 404) {
5278
- throw new Error(`Pipeline not found`);
5296
+ throw new GitLabApiError(`Pipeline not found`);
5279
5297
  }
5280
5298
  await handleGitLabError(response);
5281
5299
  const data = await response.json();
@@ -5348,7 +5366,7 @@ async function getDeployment(projectId, deploymentId) {
5348
5366
  ...getFetchConfig(),
5349
5367
  });
5350
5368
  if (response.status === 404) {
5351
- throw new Error(`Deployment not found`);
5369
+ throw new GitLabApiError(`Deployment not found`);
5352
5370
  }
5353
5371
  await handleGitLabError(response);
5354
5372
  const data = await response.json();
@@ -5400,7 +5418,7 @@ async function getEnvironment(projectId, environmentId) {
5400
5418
  ...getFetchConfig(),
5401
5419
  });
5402
5420
  if (response.status === 404) {
5403
- throw new Error(`Environment not found`);
5421
+ throw new GitLabApiError(`Environment not found`);
5404
5422
  }
5405
5423
  await handleGitLabError(response);
5406
5424
  const data = await response.json();
@@ -5453,7 +5471,7 @@ async function listPipelineJobs(projectId, pipelineId, options = {}) {
5453
5471
  ...getFetchConfig(),
5454
5472
  });
5455
5473
  if (response.status === 404) {
5456
- throw new Error(`Pipeline not found`);
5474
+ throw new GitLabApiError(`Pipeline not found`);
5457
5475
  }
5458
5476
  await handleGitLabError(response);
5459
5477
  const data = await response.json();
@@ -5476,7 +5494,7 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
5476
5494
  ...getFetchConfig(),
5477
5495
  });
5478
5496
  if (response.status === 404) {
5479
- throw new Error(`Pipeline not found`);
5497
+ throw new GitLabApiError(`Pipeline not found`);
5480
5498
  }
5481
5499
  await handleGitLabError(response);
5482
5500
  const data = await response.json();
@@ -5490,7 +5508,7 @@ async function getPipelineJob(projectId, jobId, signal) {
5490
5508
  signal,
5491
5509
  });
5492
5510
  if (response.status === 404) {
5493
- throw new Error(`Job not found`);
5511
+ throw new GitLabApiError(`Job not found`);
5494
5512
  }
5495
5513
  await handleGitLabError(response);
5496
5514
  const data = await response.json();
@@ -5518,7 +5536,7 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
5518
5536
  },
5519
5537
  });
5520
5538
  if (response.status === 404) {
5521
- 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`);
5522
5540
  }
5523
5541
  await handleGitLabError(response);
5524
5542
  const fullTrace = await response.text();
@@ -5580,7 +5598,7 @@ async function listJobArtifacts(projectId, jobId, options = {}) {
5580
5598
  ...getFetchConfig(),
5581
5599
  });
5582
5600
  if (response.status === 404) {
5583
- 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.`);
5584
5602
  }
5585
5603
  await handleGitLabError(response);
5586
5604
  const data = await response.json();
@@ -5600,7 +5618,7 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5600
5618
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts`);
5601
5619
  const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
5602
5620
  if (response.status === 404) {
5603
- 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.`);
5604
5622
  }
5605
5623
  await handleGitLabError(response);
5606
5624
  const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
@@ -5626,7 +5644,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
5626
5644
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
5627
5645
  const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
5628
5646
  if (response.status === 404) {
5629
- throw new Error(`Artifact file not found: ${artifactPath}`);
5647
+ throw new GitLabApiError(`Artifact file not found: ${artifactPath}`);
5630
5648
  }
5631
5649
  await handleGitLabError(response);
5632
5650
  return await response.text();
@@ -6042,10 +6060,10 @@ async function getRepositoryTree(options) {
6042
6060
  queryParams.append("pagination", options.pagination);
6043
6061
  const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
6044
6062
  if (response.status === 404) {
6045
- throw new Error("Repository or path not found");
6063
+ throw new GitLabApiError("Repository or path not found");
6046
6064
  }
6047
6065
  if (!response.ok) {
6048
- throw new Error(`Failed to get repository tree: ${response.statusText}`);
6066
+ throw new GitLabApiError(`Failed to get repository tree: ${response.statusText}`);
6049
6067
  }
6050
6068
  const data = await response.json();
6051
6069
  const items = z.array(GitLabTreeItemSchema).parse(data);
@@ -11991,6 +12009,22 @@ async function initializeServerByTransportMode(mode) {
11991
12009
  }
11992
12010
  }
11993
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
+ }
11994
12028
  /**
11995
12029
  * Initialize and run the server
11996
12030
  * Main entry point for server startup
@@ -12003,11 +12037,8 @@ async function runServer() {
12003
12037
  if (USE_OAUTH) {
12004
12038
  logger.info("Using OAuth authentication...");
12005
12039
  try {
12006
- const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
12007
- const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
12008
- oauthClient = oauthResult.client;
12009
- OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
12010
- logger.info("OAuth authentication successful");
12040
+ initOAuthClient();
12041
+ logger.info("OAuth enabled; token acquired lazily on first use.");
12011
12042
  }
12012
12043
  catch (error) {
12013
12044
  logger.error({ err: error }, "OAuth authentication failed");
@@ -12049,7 +12080,16 @@ async function runServer() {
12049
12080
  }
12050
12081
  }
12051
12082
  async function main() {
12052
- if (getPositionalCliCommand(process.argv) === "auth") {
12083
+ const cli = resolveCli(process.argv);
12084
+ if (cli.kind === "help") {
12085
+ process.stdout.write(cli.text);
12086
+ process.exit(0);
12087
+ }
12088
+ if (cli.kind === "usage" || cli.kind === "refused") {
12089
+ process.stderr.write(`${cli.message}\n`);
12090
+ process.exit(2);
12091
+ }
12092
+ if (cli.kind === "auth" || getPositionalCliCommand(process.argv) === "auth") {
12053
12093
  try {
12054
12094
  await runAuthCommandAsync();
12055
12095
  process.exit(0);
@@ -12061,8 +12101,54 @@ async function main() {
12061
12101
  process.exit(1);
12062
12102
  }
12063
12103
  }
12104
+ if (cli.kind === "run") {
12105
+ if (!hasCliCredentials()) {
12106
+ process.stderr.write("Missing GitLab credentials. Set --token or GITLAB_PERSONAL_ACCESS_TOKEN, or run `auth` with GITLAB_USE_OAUTH=true.\n");
12107
+ process.exit(2);
12108
+ }
12109
+ try {
12110
+ initOAuthClient();
12111
+ const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
12112
+ const masked = maskCliToolResult(cli.toolName, result);
12113
+ const formatted = formatToolOutput({
12114
+ result: masked,
12115
+ mode: cli.output,
12116
+ tableSpec: cli.tableSpec,
12117
+ });
12118
+ if (formatted.stderr) {
12119
+ process.stderr.write(formatted.stderr);
12120
+ }
12121
+ process.stdout.write(formatted.stdout);
12122
+ process.exit(formatted.isError ? 1 : 0);
12123
+ }
12124
+ catch (error) {
12125
+ process.stderr.write(formatCliError(error));
12126
+ logger.error({ err: error }, "cli command failed");
12127
+ process.exit(1);
12128
+ }
12129
+ }
12064
12130
  await runServer();
12065
12131
  }
12132
+ function hasCliCredentials() {
12133
+ return Boolean(GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN || GITLAB_AUTH_COOKIE_PATH || USE_OAUTH);
12134
+ }
12135
+ function isRecord(value) {
12136
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12137
+ }
12138
+ function maskCliToolResult(toolName, result) {
12139
+ const gitlabInstance = getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com";
12140
+ const engine = maskingPolicyResolver?.select({
12141
+ gitlabInstance,
12142
+ allowBuiltinForUnscoped: true,
12143
+ });
12144
+ if (!engine || !isRecord(result)) {
12145
+ return result;
12146
+ }
12147
+ const isPlainText = toolName === "get_pipeline_job_output" ||
12148
+ toolName === "get_job_artifact_file" ||
12149
+ (toolName === "download_release_asset" && !IS_REMOTE);
12150
+ return engine.maskToolResult(result, { textFormat: isPlainText ? "plain" : "json" });
12151
+ }
12066
12152
  main().catch(error => {
12067
12153
  logger.fatal({ err: error }, "Fatal error in main()");
12068
12154
  process.exit(1);