@zereight/mcp-gitlab 2.1.69 → 2.2.0

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 (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. package/build/test-resolve-issue-note.js +0 -127
package/build/index.js CHANGED
@@ -1,5 +1,6 @@
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, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_RESULT_CHARS, } 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_PERMISSION_MODE_RAW, 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_GITLAB_WIKI_RAW, USE_MILESTONE, USE_MILESTONE_RAW, USE_OAUTH, USE_PIPELINE, USE_PIPELINE_RAW, 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, GITLAB_MCP_COMPACT_TOOL_NAMES, GITLAB_TOOL_PROFILE, } from "./config.js";
3
+ import { getDeprecatedEnvWarnings } from "./deprecated-env.js";
3
4
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
4
5
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
6
  const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
@@ -38,7 +39,8 @@ function buildDownloadUrl(type, params) {
38
39
  header = "JOB-TOKEN";
39
40
  headerValue = String(staticToken);
40
41
  }
41
- else if (IS_OLD) {
42
+ else if (IS_OLD && !OAUTH_ACCESS_TOKEN) {
43
+ // OAuth tokens always use Bearer (see buildAuthHeaders)
42
44
  header = "Private-Token";
43
45
  headerValue = String(staticToken);
44
46
  }
@@ -59,17 +61,14 @@ function isConstantTimeSecretMatch(provided, expected) {
59
61
  }
60
62
  import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./stateless/index.js";
61
63
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
62
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
63
64
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
64
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
65
65
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
66
66
  import { AsyncLocalStorage } from "node:async_hooks";
67
- import express from "express";
68
67
  import fetchCookie from "fetch-cookie";
69
68
  import fs from "node:fs";
70
69
  import { pipeline as streamPipeline } from "node:stream/promises";
71
70
  import os from "node:os";
72
- import { fetch as undiciFetch, File, FormData } from "undici";
71
+ import { fetch as undiciFetch, File, FormData, } from "undici";
73
72
  import path from "node:path";
74
73
  import { CookieJar, parse as parseCookie } from "tough-cookie";
75
74
  import { URL } from "node:url";
@@ -82,18 +81,17 @@ import { formatCliError, formatToolOutput } from "./cli/output.js";
82
81
  import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
83
82
  import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
84
83
  import { createGitLabOAuthProvider } from "./oauth-proxy.js";
85
- import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
86
- import rateLimit, { ipKeyGenerator } from "express-rate-limit";
87
84
  import { normalizeProxyClientIpForRateLimit } from "./utils/proxy-client-ip.js";
88
85
  import { getForwardedPublicBaseUrl, getForwardedRequestHost, } from "./utils/forwarded-public-base-url.js";
89
86
  import { registerDownloadProxy } from "./downloads/proxy.js";
90
87
  import { createDownloadToken } from "./utils/download-token.js";
88
+ import { loadRemoteHttpStack } from "./server/remote-http-stack.js";
91
89
  import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
92
90
  import { formatPrometheusMetrics } from "./server/metrics.js";
93
91
  import { SERVER_VERSION } from "./server/version.js";
94
92
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
95
93
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
96
- import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "./utils/url.js";
94
+ import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "./utils/url.js";
97
95
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
98
96
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
99
97
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
@@ -108,9 +106,13 @@ import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./u
108
106
  import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsernameOptions, dropBlankArrayEntries, isBlankFilterValue, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
109
107
  import { applyJmespathToToolResult, getJmespathSyntaxError, JMESPATH_TOOL_ARGUMENT, JMESPATH_TOOL_ARGUMENT_DESCRIPTION, omitJmespathArgument, readJmespathExpression, } from "./utils/jmespath-tool-result.js";
110
108
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
111
- import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
112
109
  import { GitLabClientPool } from "./gitlab-client-pool.js";
113
- import { allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
110
+ import { advertisesReadOnly, allTools, readOnlyTools, destructiveTools, deleteTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, findSmallestToolsetForTool, isToolsetFullyActive, listDiscoverableCategories, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
111
+ import { buildUnavailableToolMessage, isSkippedByDiscoverActivation, } from "./tools/discover-activation.js";
112
+ import { selectExposedTools } from "./tools/exposed-tools.js";
113
+ import { createListedToolsCache, decorateToolsForList, } from "./tools/listed-tools.js";
114
+ import { isExcludedBySlimProfile, shouldApplySlimToolProfile } from "./tools/tool-profile.js";
115
+ import { shouldCompactToolResult } from "./utils/compact-tool-allowlist.js";
114
116
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, ErasePipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
115
117
  CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetDeploymentSchema, CreateDeploymentSchema, UpdateDeploymentSchema, DeploymentApprovalSchema, ListDeploymentMergeRequestsSchema, GetEnvironmentSchema, UpdateEnvironmentSchema, StopEnvironmentSchema, StopStaleEnvironmentsSchema, DeleteReviewAppEnvironmentsSchema, PipelineTriggerIdSchema, ListPipelineTriggersSchema, CreatePipelineTriggerSchema, UpdatePipelineTriggerSchema, TriggerPipelineSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetPipelineVariablesSchema, UpdatePipelineMetadataSchema, DeletePipelineSchema, PipelineReportSchema, WaitForPipelineSchema, WaitForPipelineJobSchema, PlayPipelineJobsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
116
118
  // Discussion Schemas
@@ -148,7 +150,7 @@ const MCP_PROMPTS = [
148
150
  render: (args) => `Review merge request !${args.merge_request_iid ?? "<iid>"} in project ${args.project_id ?? "<project_id>"}:\n` +
149
151
  `1. Call list_merge_request_changed_files to get the changed file list (use excluded_file_patterns for lockfiles/dist/generated files).\n` +
150
152
  `2. Call get_merge_request_file_diff in batches of 3-5 files, prioritizing source over config/tests.\n` +
151
- `3. Leave findings with create_merge_request_thread (inline, position: new_path/new_line) or create_merge_request_note (general).\n` +
153
+ `3. Leave inline findings with create_merge_request_thread (position: new_path/new_line). For a general note, call discover_tools with category "merge_requests", then create_merge_request_note.\n` +
152
154
  `4. Summarize the review verdict in a final top-level note.`,
153
155
  },
154
156
  {
@@ -178,6 +180,20 @@ const MCP_PROMPTS = [
178
180
  `3. Propose a remediation order, calling out any critical/high items with no available fix.`,
179
181
  },
180
182
  ];
183
+ /**
184
+ * Error text for a call to a tool that tools/list omits.
185
+ * Suggests `discover_tools` only when that call would add the tool.
186
+ * Regex-denied tools stay blocked, and slim-excluded tools are not activated.
187
+ */
188
+ function buildToolUnavailableMessage(toolName) {
189
+ return buildUnavailableToolMessage({
190
+ toolName,
191
+ toolset: findSmallestToolsetForTool(toolName),
192
+ deniedByRegex: GITLAB_DENIED_TOOLS_REGEX?.test(toolName) === true,
193
+ excludedBySlimProfile: isExcludedFromToolsetListing(toolName),
194
+ enabledByCurrentToolsets: isToolInEnabledToolset(toolName, enabledToolsets),
195
+ });
196
+ }
181
197
  /**
182
198
  * Create a new MCP Server instance with request handlers registered.
183
199
  * Each transport connection gets its own Server instance to prevent
@@ -185,44 +201,26 @@ const MCP_PROMPTS = [
185
201
  */
186
202
  /** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
187
203
  function createServer() {
188
- // Precompute filtered tool list once at server creation (Steps 1–5 are static)
189
- // Step 1: Toolset filter — keep tools in enabled toolsets
190
- const toolsAfterToolsets = allTools.filter(tool => isToolInEnabledToolset(tool.name, enabledToolsets));
191
- // Step 2: Add GITLAB_TOOLS (individual tools bypass toolset filter)
192
- const toolsetToolNames = new Set(toolsAfterToolsets.map(t => t.name));
193
- const toolsAfterIndividual = [
194
- ...toolsAfterToolsets,
195
- ...allTools.filter(tool => individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
196
- ];
197
- // Step 3: Add legacy flag overrides (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI)
198
- const afterIndividualNames = new Set(toolsAfterIndividual.map(t => t.name));
199
- const toolsAfterLegacy = [
200
- ...toolsAfterIndividual,
201
- ...allTools.filter(tool => featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
202
- ];
203
- // Step 4: Permission mode filter (readonly / modify / full)
204
- const toolsAfterReadOnly = GITLAB_PERMISSION_MODE === "full"
205
- ? toolsAfterLegacy
206
- : toolsAfterLegacy.filter(tool => isToolAllowedByPermissionMode(tool.name));
207
- // Step 5: Regex denial filter
208
- let filteredTools = GITLAB_DENIED_TOOLS_REGEX
209
- ? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
210
- : [...toolsAfterReadOnly];
211
- // Step 5.5: Always include discover_tools meta-tool (bypasses toolset filter)
212
- const discoverTool = allTools.find(t => t.name === "discover_tools");
213
- const filteredToolNames = new Set(filteredTools.map(t => t.name));
214
- if (discoverTool && !filteredToolNames.has("discover_tools")) {
215
- // Respect permission mode and regex denial filters
216
- const passesPermissionMode = isToolAllowedByPermissionMode("discover_tools");
217
- const passesRegex = !GITLAB_DENIED_TOOLS_REGEX?.test("discover_tools");
218
- if (passesPermissionMode && passesRegex) {
219
- filteredTools.push(discoverTool);
220
- }
221
- }
222
- // Step 5.7: Remove hidden policy tools
223
- if (hiddenToolSet.size > 0) {
224
- filteredTools = filteredTools.filter(tool => !hiddenToolSet.has(tool.name));
225
- }
204
+ const filteredTools = selectExposedTools({
205
+ tools: allTools,
206
+ isInEnabledToolset: toolName => isToolInEnabledToolset(toolName, enabledToolsets),
207
+ individuallyEnabledTools,
208
+ featureFlagOverrides,
209
+ isAllowedByPermissionMode: isToolAllowedByPermissionMode,
210
+ deniedToolsRegex: GITLAB_DENIED_TOOLS_REGEX,
211
+ hiddenToolNames: hiddenToolSet,
212
+ applySlimProfile,
213
+ });
214
+ const listedToolDecoration = {
215
+ readOnlyToolNames: new Set([...readOnlyTools].filter(toolName => advertisesReadOnly(toolName))),
216
+ destructiveToolNames: destructiveTools,
217
+ approveToolNames: approveToolSet,
218
+ jmespathArgument: JMESPATH_TOOL_ARGUMENT,
219
+ jmespathArgumentDescription: JMESPATH_TOOL_ARGUMENT_DESCRIPTION,
220
+ };
221
+ const listedToolsCache = { revision: 0 };
222
+ const decorateListedTools = (tools) => decorateToolsForList(tools, listedToolDecoration);
223
+ const getListedTools = createListedToolsCache(decorateListedTools);
226
224
  const mcpServer = new McpServer({
227
225
  name: SERVER_NAME,
228
226
  version: SERVER_VERSION,
@@ -261,57 +259,8 @@ function createServer() {
261
259
  };
262
260
  });
263
261
  mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
264
- // Step 6: Gemini $schema cleanup + annotations (only dynamic step per request)
265
- // <<< START: Remove $schema for Gemini compatibility >>>
266
- const tools = filteredTools.map(tool => {
267
- const modified = { ...tool };
268
- // Safety net: remove $schema if present (toJSONSchema strips it for zod schemas,
269
- // but manually-defined schemas like discover_tools may still have it)
270
- if (modified.inputSchema &&
271
- typeof modified.inputSchema === "object" &&
272
- modified.inputSchema !== null) {
273
- if ("$schema" in modified.inputSchema) {
274
- modified.inputSchema = { ...modified.inputSchema };
275
- delete modified.inputSchema.$schema;
276
- }
277
- }
278
- // Add MCP tool annotations
279
- modified.annotations = {
280
- ...(readOnlyTools.has(tool.name) ? { readOnlyHint: true } : {}),
281
- ...(destructiveTools.has(tool.name) ? { destructiveHint: true } : {}),
282
- ...(approveToolSet.has(tool.name) ? { confirmationHint: true } : {}),
283
- openWorldHint: true,
284
- };
285
- // Inject _confirmed optional parameter for approve-policy tools
286
- if (approveToolSet.has(tool.name) && modified.inputSchema?.properties) {
287
- modified.inputSchema = {
288
- ...modified.inputSchema,
289
- properties: {
290
- ...modified.inputSchema.properties,
291
- _confirmed: {
292
- type: "boolean",
293
- description: "Set to true to confirm execution of this approval-required tool.",
294
- },
295
- },
296
- };
297
- }
298
- if (modified.inputSchema && typeof modified.inputSchema === "object") {
299
- const properties = modified.inputSchema.properties && typeof modified.inputSchema.properties === "object"
300
- ? { ...modified.inputSchema.properties }
301
- : {};
302
- if (!(JMESPATH_TOOL_ARGUMENT in properties)) {
303
- properties[JMESPATH_TOOL_ARGUMENT] = {
304
- type: "string",
305
- description: JMESPATH_TOOL_ARGUMENT_DESCRIPTION,
306
- };
307
- modified.inputSchema = { ...modified.inputSchema, properties };
308
- }
309
- }
310
- return modified;
311
- });
312
- // <<< END: Remove $schema for Gemini compatibility >>>
313
262
  return {
314
- tools, // return tool list with $schema removed
263
+ tools: getListedTools(filteredTools, listedToolsCache.revision),
315
264
  };
316
265
  });
317
266
  mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -330,9 +279,7 @@ function createServer() {
330
279
  (toolName === "download_release_asset" && !IS_REMOTE);
331
280
  const filtered = applyJmespathToToolResult(result, jmespathExpression, {
332
281
  ...completionOptions,
333
- maskValue: maskingEngine
334
- ? (value) => maskingEngine.maskValue(value)
335
- : undefined,
282
+ maskValue: maskingEngine ? (value) => maskingEngine.maskValue(value) : undefined,
336
283
  });
337
284
  const masked = maskingEngine
338
285
  ? maskingEngine.maskToolResult(filtered, {
@@ -341,7 +288,7 @@ function createServer() {
341
288
  : filtered;
342
289
  return compactMcpToolResult({
343
290
  result: masked,
344
- enabled: GITLAB_MCP_COMPACT_RESULTS,
291
+ enabled: shouldCompactToolResult(toolName, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_TOOL_NAMES),
345
292
  maxChars: GITLAB_MCP_COMPACT_RESULT_CHARS,
346
293
  toolName,
347
294
  args: readOwnRecord(request.params.arguments),
@@ -356,7 +303,7 @@ function createServer() {
356
303
  durationMs,
357
304
  error: safeError instanceof Error ? safeError.message : String(safeError),
358
305
  }, `tool_call_error: ${toolName} (${durationMs}ms)`);
359
- throw safeError;
306
+ return safeError;
360
307
  };
361
308
  try {
362
309
  const authData = (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId
@@ -418,25 +365,34 @@ function createServer() {
418
365
  if (isToolAllowedByPermissionMode(toolName) &&
419
366
  !hiddenAndNotDenied &&
420
367
  !filteredTools.some(t => t.name === toolName)) {
421
- throw new Error(`Tool "${toolName}" is not available on this server`);
368
+ throw new Error(buildToolUnavailableMessage(toolName));
422
369
  }
423
370
  // Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
424
371
  if (toolName === "discover_tools") {
425
372
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
426
373
  const currentToolNames = new Set(filteredTools.map(t => t.name));
374
+ const isNotActivatable = (name) => isSkippedByDiscoverActivation({
375
+ allowedByPermissionMode: isToolAllowedByPermissionMode(name),
376
+ deniedByRegex: GITLAB_DENIED_TOOLS_REGEX?.test(name) === true,
377
+ hidden: hiddenToolSet.has(name),
378
+ excludedBySlimProfile: isExcludedFromToolsetListing(name),
379
+ });
427
380
  if (!category) {
428
- // List available categories with activation status
429
- const categories = TOOLSET_DEFINITIONS.map(def => ({
430
- id: def.id,
431
- toolCount: def.tools.size,
432
- active: [...def.tools].some(t => currentToolNames.has(t)),
433
- isDefault: def.isDefault,
434
- }));
381
+ // Fully listed only. Partial overlap with `core` must stay inactive so
382
+ // clients still call discover_tools to add the missing tools. Tools this
383
+ // handler skips (slim profile, regex, permission mode, hidden) do not
384
+ // keep a toolset inactive.
385
+ const categories = listDiscoverableCategories(currentToolNames, isNotActivatable);
435
386
  return logCompletion({
436
- content: [{
387
+ content: [
388
+ {
437
389
  type: "text",
438
- text: JSON.stringify({ categories, hint: "Call discover_tools with a category name to activate it" }),
439
- }],
390
+ text: JSON.stringify({
391
+ categories,
392
+ hint: "Call discover_tools with a category name to activate it",
393
+ }),
394
+ },
395
+ ],
440
396
  });
441
397
  }
442
398
  if (!ALL_TOOLSET_IDS.has(category)) {
@@ -457,8 +413,8 @@ function createServer() {
457
413
  isError: true,
458
414
  });
459
415
  }
460
- // Check if already fully active
461
- const alreadyActive = [...toolsetDef.tools].every(t => currentToolNames.has(t));
416
+ // Same predicate as the category listing `active` flag.
417
+ const alreadyActive = isToolsetFullyActive(toolsetDef.tools, currentToolNames, isNotActivatable);
462
418
  if (alreadyActive) {
463
419
  return logCompletion({
464
420
  content: [
@@ -476,11 +432,7 @@ function createServer() {
476
432
  continue;
477
433
  if (currentToolNames.has(tool.name))
478
434
  continue;
479
- if (!isToolAllowedByPermissionMode(tool.name))
480
- continue;
481
- if (GITLAB_DENIED_TOOLS_REGEX?.test(tool.name))
482
- continue;
483
- if (hiddenToolSet.has(tool.name))
435
+ if (isNotActivatable(tool.name))
484
436
  continue;
485
437
  newTools.push(tool);
486
438
  }
@@ -495,6 +447,7 @@ function createServer() {
495
447
  });
496
448
  }
497
449
  filteredTools.push(...newTools);
450
+ listedToolsCache.revision += 1;
498
451
  // Notify client that tool list has changed
499
452
  try {
500
453
  await mcpServer.server.sendToolListChanged();
@@ -505,14 +458,16 @@ function createServer() {
505
458
  const addedNames = newTools.map(t => t.name);
506
459
  logger.info({ event: "toolset_activated", category, toolCount: addedNames.length }, `Activated toolset: ${category} (+${addedNames.length} tools)`);
507
460
  return logCompletion({
508
- content: [{
461
+ content: [
462
+ {
509
463
  type: "text",
510
464
  text: JSON.stringify({
511
465
  activated: category,
512
466
  addedTools: addedNames,
513
467
  totalTools: filteredTools.length,
514
468
  }),
515
- }],
469
+ },
470
+ ],
516
471
  });
517
472
  }
518
473
  // Check approve policy: tool is exposed but requires explicit confirmation
@@ -547,8 +502,20 @@ function createServer() {
547
502
  return logCompletion(result);
548
503
  }
549
504
  catch (error) {
550
- logError(error);
551
- throw error;
505
+ const isGitLabApiError = error instanceof GitLabApiError;
506
+ const safeError = logError(error);
507
+ if (isGitLabApiError) {
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text: safeError instanceof Error ? safeError.message : String(safeError),
513
+ },
514
+ ],
515
+ isError: true,
516
+ };
517
+ }
518
+ throw safeError;
552
519
  }
553
520
  });
554
521
  return mcpServer;
@@ -557,7 +524,10 @@ function createServer() {
557
524
  * Validate configuration at startup
558
525
  */
559
526
  function isLoopbackBindHost(host) {
560
- const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
527
+ const normalized = host
528
+ .trim()
529
+ .toLowerCase()
530
+ .replace(/^\[|\]$/g, "");
561
531
  const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
562
532
  return (normalized === "localhost" ||
563
533
  isIpv4Loopback ||
@@ -565,7 +535,10 @@ function isLoopbackBindHost(host) {
565
535
  normalized === "0:0:0:0:0:0:0:1");
566
536
  }
567
537
  function formatHostWithPort(host, port) {
568
- const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
538
+ const normalized = host
539
+ .trim()
540
+ .toLowerCase()
541
+ .replace(/^\[|\]$/g, "");
569
542
  if (!normalized || normalized === "0.0.0.0" || normalized === "::")
570
543
  return null;
571
544
  if (normalized.includes(":"))
@@ -916,6 +889,12 @@ const hiddenToolSet = new Set((GITLAB_TOOL_POLICY_HIDDEN_RAW || "")
916
889
  logger.warn({ event: "unknown_approve_tool", name }, `GITLAB_TOOL_POLICY_APPROVE contains unknown tool: "${name}"`);
917
890
  }
918
891
  }
892
+ if (GITLAB_MCP_COMPACT_TOOL_NAMES.size > 0) {
893
+ const unknownCompactTools = [...GITLAB_MCP_COMPACT_TOOL_NAMES].filter(name => !knownToolNames.has(name));
894
+ if (unknownCompactTools.length > 0) {
895
+ logger.warn(`Unknown tool names in GITLAB_MCP_COMPACT_TOOLS (will be ignored): ${unknownCompactTools.join(", ")}`);
896
+ }
897
+ }
919
898
  for (const name of hiddenToolSet) {
920
899
  if (!knownToolNames.has(name)) {
921
900
  logger.warn({ event: "unknown_hidden_tool", name }, `GITLAB_TOOL_POLICY_HIDDEN contains unknown tool: "${name}"`);
@@ -1097,12 +1076,15 @@ function buildAuthHeaders() {
1097
1076
  // OAuth token takes priority over PAT when both are set.
1098
1077
  // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
1099
1078
  // 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) };
1079
+ // OAuth tokens always travel as `Authorization: Bearer`, even with GITLAB_IS_OLD: the HTTP
1080
+ // client strips `Authorization` on cross-origin redirects but not `Private-Token`.
1081
+ if (OAUTH_ACCESS_TOKEN) {
1082
+ return { Authorization: `Bearer ${OAUTH_ACCESS_TOKEN}` };
1103
1083
  }
1104
- if (token) {
1105
- return { Authorization: `Bearer ${token}` };
1084
+ if (GITLAB_PERSONAL_ACCESS_TOKEN) {
1085
+ return IS_OLD
1086
+ ? { "Private-Token": String(GITLAB_PERSONAL_ACCESS_TOKEN) }
1087
+ : { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
1106
1088
  }
1107
1089
  // Fall back to CI job token
1108
1090
  if (GITLAB_JOB_TOKEN) {
@@ -1212,6 +1194,13 @@ const downloadRedirectOptions = () => ({
1212
1194
  const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
1213
1195
  const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
1214
1196
  const featureFlagOverrides = buildFeatureFlagOverrides();
1197
+ const applySlimProfile = shouldApplySlimToolProfile(GITLAB_TOOL_PROFILE, GITLAB_TOOLSETS_RAW);
1198
+ if (GITLAB_TOOL_PROFILE === "slim" && !applySlimProfile) {
1199
+ logger.warn("GITLAB_TOOL_PROFILE=slim is ignored because GITLAB_TOOLSETS is set");
1200
+ }
1201
+ function isExcludedFromToolsetListing(toolName) {
1202
+ return isExcludedBySlimProfile(toolName, applySlimProfile, individuallyEnabledTools);
1203
+ }
1215
1204
  // Warn about potentially confusing configuration
1216
1205
  if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1217
1206
  logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
@@ -1325,6 +1314,12 @@ if (!isCliInvocation(process.argv) &&
1325
1314
  logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
1326
1315
  process.exit(1);
1327
1316
  }
1317
+ class GitLabApiError extends Error {
1318
+ constructor(message) {
1319
+ super(message);
1320
+ this.name = "GitLabApiError";
1321
+ }
1322
+ }
1328
1323
  /**
1329
1324
  * Utility function for handling GitLab API errors
1330
1325
  * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
@@ -1341,12 +1336,12 @@ async function handleGitLabError(response) {
1341
1336
  // before the common tool-call boundary returns/logs it.
1342
1337
  logger.error("GitLab API Rate Limit Exceeded");
1343
1338
  logger.error("User API Key Rate limit exceeded. Please try again later.");
1344
- const error = new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1339
+ const error = new GitLabApiError(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1345
1340
  throw error;
1346
1341
  }
1347
1342
  else {
1348
1343
  // Handle other API errors
1349
- const error = new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1344
+ const error = new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1350
1345
  throw error;
1351
1346
  }
1352
1347
  }
@@ -1411,7 +1406,7 @@ async function forkProject(projectId, namespace) {
1411
1406
  });
1412
1407
  // Handle case where project already exists
1413
1408
  if (response.status === 409) {
1414
- throw new Error("Project already exists in the target namespace");
1409
+ throw new GitLabApiError("Project already exists in the target namespace");
1415
1410
  }
1416
1411
  await handleGitLabError(response);
1417
1412
  const data = await response.json();
@@ -1482,7 +1477,7 @@ async function getFileContents(projectId, filePath, ref) {
1482
1477
  });
1483
1478
  // Handle file not found
1484
1479
  if (response.status === 404) {
1485
- throw new Error(`File not found: ${filePath}`);
1480
+ throw new GitLabApiError(`File not found: ${filePath}`);
1486
1481
  }
1487
1482
  await handleGitLabError(response);
1488
1483
  const data = await response.json();
@@ -1515,7 +1510,7 @@ async function createIssue(projectId, options) {
1515
1510
  // Handle bad request
1516
1511
  if (response.status === 400) {
1517
1512
  const errorBody = await response.text();
1518
- throw new Error(`Invalid request: ${errorBody}`);
1513
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
1519
1514
  }
1520
1515
  await handleGitLabError(response);
1521
1516
  const data = await response.json();
@@ -1751,7 +1746,7 @@ async function executeGraphQL(query, variables = {}) {
1751
1746
  });
1752
1747
  if (!response.ok) {
1753
1748
  const errorBody = await response.text();
1754
- throw new Error(`GraphQL request failed (${response.status}): ${errorBody}`);
1749
+ throw new GitLabApiError(`GraphQL request failed (${response.status}): ${errorBody}`);
1755
1750
  }
1756
1751
  const json = await response.json();
1757
1752
  if (json.errors && json.errors.length > 0) {
@@ -1790,7 +1785,9 @@ async function resolveNamesToIds(projectPath, namespaceKind, labelNames, usernam
1790
1785
  // One alias per label — exact title match via the `title` argument, includes ancestor
1791
1786
  // group labels, single round trip with no pagination needed.
1792
1787
  const varDefs = labelNames.map((_, i) => `$l${i}: String!`).join(", ");
1793
- const aliases = labelNames.map((_, i) => `l${i}: labels(title: $l${i}, includeAncestorGroups: true, first: 1) { nodes { id } }`).join(" ");
1788
+ const aliases = labelNames
1789
+ .map((_, i) => `l${i}: labels(title: $l${i}, includeAncestorGroups: true, first: 1) { nodes { id } }`)
1790
+ .join(" ");
1794
1791
  const rootField = namespaceKind === "group" ? "group" : "project";
1795
1792
  const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!${varDefs ? `, ${varDefs}` : ""}) {
1796
1793
  ${rootField}(fullPath: $path) { ${aliases || "__typename"} }
@@ -3200,11 +3197,11 @@ async function createMergeRequest(projectId, options) {
3200
3197
  });
3201
3198
  if (response.status === 400) {
3202
3199
  const errorBody = await response.text();
3203
- throw new Error(`Invalid request: ${errorBody}`);
3200
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
3204
3201
  }
3205
3202
  if (!response.ok) {
3206
3203
  const errorBody = await response.text();
3207
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3204
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3208
3205
  }
3209
3206
  const data = await response.json();
3210
3207
  return GitLabMergeRequestSchema.parse(data);
@@ -3292,7 +3289,7 @@ async function deleteMergeRequestDiscussionNote(projectId, mergeRequestIid, disc
3292
3289
  });
3293
3290
  if (!response.ok) {
3294
3291
  const errorText = await response.text();
3295
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3292
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3296
3293
  }
3297
3294
  }
3298
3295
  /**
@@ -3385,7 +3382,9 @@ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body,
3385
3382
  async function createIssueNote(projectId, issueIid, discussionId, body, createdAt) {
3386
3383
  projectId = decodeURIComponent(projectId); // Decode project ID
3387
3384
  const basePath = `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/issues/${encodeGitLabPathSegment(issueIid)}`;
3388
- const url = new URL(discussionId ? `${basePath}/discussions/${encodeGitLabPathSegment(discussionId)}/notes` : `${basePath}/notes`);
3385
+ const url = new URL(discussionId
3386
+ ? `${basePath}/discussions/${encodeGitLabPathSegment(discussionId)}/notes`
3387
+ : `${basePath}/notes`);
3389
3388
  const payload = { body };
3390
3389
  if (createdAt) {
3391
3390
  payload.created_at = createdAt;
@@ -3452,7 +3451,7 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
3452
3451
  });
3453
3452
  if (!response.ok) {
3454
3453
  const errorText = await response.text();
3455
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3454
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
3456
3455
  }
3457
3456
  }
3458
3457
  // --- Emoji Reactions (REST) ---
@@ -3623,7 +3622,7 @@ async function createOrUpdateFile(projectId, filePath, content, commitMessage, b
3623
3622
  });
3624
3623
  if (!response.ok) {
3625
3624
  const errorBody = await response.text();
3626
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3625
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3627
3626
  }
3628
3627
  const data = await response.json();
3629
3628
  return GitLabCreateUpdateFileResponseSchema.parse(data);
@@ -3652,11 +3651,11 @@ async function createCommit(projectId, message, branch, actions) {
3652
3651
  });
3653
3652
  if (response.status === 400) {
3654
3653
  const errorBody = await response.text();
3655
- throw new Error(`Invalid request: ${errorBody}`);
3654
+ throw new GitLabApiError(`Invalid request: ${errorBody}`);
3656
3655
  }
3657
3656
  if (!response.ok) {
3658
3657
  const errorBody = await response.text();
3659
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3658
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3660
3659
  }
3661
3660
  const data = await response.json();
3662
3661
  return GitLabCommitSchema.parse(data);
@@ -3682,7 +3681,7 @@ async function searchProjects(query, page = 1, perPage = 20) {
3682
3681
  });
3683
3682
  if (!response.ok) {
3684
3683
  const errorBody = await response.text();
3685
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3684
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3686
3685
  }
3687
3686
  const unfiltered = (await response.json());
3688
3687
  const projects = filterProjectsByAllowlist(unfiltered);
@@ -3769,7 +3768,7 @@ async function createRepository(options) {
3769
3768
  });
3770
3769
  if (!response.ok) {
3771
3770
  const errorBody = await response.text();
3772
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3771
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
3773
3772
  }
3774
3773
  const data = await response.json();
3775
3774
  return GitLabRepositorySchema.parse(data);
@@ -4195,7 +4194,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
4195
4194
  });
4196
4195
  if (!response.ok) {
4197
4196
  const errorBody = await response.text();
4198
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
4197
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
4199
4198
  }
4200
4199
  const data = await response.json();
4201
4200
  return GitLabCompareResultSchema.parse(data);
@@ -4428,7 +4427,7 @@ noteableIid, body) {
4428
4427
  });
4429
4428
  if (!response.ok) {
4430
4429
  const errorText = await response.text();
4431
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4430
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4432
4431
  }
4433
4432
  return await response.json();
4434
4433
  }
@@ -4443,7 +4442,7 @@ async function getDraftNote(project_id, merge_request_iid, draft_note_id) {
4443
4442
  const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/merge_requests/${encodeGitLabPathSegment(merge_request_iid)}/draft_notes/${encodeGitLabPathSegment(draft_note_id)}`, { ...getFetchConfig() });
4444
4443
  if (!response.ok) {
4445
4444
  const errorText = await response.text();
4446
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4445
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4447
4446
  }
4448
4447
  const data = await response.json();
4449
4448
  return GitLabDraftNoteSchema.parse(data);
@@ -4457,7 +4456,7 @@ async function listDraftNotes(projectId, mergeRequestIid) {
4457
4456
  });
4458
4457
  if (!response.ok) {
4459
4458
  const errorText = await response.text();
4460
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4459
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4461
4460
  }
4462
4461
  const data = await response.json();
4463
4462
  return z.array(GitLabDraftNoteSchema).parse(data);
@@ -4492,7 +4491,7 @@ async function createDraftNote(projectId, mergeRequestIid, body, inReplyToDiscus
4492
4491
  });
4493
4492
  if (!response.ok) {
4494
4493
  const errorText = await response.text();
4495
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4494
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4496
4495
  }
4497
4496
  const data = await response.json();
4498
4497
  return GitLabDraftNoteSchema.parse(data);
@@ -4527,7 +4526,7 @@ async function updateDraftNote(projectId, mergeRequestIid, draftNoteId, body, po
4527
4526
  });
4528
4527
  if (!response.ok) {
4529
4528
  const errorText = await response.text();
4530
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4529
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4531
4530
  }
4532
4531
  const data = await response.json();
4533
4532
  return GitLabDraftNoteSchema.parse(data);
@@ -4548,7 +4547,7 @@ async function deleteDraftNote(projectId, mergeRequestIid, draftNoteId) {
4548
4547
  });
4549
4548
  if (!response.ok) {
4550
4549
  const errorText = await response.text();
4551
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4550
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4552
4551
  }
4553
4552
  }
4554
4553
  /**
@@ -4567,7 +4566,7 @@ async function publishDraftNote(projectId, mergeRequestIid, draftNoteId) {
4567
4566
  });
4568
4567
  if (!response.ok) {
4569
4568
  const errorText = await response.text();
4570
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4569
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4571
4570
  }
4572
4571
  // Handle empty response (204 No Content) or successful response
4573
4572
  const responseText = await response.text();
@@ -4648,7 +4647,7 @@ async function bulkPublishDraftNotes(projectId, mergeRequestIid, options = {}) {
4648
4647
  });
4649
4648
  if (!response.ok) {
4650
4649
  const errorText = await response.text();
4651
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4650
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4652
4651
  }
4653
4652
  // Handle empty response (204 No Content) or successful response
4654
4653
  const responseText = await response.text();
@@ -4679,7 +4678,7 @@ async function resolveMergeRequestThread(projectId, mergeRequestIid, discussionI
4679
4678
  });
4680
4679
  if (!response.ok) {
4681
4680
  const errorText = await response.text();
4682
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4681
+ throw new GitLabApiError(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
4683
4682
  }
4684
4683
  }
4685
4684
  /**
@@ -5271,7 +5270,7 @@ async function getPipeline(projectId, pipelineId, signal) {
5271
5270
  signal,
5272
5271
  });
5273
5272
  if (response.status === 404) {
5274
- throw new Error(`Pipeline not found`);
5273
+ throw new GitLabApiError(`Pipeline not found`);
5275
5274
  }
5276
5275
  await handleGitLabError(response);
5277
5276
  const data = await response.json();
@@ -5344,7 +5343,7 @@ async function getDeployment(projectId, deploymentId) {
5344
5343
  ...getFetchConfig(),
5345
5344
  });
5346
5345
  if (response.status === 404) {
5347
- throw new Error(`Deployment not found`);
5346
+ throw new GitLabApiError(`Deployment not found`);
5348
5347
  }
5349
5348
  await handleGitLabError(response);
5350
5349
  const data = await response.json();
@@ -5358,7 +5357,8 @@ async function deploymentRequest(projectId, path = "", method = "GET", body, que
5358
5357
  url.searchParams.set(key, String(value));
5359
5358
  }
5360
5359
  const response = await fetch(url.toString(), {
5361
- ...getFetchConfig(), method,
5360
+ ...getFetchConfig(),
5361
+ method,
5362
5362
  body: body === undefined ? undefined : JSON.stringify(body),
5363
5363
  });
5364
5364
  await handleGitLabError(response);
@@ -5396,7 +5396,7 @@ async function getEnvironment(projectId, environmentId) {
5396
5396
  ...getFetchConfig(),
5397
5397
  });
5398
5398
  if (response.status === 404) {
5399
- throw new Error(`Environment not found`);
5399
+ throw new GitLabApiError(`Environment not found`);
5400
5400
  }
5401
5401
  await handleGitLabError(response);
5402
5402
  const data = await response.json();
@@ -5408,7 +5408,11 @@ async function environmentRequest(projectId, path, method, body, query) {
5408
5408
  for (const [key, value] of Object.entries(query ?? {}))
5409
5409
  if (value !== undefined)
5410
5410
  url.searchParams.set(key, String(value));
5411
- const response = await fetch(url.toString(), { ...getFetchConfig(), method, body: body === undefined ? undefined : JSON.stringify(body) });
5411
+ const response = await fetch(url.toString(), {
5412
+ ...getFetchConfig(),
5413
+ method,
5414
+ body: body === undefined ? undefined : JSON.stringify(body),
5415
+ });
5412
5416
  await handleGitLabError(response);
5413
5417
  return response.status === 204 ? { success: true } : response.json();
5414
5418
  }
@@ -5428,7 +5432,11 @@ async function triggerPipeline(projectId, token, ref, variables, inputs) {
5428
5432
  body.variables = variables;
5429
5433
  if (inputs)
5430
5434
  body.inputs = inputs;
5431
- const response = await fetch(url.toString(), { ...getFetchConfig(), method: "POST", body: Object.keys(body).length ? JSON.stringify(body) : undefined });
5435
+ const response = await fetch(url.toString(), {
5436
+ ...getFetchConfig(),
5437
+ method: "POST",
5438
+ body: Object.keys(body).length ? JSON.stringify(body) : undefined,
5439
+ });
5432
5440
  await handleGitLabError(response);
5433
5441
  return response.json();
5434
5442
  }
@@ -5449,7 +5457,7 @@ async function listPipelineJobs(projectId, pipelineId, options = {}) {
5449
5457
  ...getFetchConfig(),
5450
5458
  });
5451
5459
  if (response.status === 404) {
5452
- throw new Error(`Pipeline not found`);
5460
+ throw new GitLabApiError(`Pipeline not found`);
5453
5461
  }
5454
5462
  await handleGitLabError(response);
5455
5463
  const data = await response.json();
@@ -5472,7 +5480,7 @@ async function listPipelineTriggerJobs(projectId, pipelineId, options = {}) {
5472
5480
  ...getFetchConfig(),
5473
5481
  });
5474
5482
  if (response.status === 404) {
5475
- throw new Error(`Pipeline not found`);
5483
+ throw new GitLabApiError(`Pipeline not found`);
5476
5484
  }
5477
5485
  await handleGitLabError(response);
5478
5486
  const data = await response.json();
@@ -5486,7 +5494,7 @@ async function getPipelineJob(projectId, jobId, signal) {
5486
5494
  signal,
5487
5495
  });
5488
5496
  if (response.status === 404) {
5489
- throw new Error(`Job not found`);
5497
+ throw new GitLabApiError(`Job not found`);
5490
5498
  }
5491
5499
  await handleGitLabError(response);
5492
5500
  const data = await response.json();
@@ -5514,7 +5522,7 @@ async function getPipelineJobOutput(projectId, jobId, limit, offset) {
5514
5522
  },
5515
5523
  });
5516
5524
  if (response.status === 404) {
5517
- throw new Error(`Job trace not found or job is not finished yet`);
5525
+ throw new GitLabApiError(`Job trace not found or job is not finished yet`);
5518
5526
  }
5519
5527
  await handleGitLabError(response);
5520
5528
  const fullTrace = await response.text();
@@ -5576,7 +5584,7 @@ async function listJobArtifacts(projectId, jobId, options = {}) {
5576
5584
  ...getFetchConfig(),
5577
5585
  });
5578
5586
  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.`);
5587
+ throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5580
5588
  }
5581
5589
  await handleGitLabError(response);
5582
5590
  const data = await response.json();
@@ -5596,7 +5604,7 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
5596
5604
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts`);
5597
5605
  const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
5598
5606
  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.`);
5607
+ throw new GitLabApiError(`Job artifacts not found. The job may not have produced artifacts or the job ID is invalid.`);
5600
5608
  }
5601
5609
  await handleGitLabError(response);
5602
5610
  const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
@@ -5622,7 +5630,7 @@ async function getJobArtifactFile(projectId, jobId, artifactPath) {
5622
5630
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`);
5623
5631
  const response = await fetchWithValidatedRedirects(url.toString(), downloadRedirectOptions());
5624
5632
  if (response.status === 404) {
5625
- throw new Error(`Artifact file not found: ${artifactPath}`);
5633
+ throw new GitLabApiError(`Artifact file not found: ${artifactPath}`);
5626
5634
  }
5627
5635
  await handleGitLabError(response);
5628
5636
  return await response.text();
@@ -6038,10 +6046,10 @@ async function getRepositoryTree(options) {
6038
6046
  queryParams.append("pagination", options.pagination);
6039
6047
  const response = await fetch(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(options.project_id))}/repository/tree?${queryParams.toString()}`, { ...getFetchConfig() });
6040
6048
  if (response.status === 404) {
6041
- throw new Error("Repository or path not found");
6049
+ throw new GitLabApiError("Repository or path not found");
6042
6050
  }
6043
6051
  if (!response.ok) {
6044
- throw new Error(`Failed to get repository tree: ${response.statusText}`);
6052
+ throw new GitLabApiError(`Failed to get repository tree: ${response.statusText}`);
6045
6053
  }
6046
6054
  const data = await response.json();
6047
6055
  const items = z.array(GitLabTreeItemSchema).parse(data);
@@ -7999,8 +8007,7 @@ async function handleToolCall(params) {
7999
8007
  }
8000
8008
  case "push_files": {
8001
8009
  const args = PushFilesSchema.parse(params.arguments);
8002
- if (GITLAB_PERMISSION_MODE === "modify" &&
8003
- fileOperationsIncludeDeleteOrMove(args.files)) {
8010
+ if (GITLAB_PERMISSION_MODE === "modify" && fileOperationsIncludeDeleteOrMove(args.files)) {
8004
8011
  throw new Error("push_files does not allow delete or move actions in modify mode");
8005
8012
  }
8006
8013
  const result = await createCommit(args.project_id, args.commit_message, args.branch, args.files.map(f => ({
@@ -8871,7 +8878,14 @@ async function handleToolCall(params) {
8871
8878
  const args = DeleteWorkItemEmojiReactionSchema.parse(params.arguments);
8872
8879
  const { workItemGID } = await resolveWorkItemGID(args.project_id, args.iid);
8873
8880
  const result = await removeGraphQLAwardEmoji(workItemGID, args.name);
8874
- return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "success", message: "Work item emoji reaction removed" }) }] };
8881
+ return {
8882
+ content: [
8883
+ {
8884
+ type: "text",
8885
+ text: JSON.stringify(result ?? { status: "success", message: "Work item emoji reaction removed" }),
8886
+ },
8887
+ ],
8888
+ };
8875
8889
  }
8876
8890
  case "create_work_item_note_emoji_reaction": {
8877
8891
  const args = CreateWorkItemNoteEmojiReactionSchema.parse(params.arguments);
@@ -8881,7 +8895,14 @@ async function handleToolCall(params) {
8881
8895
  case "delete_work_item_note_emoji_reaction": {
8882
8896
  const args = DeleteWorkItemNoteEmojiReactionSchema.parse(params.arguments);
8883
8897
  const result = await removeGraphQLAwardEmoji(args.note_id, args.name);
8884
- return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "success", message: "Work item note emoji reaction removed" }) }] };
8898
+ return {
8899
+ content: [
8900
+ {
8901
+ type: "text",
8902
+ text: JSON.stringify(result ?? { status: "success", message: "Work item note emoji reaction removed" }),
8903
+ },
8904
+ ],
8905
+ };
8885
8906
  }
8886
8907
  case "get_timeline_events": {
8887
8908
  const args = GetTimelineEventsSchema.parse(params.arguments);
@@ -9084,23 +9105,55 @@ async function handleToolCall(params) {
9084
9105
  }
9085
9106
  case "get_pipeline_variables": {
9086
9107
  const { project_id, pipeline_id, page, per_page } = GetPipelineVariablesSchema.parse(params.arguments);
9087
- return { content: [{ type: "text", text: JSON.stringify(await getPipelineVariables(project_id, pipeline_id, page, per_page)) }] };
9108
+ return {
9109
+ content: [
9110
+ {
9111
+ type: "text",
9112
+ text: JSON.stringify(await getPipelineVariables(project_id, pipeline_id, page, per_page)),
9113
+ },
9114
+ ],
9115
+ };
9088
9116
  }
9089
9117
  case "get_pipeline_test_report": {
9090
9118
  const { project_id, pipeline_id, page, per_page } = PipelineReportSchema.parse(params.arguments);
9091
- return { content: [{ type: "text", text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, false, page, per_page)) }] };
9119
+ return {
9120
+ content: [
9121
+ {
9122
+ type: "text",
9123
+ text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, false, page, per_page)),
9124
+ },
9125
+ ],
9126
+ };
9092
9127
  }
9093
9128
  case "get_pipeline_test_report_summary": {
9094
9129
  const { project_id, pipeline_id, page, per_page } = PipelineReportSchema.parse(params.arguments);
9095
- return { content: [{ type: "text", text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, true, page, per_page)) }] };
9130
+ return {
9131
+ content: [
9132
+ {
9133
+ type: "text",
9134
+ text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, true, page, per_page)),
9135
+ },
9136
+ ],
9137
+ };
9096
9138
  }
9097
9139
  case "delete_pipeline": {
9098
9140
  const { project_id, pipeline_id } = DeletePipelineSchema.parse(params.arguments);
9099
- return { content: [{ type: "text", text: JSON.stringify(await deletePipeline(project_id, pipeline_id)) }] };
9141
+ return {
9142
+ content: [
9143
+ { type: "text", text: JSON.stringify(await deletePipeline(project_id, pipeline_id)) },
9144
+ ],
9145
+ };
9100
9146
  }
9101
9147
  case "update_pipeline_metadata": {
9102
9148
  const { project_id, pipeline_id, name } = UpdatePipelineMetadataSchema.parse(params.arguments);
9103
- return { content: [{ type: "text", text: JSON.stringify(await updatePipelineMetadata(project_id, pipeline_id, name)) }] };
9149
+ return {
9150
+ content: [
9151
+ {
9152
+ type: "text",
9153
+ text: JSON.stringify(await updatePipelineMetadata(project_id, pipeline_id, name)),
9154
+ },
9155
+ ],
9156
+ };
9104
9157
  }
9105
9158
  case "list_deployments": {
9106
9159
  const args = ListDeploymentsSchema.parse(params.arguments);
@@ -9119,23 +9172,58 @@ async function handleToolCall(params) {
9119
9172
  }
9120
9173
  case "create_deployment": {
9121
9174
  const { project_id, ...body } = CreateDeploymentSchema.parse(params.arguments);
9122
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, "", "POST", body)) }] };
9175
+ return {
9176
+ content: [
9177
+ {
9178
+ type: "text",
9179
+ text: JSON.stringify(await deploymentRequest(project_id, "", "POST", body)),
9180
+ },
9181
+ ],
9182
+ };
9123
9183
  }
9124
9184
  case "update_deployment": {
9125
9185
  const { project_id, deployment_id, ...body } = UpdateDeploymentSchema.parse(params.arguments);
9126
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "PUT", body)) }] };
9186
+ return {
9187
+ content: [
9188
+ {
9189
+ type: "text",
9190
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "PUT", body)),
9191
+ },
9192
+ ],
9193
+ };
9127
9194
  }
9128
9195
  case "delete_deployment": {
9129
9196
  const { project_id, deployment_id } = GetDeploymentSchema.parse(params.arguments);
9130
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "DELETE")) }] };
9197
+ return {
9198
+ content: [
9199
+ {
9200
+ type: "text",
9201
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "DELETE")),
9202
+ },
9203
+ ],
9204
+ };
9131
9205
  }
9132
9206
  case "list_deployment_merge_requests": {
9133
9207
  const { project_id, deployment_id, ...query } = ListDeploymentMergeRequestsSchema.parse(params.arguments);
9134
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/merge_requests`, "GET", undefined, query)) }] };
9208
+ return {
9209
+ content: [
9210
+ {
9211
+ type: "text",
9212
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/merge_requests`, "GET", undefined, query)),
9213
+ },
9214
+ ],
9215
+ };
9135
9216
  }
9136
9217
  case "approve_deployment": {
9137
9218
  const { project_id, deployment_id, ...body } = DeploymentApprovalSchema.parse(params.arguments);
9138
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/approval`, "POST", body)) }] };
9219
+ return {
9220
+ content: [
9221
+ {
9222
+ type: "text",
9223
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/approval`, "POST", body)),
9224
+ },
9225
+ ],
9226
+ };
9139
9227
  }
9140
9228
  case "list_environments": {
9141
9229
  const args = ListEnvironmentsSchema.parse(params.arguments);
@@ -9154,47 +9242,124 @@ async function handleToolCall(params) {
9154
9242
  }
9155
9243
  case "update_environment": {
9156
9244
  const { project_id, environment_id, ...body } = UpdateEnvironmentSchema.parse(params.arguments);
9157
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "PUT", body)) }] };
9245
+ return {
9246
+ content: [
9247
+ {
9248
+ type: "text",
9249
+ text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "PUT", body)),
9250
+ },
9251
+ ],
9252
+ };
9158
9253
  }
9159
9254
  case "delete_environment": {
9160
9255
  const { project_id, environment_id } = GetEnvironmentSchema.parse(params.arguments);
9161
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "DELETE")) }] };
9256
+ return {
9257
+ content: [
9258
+ {
9259
+ type: "text",
9260
+ text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "DELETE")),
9261
+ },
9262
+ ],
9263
+ };
9162
9264
  }
9163
9265
  case "stop_environment": {
9164
9266
  const { project_id, environment_id, force } = StopEnvironmentSchema.parse(params.arguments);
9165
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}/stop`, "POST", undefined, { force })) }] };
9267
+ return {
9268
+ content: [
9269
+ {
9270
+ type: "text",
9271
+ text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}/stop`, "POST", undefined, { force })),
9272
+ },
9273
+ ],
9274
+ };
9166
9275
  }
9167
9276
  case "stop_stale_environments": {
9168
9277
  const { project_id, before } = StopStaleEnvironmentsSchema.parse(params.arguments);
9169
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, "/stop_stale", "POST", undefined, { before })) }] };
9278
+ return {
9279
+ content: [
9280
+ {
9281
+ type: "text",
9282
+ text: JSON.stringify(await environmentRequest(project_id, "/stop_stale", "POST", undefined, { before })),
9283
+ },
9284
+ ],
9285
+ };
9170
9286
  }
9171
9287
  case "delete_review_app_environments": {
9172
9288
  const { project_id, ...query } = DeleteReviewAppEnvironmentsSchema.parse(params.arguments);
9173
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, "/review_apps", "DELETE", undefined, query)) }] };
9289
+ return {
9290
+ content: [
9291
+ {
9292
+ type: "text",
9293
+ text: JSON.stringify(await environmentRequest(project_id, "/review_apps", "DELETE", undefined, query)),
9294
+ },
9295
+ ],
9296
+ };
9174
9297
  }
9175
9298
  case "list_pipeline_triggers": {
9176
9299
  const { project_id } = ListPipelineTriggersSchema.parse(params.arguments);
9177
- return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id))) }] };
9300
+ return {
9301
+ content: [
9302
+ {
9303
+ type: "text",
9304
+ text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id))),
9305
+ },
9306
+ ],
9307
+ };
9178
9308
  }
9179
9309
  case "get_pipeline_trigger": {
9180
9310
  const { project_id, trigger_id } = PipelineTriggerIdSchema.parse(params.arguments);
9181
- return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`))) }] };
9311
+ return {
9312
+ content: [
9313
+ {
9314
+ type: "text",
9315
+ text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`))),
9316
+ },
9317
+ ],
9318
+ };
9182
9319
  }
9183
9320
  case "create_pipeline_trigger": {
9184
9321
  const { project_id, ...body } = CreatePipelineTriggerSchema.parse(params.arguments);
9185
- return { content: [{ type: "text", text: JSON.stringify(await pipelineTriggerRequest(project_id, "", "POST", body)) }] };
9322
+ return {
9323
+ content: [
9324
+ {
9325
+ type: "text",
9326
+ text: JSON.stringify(await pipelineTriggerRequest(project_id, "", "POST", body)),
9327
+ },
9328
+ ],
9329
+ };
9186
9330
  }
9187
9331
  case "update_pipeline_trigger": {
9188
9332
  const { project_id, trigger_id, ...body } = UpdatePipelineTriggerSchema.parse(params.arguments);
9189
- return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "PUT", body))) }] };
9333
+ return {
9334
+ content: [
9335
+ {
9336
+ type: "text",
9337
+ text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "PUT", body))),
9338
+ },
9339
+ ],
9340
+ };
9190
9341
  }
9191
9342
  case "delete_pipeline_trigger": {
9192
9343
  const { project_id, trigger_id } = PipelineTriggerIdSchema.parse(params.arguments);
9193
- return { content: [{ type: "text", text: JSON.stringify(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "DELETE")) }] };
9344
+ return {
9345
+ content: [
9346
+ {
9347
+ type: "text",
9348
+ text: JSON.stringify(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "DELETE")),
9349
+ },
9350
+ ],
9351
+ };
9194
9352
  }
9195
9353
  case "trigger_pipeline": {
9196
9354
  const { project_id, token, ref, variables, inputs } = TriggerPipelineSchema.parse(params.arguments);
9197
- return { content: [{ type: "text", text: JSON.stringify(await triggerPipeline(project_id, token, ref, variables, inputs)) }] };
9355
+ return {
9356
+ content: [
9357
+ {
9358
+ type: "text",
9359
+ text: JSON.stringify(await triggerPipeline(project_id, token, ref, variables, inputs)),
9360
+ },
9361
+ ],
9362
+ };
9198
9363
  }
9199
9364
  case "list_pipeline_jobs": {
9200
9365
  const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(params.arguments);
@@ -9561,7 +9726,7 @@ async function handleToolCall(params) {
9561
9726
  };
9562
9727
  }
9563
9728
  case "play_pipeline_jobs": {
9564
- const { project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds } = PlayPipelineJobsSchema.parse(params.arguments);
9729
+ const { project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds, } = PlayPipelineJobsSchema.parse(params.arguments);
9565
9730
  const jobs = await playPipelineJobs(project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds);
9566
9731
  return { content: [{ type: "text", text: JSON.stringify(jobs) }] };
9567
9732
  }
@@ -9591,7 +9756,11 @@ async function handleToolCall(params) {
9591
9756
  }
9592
9757
  case "erase_pipeline_job": {
9593
9758
  const { project_id, job_id } = ErasePipelineJobSchema.parse(params.arguments);
9594
- return { content: [{ type: "text", text: JSON.stringify(await erasePipelineJob(project_id, job_id)) }] };
9759
+ return {
9760
+ content: [
9761
+ { type: "text", text: JSON.stringify(await erasePipelineJob(project_id, job_id)) },
9762
+ ],
9763
+ };
9595
9764
  }
9596
9765
  case "wait_for_pipeline": {
9597
9766
  const { project_id, pipeline_id, timeout_seconds, poll_interval_seconds } = WaitForPipelineSchema.parse(params.arguments);
@@ -9623,7 +9792,15 @@ async function handleToolCall(params) {
9623
9792
  }
9624
9793
  const downloadUrl = buildDownloadUrl("job-artifacts", { project_id, job_id });
9625
9794
  return {
9626
- content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: `artifacts_job_${job_id}.zip` }) }],
9795
+ content: [
9796
+ {
9797
+ type: "text",
9798
+ text: JSON.stringify({
9799
+ download_url: downloadUrl,
9800
+ filename: `artifacts_job_${job_id}.zip`,
9801
+ }),
9802
+ },
9803
+ ],
9627
9804
  };
9628
9805
  }
9629
9806
  const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
@@ -10126,7 +10303,12 @@ async function handleToolCall(params) {
10126
10303
  filename: args.filename,
10127
10304
  });
10128
10305
  return {
10129
- content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: args.filename }) }],
10306
+ content: [
10307
+ {
10308
+ type: "text",
10309
+ text: JSON.stringify({ download_url: downloadUrl, filename: args.filename }),
10310
+ },
10311
+ ],
10130
10312
  };
10131
10313
  }
10132
10314
  const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
@@ -10232,7 +10414,15 @@ async function handleToolCall(params) {
10232
10414
  direct_asset_path: args.direct_asset_path,
10233
10415
  });
10234
10416
  return {
10235
- content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: args.direct_asset_path.split("/").pop() || args.direct_asset_path }) }],
10417
+ content: [
10418
+ {
10419
+ type: "text",
10420
+ text: JSON.stringify({
10421
+ download_url: downloadUrl,
10422
+ filename: args.direct_asset_path.split("/").pop() || args.direct_asset_path,
10423
+ }),
10424
+ },
10425
+ ],
10236
10426
  };
10237
10427
  }
10238
10428
  const assetContent = await downloadReleaseAsset(args.project_id, args.tag_name, args.direct_asset_path);
@@ -10423,9 +10613,7 @@ async function handleToolCall(params) {
10423
10613
  const jobResponse = await fetch(jobUrl.toString(), getFetchConfig());
10424
10614
  authenticated = jobResponse.ok;
10425
10615
  }
10426
- const versionMetadata = authenticated
10427
- ? await fetchGitLabVersionMetadata()
10428
- : null;
10616
+ const versionMetadata = authenticated ? await fetchGitLabVersionMetadata() : null;
10429
10617
  return {
10430
10618
  content: [
10431
10619
  {
@@ -10488,7 +10676,9 @@ async function handleToolCall(params) {
10488
10676
  });
10489
10677
  await handleGitLabError(response);
10490
10678
  return {
10491
- content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }) }],
10679
+ content: [
10680
+ { type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }) },
10681
+ ],
10492
10682
  };
10493
10683
  }
10494
10684
  case "list_protected_branches": {
@@ -10563,7 +10753,12 @@ async function handleToolCall(params) {
10563
10753
  });
10564
10754
  await handleGitLabError(response);
10565
10755
  return {
10566
- content: [{ type: "text", text: JSON.stringify({ status: "unprotected", branch: args.branch_name }) }],
10756
+ content: [
10757
+ {
10758
+ type: "text",
10759
+ text: JSON.stringify({ status: "unprotected", branch: args.branch_name }),
10760
+ },
10761
+ ],
10567
10762
  };
10568
10763
  }
10569
10764
  case "update_default_branch": {
@@ -10579,7 +10774,16 @@ async function handleToolCall(params) {
10579
10774
  await handleGitLabError(response);
10580
10775
  const data = await response.json();
10581
10776
  return {
10582
- content: [{ type: "text", text: JSON.stringify({ status: "updated", default_branch: args.default_branch, project: data }) }],
10777
+ content: [
10778
+ {
10779
+ type: "text",
10780
+ text: JSON.stringify({
10781
+ status: "updated",
10782
+ default_branch: args.default_branch,
10783
+ project: data,
10784
+ }),
10785
+ },
10786
+ ],
10583
10787
  };
10584
10788
  }
10585
10789
  default:
@@ -10634,6 +10838,7 @@ function buildDownloadProxyDeps() {
10634
10838
  * Start server with traditional SSE transport
10635
10839
  */
10636
10840
  async function startSSEServer() {
10841
+ const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack();
10637
10842
  const app = express();
10638
10843
  const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
10639
10844
  if (MCP_TRUST_PROXY) {
@@ -10670,7 +10875,8 @@ async function startSSEServer() {
10670
10875
  const host = req.headers.host;
10671
10876
  const forwardedHost = MCP_TRUST_PROXY ? getForwardedRequestHost(req, true) : undefined;
10672
10877
  const requestHosts = [host, forwardedHost].filter((value) => Boolean(value));
10673
- if (requestHosts.length === 0 || !requestHosts.some(requestHost => effectiveHosts.has(requestHost))) {
10878
+ if (requestHosts.length === 0 ||
10879
+ !requestHosts.some(requestHost => effectiveHosts.has(requestHost))) {
10674
10880
  res.status(403).json({ error: "Invalid Host header" });
10675
10881
  return;
10676
10882
  }
@@ -10845,6 +11051,7 @@ async function startSSEServer() {
10845
11051
  * Start server with Streamable HTTP transport
10846
11052
  */
10847
11053
  async function startStreamableHTTPServer() {
11054
+ const { express, rateLimit, ipKeyGenerator, StreamableHTTPServerTransport, mcpAuthRouter, requireBearerAuth, } = await loadRemoteHttpStack();
10848
11055
  const app = express();
10849
11056
  // Session IDs are untrusted map keys. A null prototype prevents inherited
10850
11057
  // names such as "constructor" from masquerading as live transports.
@@ -11377,9 +11584,7 @@ async function startStreamableHTTPServer() {
11377
11584
  // ipKeyGenerator for correct IPv6 subnet handling.
11378
11585
  const rateLimitOptions = { keyGenerator: mcpRateLimitKeyGenerator };
11379
11586
  const parsedRegisterLimit = Number(process.env.OAUTH_REGISTER_RATE_LIMIT_PER_HOUR);
11380
- const OAUTH_REGISTER_RATE_LIMIT_PER_HOUR = Number.isInteger(parsedRegisterLimit) && parsedRegisterLimit >= 1
11381
- ? parsedRegisterLimit
11382
- : 20;
11587
+ const OAUTH_REGISTER_RATE_LIMIT_PER_HOUR = Number.isInteger(parsedRegisterLimit) && parsedRegisterLimit >= 1 ? parsedRegisterLimit : 20;
11383
11588
  const clientRegistrationRateLimitOptions = {
11384
11589
  ...rateLimitOptions,
11385
11590
  windowMs: 60 * 60 * 1000, // 1 hour
@@ -11479,9 +11684,7 @@ async function startStreamableHTTPServer() {
11479
11684
  ? staticMcpBearerAuth
11480
11685
  : (_req, _res, next) => next();
11481
11686
  const rejectUnknownStatefulSession = (req, res, next) => {
11482
- const usesStatelessSessions = OAUTH_STATELESS_MODE &&
11483
- STATELESS_MATERIAL &&
11484
- (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH);
11687
+ const usesStatelessSessions = OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH);
11485
11688
  const sessionId = readMcpSessionIdHeader(req);
11486
11689
  if (!usesStatelessSessions && sessionId && !streamableTransports[sessionId]) {
11487
11690
  res.status(404).json({ error: "Session not found" });
@@ -11499,7 +11702,9 @@ async function startStreamableHTTPServer() {
11499
11702
  // entirely and derive the session auth from either the current request
11500
11703
  // headers (init) or a sealed Mcp-Session-Id (subsequent requests).
11501
11704
  // Rate limiting is disabled here because there is no shared counter.
11502
- if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11705
+ if (OAUTH_STATELESS_MODE &&
11706
+ STATELESS_MATERIAL &&
11707
+ (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11503
11708
  await handleStatelessMcpRequest(req, res, STATELESS_MATERIAL, OAUTH_STATELESS_SESSION_TTL_SECONDS);
11504
11709
  return;
11505
11710
  }
@@ -11780,7 +11985,9 @@ async function startStreamableHTTPServer() {
11780
11985
  });
11781
11986
  return;
11782
11987
  }
11783
- if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11988
+ if (OAUTH_STATELESS_MODE &&
11989
+ STATELESS_MATERIAL &&
11990
+ (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11784
11991
  res.setHeader("Allow", "POST");
11785
11992
  res.status(405).json({
11786
11993
  error: "Method Not Allowed",
@@ -11987,6 +12194,22 @@ async function initializeServerByTransportMode(mode) {
11987
12194
  }
11988
12195
  }
11989
12196
  }
12197
+ /**
12198
+ * Construct the OAuth client when OAuth is enabled. No network: the token is
12199
+ * acquired lazily on the first tool call via ensureValidOAuthToken, which
12200
+ * avoids blocking startup and opening a browser on transient boot-time errors.
12201
+ * Shared by the MCP server and the human CLI.
12202
+ */
12203
+ function initOAuthClient() {
12204
+ if (!USE_OAUTH) {
12205
+ return;
12206
+ }
12207
+ if (isCleartextRemoteUrl(GITLAB_API_URL)) {
12208
+ throw new Error(`Refusing to send OAuth tokens over cleartext HTTP to ${GITLAB_API_URL}. ` +
12209
+ "Use an https:// GITLAB_API_URL (http:// is only allowed for localhost).");
12210
+ }
12211
+ oauthClient = createGitLabOAuthClient(GITLAB_API_URL.replace(/\/api\/v4$/, ""));
12212
+ }
11990
12213
  /**
11991
12214
  * Initialize and run the server
11992
12215
  * Main entry point for server startup
@@ -11999,12 +12222,7 @@ async function runServer() {
11999
12222
  if (USE_OAUTH) {
12000
12223
  logger.info("Using OAuth authentication...");
12001
12224
  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);
12225
+ initOAuthClient();
12008
12226
  logger.info("OAuth enabled; token acquired lazily on first use.");
12009
12227
  }
12010
12228
  catch (error) {
@@ -12026,16 +12244,16 @@ async function runServer() {
12026
12244
  }
12027
12245
  logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.join(", ")}`);
12028
12246
  logger.info(`Default GitLab API URL: ${GITLAB_API_URL}`);
12029
- if (GITLAB_ALLOWED_GROUPS_RAW) {
12030
- if (GITLAB_OAUTH_ALLOWED_GROUPS_RAW) {
12031
- logger.warn("GITLAB_ALLOWED_GROUPS is set but ignored — GITLAB_OAUTH_ALLOWED_GROUPS takes precedence.");
12032
- }
12033
- else {
12034
- logger.warn("GITLAB_ALLOWED_GROUPS is deprecated. Use GITLAB_OAUTH_ALLOWED_GROUPS instead.");
12035
- }
12036
- }
12037
- if (GITLAB_READ_ONLY_MODE) {
12038
- logger.warn("GITLAB_READ_ONLY_MODE is deprecated. Use GITLAB_PERMISSION_MODE=readonly or --permission-mode=readonly instead.");
12247
+ for (const warning of getDeprecatedEnvWarnings({
12248
+ readOnlyMode: GITLAB_READ_ONLY_MODE,
12249
+ permissionModeRaw: GITLAB_PERMISSION_MODE_RAW,
12250
+ allowedGroupsRaw: GITLAB_ALLOWED_GROUPS_RAW,
12251
+ oauthAllowedGroupsRaw: GITLAB_OAUTH_ALLOWED_GROUPS_RAW,
12252
+ useWikiRaw: USE_GITLAB_WIKI_RAW,
12253
+ useMilestoneRaw: USE_MILESTONE_RAW,
12254
+ usePipelineRaw: USE_PIPELINE_RAW,
12255
+ })) {
12256
+ logger.warn(warning);
12039
12257
  }
12040
12258
  if (GITLAB_OAUTH_ALLOWED_GROUPS) {
12041
12259
  logger.info(`Group access control enabled for: ${GITLAB_OAUTH_ALLOWED_GROUPS.join(", ")}`);
@@ -12074,6 +12292,7 @@ async function main() {
12074
12292
  process.exit(2);
12075
12293
  }
12076
12294
  try {
12295
+ initOAuthClient();
12077
12296
  const result = await handleToolCall({ name: cli.toolName, arguments: cli.args });
12078
12297
  const masked = maskCliToolResult(cli.toolName, result);
12079
12298
  const formatted = formatToolOutput({