@zereight/mcp-gitlab 2.1.70 → 2.2.1

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 (158) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +15 -11
  3. package/README.pt-BR.md +9 -5
  4. package/README.zh-CN.md +9 -5
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +63 -0
  7. package/build/index.js +430 -200
  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 +232 -63
  16. package/build/tools/tool-descriptions.js +3 -3
  17. package/build/tools/tool-profile.js +66 -0
  18. package/build/utils/compact-tool-allowlist.js +14 -0
  19. package/build/utils/logger.js +34 -12
  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/credentials.test.js +0 -153
  32. package/build/test/cli/exposure.test.js +0 -104
  33. package/build/test/cli/load-config-snapshot.js +0 -5
  34. package/build/test/cli/output.test.js +0 -74
  35. package/build/test/cli/replay-command.test.js +0 -37
  36. package/build/test/cli/router.test.js +0 -183
  37. package/build/test/client-pool-test.js +0 -109
  38. package/build/test/clients/client.js +0 -34
  39. package/build/test/clients/custom-header-client.js +0 -122
  40. package/build/test/clients/sse-client.js +0 -95
  41. package/build/test/clients/stdio-client.js +0 -113
  42. package/build/test/clients/streamable-http-client.js +0 -95
  43. package/build/test/config-allowed-groups.test.js +0 -97
  44. package/build/test/create-commit-actions.test.js +0 -102
  45. package/build/test/draft-note-schema.test.js +0 -44
  46. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  47. package/build/test/dynamic-api-url-test.js +0 -304
  48. package/build/test/dynamic-routing-tests.js +0 -495
  49. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  50. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  51. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  52. package/build/test/gitlab-diff-schema.test.js +0 -36
  53. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  54. package/build/test/gitlab-tag-schema.test.js +0 -51
  55. package/build/test/group-milestone-schema.test.js +0 -45
  56. package/build/test/mcp-oauth-tests.js +0 -905
  57. package/build/test/mcp-server-name.test.js +0 -87
  58. package/build/test/merge-merge-request-schema.test.js +0 -75
  59. package/build/test/multi-server-test.js +0 -190
  60. package/build/test/no-proxy-integration-test.js +0 -183
  61. package/build/test/no-proxy-test.js +0 -149
  62. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  63. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  64. package/build/test/oauth-device-flow-tests.js +0 -450
  65. package/build/test/oauth-download-url.test.js +0 -157
  66. package/build/test/oauth-startup.test.js +0 -204
  67. package/build/test/oauth-tests.js +0 -428
  68. package/build/test/path-segment-encoding.test.js +0 -98
  69. package/build/test/remote-auth-simple-test.js +0 -277
  70. package/build/test/remote-auth-tests.js +0 -314
  71. package/build/test/response-masking.test.js +0 -445
  72. package/build/test/schema-tests.js +0 -1679
  73. package/build/test/server/metrics.test.js +0 -47
  74. package/build/test/server/request-helpers.test.js +0 -48
  75. package/build/test/server/version.test.js +0 -13
  76. package/build/test/sse-auth-guard.test.js +0 -96
  77. package/build/test/sse-session-limits.test.js +0 -273
  78. package/build/test/stateless/callback-proxy.test.js +0 -510
  79. package/build/test/stateless/client-id.test.js +0 -177
  80. package/build/test/stateless/codec.test.js +0 -328
  81. package/build/test/stateless/config-ttl.test.js +0 -149
  82. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  83. package/build/test/stateless/session-id-integration.test.js +0 -675
  84. package/build/test/stateless/session-id.test.js +0 -157
  85. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  86. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  87. package/build/test/streamable-http-sse-stream.test.js +0 -350
  88. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  89. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  90. package/build/test/test-all-transport-server.js +0 -255
  91. package/build/test/test-auth-retry.js +0 -195
  92. package/build/test/test-blank-filters.js +0 -156
  93. package/build/test/test-ci-catalog.js +0 -177
  94. package/build/test/test-ci-lint.js +0 -191
  95. package/build/test/test-ci-variables.js +0 -309
  96. package/build/test/test-create-repository.js +0 -120
  97. package/build/test/test-dependency-proxy.js +0 -195
  98. package/build/test/test-deployment-tools.js +0 -463
  99. package/build/test/test-download-attachment.js +0 -190
  100. package/build/test/test-dynamic-project-scope.js +0 -524
  101. package/build/test/test-empty-toolsets.js +0 -535
  102. package/build/test/test-get-file-blame.js +0 -145
  103. package/build/test/test-get-file-contents.js +0 -128
  104. package/build/test/test-geteffectiveprojectid.js +0 -804
  105. package/build/test/test-health-check.js +0 -115
  106. package/build/test/test-issue-description-patch.js +0 -256
  107. package/build/test/test-job-artifacts.js +0 -275
  108. package/build/test/test-json-schema.js +0 -166
  109. package/build/test/test-list-group-members.js +0 -150
  110. package/build/test/test-list-group-merge-requests.js +0 -165
  111. package/build/test/test-list-issues.js +0 -170
  112. package/build/test/test-list-merge-requests.js +0 -201
  113. package/build/test/test-list-milestones.js +0 -145
  114. package/build/test/test-list-project-members.js +0 -132
  115. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  116. package/build/test/test-merge-request-approvals.js +0 -187
  117. package/build/test/test-merge-request-pipelines.js +0 -106
  118. package/build/test/test-mr-diffs-filter.js +0 -132
  119. package/build/test/test-mr-file-diffs.js +0 -251
  120. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  121. package/build/test/test-orbit.js +0 -155
  122. package/build/test/test-permission-mode.js +0 -424
  123. package/build/test/test-pipeline-schedules.js +0 -484
  124. package/build/test/test-protected-branches.js +0 -174
  125. package/build/test/test-remote-downloads.js +0 -510
  126. package/build/test/test-search-code.js +0 -275
  127. package/build/test/test-snippets.js +0 -735
  128. package/build/test/test-tags.js +0 -220
  129. package/build/test/test-todos.js +0 -195
  130. package/build/test/test-token-optimizations.js +0 -691
  131. package/build/test/test-toolset-filtering.js +0 -499
  132. package/build/test/test-update-issue-slim.js +0 -141
  133. package/build/test/test-update-project.js +0 -112
  134. package/build/test/test-upload-markdown.js +0 -206
  135. package/build/test/test-vulnerabilities.js +0 -399
  136. package/build/test/test-webhooks.js +0 -218
  137. package/build/test/tool-description-quality.test.js +0 -50
  138. package/build/test/utils/bulk-publish-options.test.js +0 -32
  139. package/build/test/utils/download-token.test.js +0 -35
  140. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  141. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  142. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  143. package/build/test/utils/graphql-query.test.js +0 -168
  144. package/build/test/utils/helpers-path.test.js +0 -104
  145. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  146. package/build/test/utils/masking.test.js +0 -384
  147. package/build/test/utils/merge-request-position.test.js +0 -57
  148. package/build/test/utils/mock-gitlab-server.js +0 -614
  149. package/build/test/utils/proxy-client-ip.test.js +0 -28
  150. package/build/test/utils/redact-sensitive.test.js +0 -32
  151. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  152. package/build/test/utils/server-launcher.js +0 -231
  153. package/build/test/utils/tool-args.test.js +0 -210
  154. package/build/test/utils/url.test.js +0 -141
  155. package/build/test/utils/version-check.test.js +0 -239
  156. package/build/test/utils/wiki-title.test.js +0 -21
  157. package/build/test-note.js +0 -54
  158. 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");
@@ -60,17 +61,14 @@ function isConstantTimeSecretMatch(provided, expected) {
60
61
  }
61
62
  import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./stateless/index.js";
62
63
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
63
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
64
64
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
65
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
66
65
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
67
66
  import { AsyncLocalStorage } from "node:async_hooks";
68
- import express from "express";
69
67
  import fetchCookie from "fetch-cookie";
70
68
  import fs from "node:fs";
71
69
  import { pipeline as streamPipeline } from "node:stream/promises";
72
70
  import os from "node:os";
73
- import { fetch as undiciFetch, File, FormData } from "undici";
71
+ import { fetch as undiciFetch, File, FormData, } from "undici";
74
72
  import path from "node:path";
75
73
  import { CookieJar, parse as parseCookie } from "tough-cookie";
76
74
  import { URL } from "node:url";
@@ -83,12 +81,11 @@ import { formatCliError, formatToolOutput } from "./cli/output.js";
83
81
  import { compactMcpToolResult, readOwnRecord } from "./cli/compact-mcp-result.js";
84
82
  import { compileDeniedToolsRegex } from "./tools/denied-regex.js";
85
83
  import { createGitLabOAuthProvider } from "./oauth-proxy.js";
86
- import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
87
- import rateLimit, { ipKeyGenerator } from "express-rate-limit";
88
84
  import { normalizeProxyClientIpForRateLimit } from "./utils/proxy-client-ip.js";
89
85
  import { getForwardedPublicBaseUrl, getForwardedRequestHost, } from "./utils/forwarded-public-base-url.js";
90
86
  import { registerDownloadProxy } from "./downloads/proxy.js";
91
87
  import { createDownloadToken } from "./utils/download-token.js";
88
+ import { loadRemoteHttpStack } from "./server/remote-http-stack.js";
92
89
  import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
93
90
  import { formatPrometheusMetrics } from "./server/metrics.js";
94
91
  import { SERVER_VERSION } from "./server/version.js";
@@ -109,9 +106,13 @@ import { buildBulkPublishDraftNotesBody, needsGitLab19_2BulkPublish, } from "./u
109
106
  import { appendFilterParam, appendFilterParams, cleanMutuallyExclusiveIdUsernameOptions, dropBlankArrayEntries, isBlankFilterValue, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
110
107
  import { applyJmespathToToolResult, getJmespathSyntaxError, JMESPATH_TOOL_ARGUMENT, JMESPATH_TOOL_ARGUMENT_DESCRIPTION, omitJmespathArgument, readJmespathExpression, } from "./utils/jmespath-tool-result.js";
111
108
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
112
- import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
113
109
  import { GitLabClientPool } from "./gitlab-client-pool.js";
114
- 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";
115
116
  import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, ErasePipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
116
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,
117
118
  // Discussion Schemas
@@ -149,7 +150,7 @@ const MCP_PROMPTS = [
149
150
  render: (args) => `Review merge request !${args.merge_request_iid ?? "<iid>"} in project ${args.project_id ?? "<project_id>"}:\n` +
150
151
  `1. Call list_merge_request_changed_files to get the changed file list (use excluded_file_patterns for lockfiles/dist/generated files).\n` +
151
152
  `2. Call get_merge_request_file_diff in batches of 3-5 files, prioritizing source over config/tests.\n` +
152
- `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` +
153
154
  `4. Summarize the review verdict in a final top-level note.`,
154
155
  },
155
156
  {
@@ -179,6 +180,20 @@ const MCP_PROMPTS = [
179
180
  `3. Propose a remediation order, calling out any critical/high items with no available fix.`,
180
181
  },
181
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
+ }
182
197
  /**
183
198
  * Create a new MCP Server instance with request handlers registered.
184
199
  * Each transport connection gets its own Server instance to prevent
@@ -186,44 +201,26 @@ const MCP_PROMPTS = [
186
201
  */
187
202
  /** Builds an MCP server with the tool list filtered by toolsets, permission mode, and policy. */
188
203
  function createServer() {
189
- // Precompute filtered tool list once at server creation (Steps 1–5 are static)
190
- // Step 1: Toolset filter — keep tools in enabled toolsets
191
- const toolsAfterToolsets = allTools.filter(tool => isToolInEnabledToolset(tool.name, enabledToolsets));
192
- // Step 2: Add GITLAB_TOOLS (individual tools bypass toolset filter)
193
- const toolsetToolNames = new Set(toolsAfterToolsets.map(t => t.name));
194
- const toolsAfterIndividual = [
195
- ...toolsAfterToolsets,
196
- ...allTools.filter(tool => individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name)),
197
- ];
198
- // Step 3: Add legacy flag overrides (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI)
199
- const afterIndividualNames = new Set(toolsAfterIndividual.map(t => t.name));
200
- const toolsAfterLegacy = [
201
- ...toolsAfterIndividual,
202
- ...allTools.filter(tool => featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name)),
203
- ];
204
- // Step 4: Permission mode filter (readonly / modify / full)
205
- const toolsAfterReadOnly = GITLAB_PERMISSION_MODE === "full"
206
- ? toolsAfterLegacy
207
- : toolsAfterLegacy.filter(tool => isToolAllowedByPermissionMode(tool.name));
208
- // Step 5: Regex denial filter
209
- let filteredTools = GITLAB_DENIED_TOOLS_REGEX
210
- ? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX.test(tool.name))
211
- : [...toolsAfterReadOnly];
212
- // Step 5.5: Always include discover_tools meta-tool (bypasses toolset filter)
213
- const discoverTool = allTools.find(t => t.name === "discover_tools");
214
- const filteredToolNames = new Set(filteredTools.map(t => t.name));
215
- if (discoverTool && !filteredToolNames.has("discover_tools")) {
216
- // Respect permission mode and regex denial filters
217
- const passesPermissionMode = isToolAllowedByPermissionMode("discover_tools");
218
- const passesRegex = !GITLAB_DENIED_TOOLS_REGEX?.test("discover_tools");
219
- if (passesPermissionMode && passesRegex) {
220
- filteredTools.push(discoverTool);
221
- }
222
- }
223
- // Step 5.7: Remove hidden policy tools
224
- if (hiddenToolSet.size > 0) {
225
- filteredTools = filteredTools.filter(tool => !hiddenToolSet.has(tool.name));
226
- }
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);
227
224
  const mcpServer = new McpServer({
228
225
  name: SERVER_NAME,
229
226
  version: SERVER_VERSION,
@@ -262,57 +259,8 @@ function createServer() {
262
259
  };
263
260
  });
264
261
  mcpServer.server.setRequestHandler(ListToolsRequestSchema, async () => {
265
- // Step 6: Gemini $schema cleanup + annotations (only dynamic step per request)
266
- // <<< START: Remove $schema for Gemini compatibility >>>
267
- const tools = filteredTools.map(tool => {
268
- const modified = { ...tool };
269
- // Safety net: remove $schema if present (toJSONSchema strips it for zod schemas,
270
- // but manually-defined schemas like discover_tools may still have it)
271
- if (modified.inputSchema &&
272
- typeof modified.inputSchema === "object" &&
273
- modified.inputSchema !== null) {
274
- if ("$schema" in modified.inputSchema) {
275
- modified.inputSchema = { ...modified.inputSchema };
276
- delete modified.inputSchema.$schema;
277
- }
278
- }
279
- // Add MCP tool annotations
280
- modified.annotations = {
281
- ...(readOnlyTools.has(tool.name) ? { readOnlyHint: true } : {}),
282
- ...(destructiveTools.has(tool.name) ? { destructiveHint: true } : {}),
283
- ...(approveToolSet.has(tool.name) ? { confirmationHint: true } : {}),
284
- openWorldHint: true,
285
- };
286
- // Inject _confirmed optional parameter for approve-policy tools
287
- if (approveToolSet.has(tool.name) && modified.inputSchema?.properties) {
288
- modified.inputSchema = {
289
- ...modified.inputSchema,
290
- properties: {
291
- ...modified.inputSchema.properties,
292
- _confirmed: {
293
- type: "boolean",
294
- description: "Set to true to confirm execution of this approval-required tool.",
295
- },
296
- },
297
- };
298
- }
299
- if (modified.inputSchema && typeof modified.inputSchema === "object") {
300
- const properties = modified.inputSchema.properties && typeof modified.inputSchema.properties === "object"
301
- ? { ...modified.inputSchema.properties }
302
- : {};
303
- if (!(JMESPATH_TOOL_ARGUMENT in properties)) {
304
- properties[JMESPATH_TOOL_ARGUMENT] = {
305
- type: "string",
306
- description: JMESPATH_TOOL_ARGUMENT_DESCRIPTION,
307
- };
308
- modified.inputSchema = { ...modified.inputSchema, properties };
309
- }
310
- }
311
- return modified;
312
- });
313
- // <<< END: Remove $schema for Gemini compatibility >>>
314
262
  return {
315
- tools, // return tool list with $schema removed
263
+ tools: getListedTools(filteredTools, listedToolsCache.revision),
316
264
  };
317
265
  });
318
266
  mcpServer.server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -331,9 +279,7 @@ function createServer() {
331
279
  (toolName === "download_release_asset" && !IS_REMOTE);
332
280
  const filtered = applyJmespathToToolResult(result, jmespathExpression, {
333
281
  ...completionOptions,
334
- maskValue: maskingEngine
335
- ? (value) => maskingEngine.maskValue(value)
336
- : undefined,
282
+ maskValue: maskingEngine ? (value) => maskingEngine.maskValue(value) : undefined,
337
283
  });
338
284
  const masked = maskingEngine
339
285
  ? maskingEngine.maskToolResult(filtered, {
@@ -342,7 +288,7 @@ function createServer() {
342
288
  : filtered;
343
289
  return compactMcpToolResult({
344
290
  result: masked,
345
- enabled: GITLAB_MCP_COMPACT_RESULTS,
291
+ enabled: shouldCompactToolResult(toolName, GITLAB_MCP_COMPACT_RESULTS, GITLAB_MCP_COMPACT_TOOL_NAMES),
346
292
  maxChars: GITLAB_MCP_COMPACT_RESULT_CHARS,
347
293
  toolName,
348
294
  args: readOwnRecord(request.params.arguments),
@@ -419,25 +365,34 @@ function createServer() {
419
365
  if (isToolAllowedByPermissionMode(toolName) &&
420
366
  !hiddenAndNotDenied &&
421
367
  !filteredTools.some(t => t.name === toolName)) {
422
- throw new Error(`Tool "${toolName}" is not available on this server`);
368
+ throw new Error(buildToolUnavailableMessage(toolName));
423
369
  }
424
370
  // Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
425
371
  if (toolName === "discover_tools") {
426
372
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
427
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
+ });
428
380
  if (!category) {
429
- // List available categories with activation status
430
- const categories = TOOLSET_DEFINITIONS.map(def => ({
431
- id: def.id,
432
- toolCount: def.tools.size,
433
- active: [...def.tools].some(t => currentToolNames.has(t)),
434
- isDefault: def.isDefault,
435
- }));
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);
436
386
  return logCompletion({
437
- content: [{
387
+ content: [
388
+ {
438
389
  type: "text",
439
- text: JSON.stringify({ categories, hint: "Call discover_tools with a category name to activate it" }),
440
- }],
390
+ text: JSON.stringify({
391
+ categories,
392
+ hint: "Call discover_tools with a category name to activate it",
393
+ }),
394
+ },
395
+ ],
441
396
  });
442
397
  }
443
398
  if (!ALL_TOOLSET_IDS.has(category)) {
@@ -458,8 +413,8 @@ function createServer() {
458
413
  isError: true,
459
414
  });
460
415
  }
461
- // Check if already fully active
462
- 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);
463
418
  if (alreadyActive) {
464
419
  return logCompletion({
465
420
  content: [
@@ -477,11 +432,7 @@ function createServer() {
477
432
  continue;
478
433
  if (currentToolNames.has(tool.name))
479
434
  continue;
480
- if (!isToolAllowedByPermissionMode(tool.name))
481
- continue;
482
- if (GITLAB_DENIED_TOOLS_REGEX?.test(tool.name))
483
- continue;
484
- if (hiddenToolSet.has(tool.name))
435
+ if (isNotActivatable(tool.name))
485
436
  continue;
486
437
  newTools.push(tool);
487
438
  }
@@ -496,6 +447,7 @@ function createServer() {
496
447
  });
497
448
  }
498
449
  filteredTools.push(...newTools);
450
+ listedToolsCache.revision += 1;
499
451
  // Notify client that tool list has changed
500
452
  try {
501
453
  await mcpServer.server.sendToolListChanged();
@@ -506,14 +458,16 @@ function createServer() {
506
458
  const addedNames = newTools.map(t => t.name);
507
459
  logger.info({ event: "toolset_activated", category, toolCount: addedNames.length }, `Activated toolset: ${category} (+${addedNames.length} tools)`);
508
460
  return logCompletion({
509
- content: [{
461
+ content: [
462
+ {
510
463
  type: "text",
511
464
  text: JSON.stringify({
512
465
  activated: category,
513
466
  addedTools: addedNames,
514
467
  totalTools: filteredTools.length,
515
468
  }),
516
- }],
469
+ },
470
+ ],
517
471
  });
518
472
  }
519
473
  // Check approve policy: tool is exposed but requires explicit confirmation
@@ -548,9 +502,9 @@ function createServer() {
548
502
  return logCompletion(result);
549
503
  }
550
504
  catch (error) {
551
- const isGitLabApiError = error instanceof GitLabApiError;
505
+ const isRecoverableToolError = error instanceof GitLabApiError || error instanceof ToolInputError;
552
506
  const safeError = logError(error);
553
- if (isGitLabApiError) {
507
+ if (isRecoverableToolError) {
554
508
  return {
555
509
  content: [
556
510
  {
@@ -570,7 +524,10 @@ function createServer() {
570
524
  * Validate configuration at startup
571
525
  */
572
526
  function isLoopbackBindHost(host) {
573
- const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
527
+ const normalized = host
528
+ .trim()
529
+ .toLowerCase()
530
+ .replace(/^\[|\]$/g, "");
574
531
  const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
575
532
  return (normalized === "localhost" ||
576
533
  isIpv4Loopback ||
@@ -578,7 +535,10 @@ function isLoopbackBindHost(host) {
578
535
  normalized === "0:0:0:0:0:0:0:1");
579
536
  }
580
537
  function formatHostWithPort(host, port) {
581
- const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
538
+ const normalized = host
539
+ .trim()
540
+ .toLowerCase()
541
+ .replace(/^\[|\]$/g, "");
582
542
  if (!normalized || normalized === "0.0.0.0" || normalized === "::")
583
543
  return null;
584
544
  if (normalized.includes(":"))
@@ -929,6 +889,12 @@ const hiddenToolSet = new Set((GITLAB_TOOL_POLICY_HIDDEN_RAW || "")
929
889
  logger.warn({ event: "unknown_approve_tool", name }, `GITLAB_TOOL_POLICY_APPROVE contains unknown tool: "${name}"`);
930
890
  }
931
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
+ }
932
898
  for (const name of hiddenToolSet) {
933
899
  if (!knownToolNames.has(name)) {
934
900
  logger.warn({ event: "unknown_hidden_tool", name }, `GITLAB_TOOL_POLICY_HIDDEN contains unknown tool: "${name}"`);
@@ -1228,6 +1194,13 @@ const downloadRedirectOptions = () => ({
1228
1194
  const enabledToolsets = parseEnabledToolsets(GITLAB_TOOLSETS_RAW);
1229
1195
  const individuallyEnabledTools = parseIndividualTools(GITLAB_TOOLS_RAW);
1230
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
+ }
1231
1204
  // Warn about potentially confusing configuration
1232
1205
  if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
1233
1206
  logger.warn("GITLAB_TOOLSETS is set alongside legacy flags (USE_PIPELINE, USE_MILESTONE, USE_GITLAB_WIKI). " +
@@ -1347,6 +1320,13 @@ class GitLabApiError extends Error {
1347
1320
  this.name = "GitLabApiError";
1348
1321
  }
1349
1322
  }
1323
+ class ToolInputError extends Error {
1324
+ constructor(message) {
1325
+ super(message);
1326
+ this.name = "ToolInputError";
1327
+ }
1328
+ }
1329
+ const MERGE_REQUEST_IDENTIFIER_REQUIRED = "Either merge_request_iid or source_branch must be provided";
1350
1330
  /**
1351
1331
  * Utility function for handling GitLab API errors
1352
1332
  * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
@@ -1373,6 +1353,39 @@ async function handleGitLabError(response) {
1373
1353
  }
1374
1354
  }
1375
1355
  }
1356
+ const USER_API_INSUFFICIENT_SCOPE_MESSAGE = "GitLab API error: 403 Forbidden on GET /api/v4/user (insufficient scope). The token was accepted, but read_user (classic PAT) or User API read (fine-grained User:Read) is required for whoami, my_issues, and other current-user tools.";
1357
+ const USER_API_HEALTH_WARNING = "GET /api/v4/user returned 403 (insufficient scope). The token was accepted and other tools may still work. Grant read_user (classic) or User:Read (fine-grained) for whoami, my_issues, and version metadata.";
1358
+ function isGitLabUserApiRateLimitBody(errorBody) {
1359
+ return errorBody.includes("User API Key Rate limit exceeded");
1360
+ }
1361
+ function isGitLabInsufficientUserScopeBody(errorBody) {
1362
+ // Fine-grained PATs use insufficient_granular_scope, which does not contain insufficient_scope.
1363
+ return (errorBody.includes("insufficient_granular_scope") || errorBody.includes("insufficient_scope"));
1364
+ }
1365
+ async function isInsufficientUserScopeForbiddenAsync(response) {
1366
+ if (response.status !== 403) {
1367
+ return false;
1368
+ }
1369
+ const errorBody = await response.text();
1370
+ return isGitLabInsufficientUserScopeBody(errorBody);
1371
+ }
1372
+ async function throwUserApiScopeErrorUnlessRateLimitedAsync(response) {
1373
+ const errorBody = await response.clone().text();
1374
+ // Rate-limit and other 403s keep the upstream body via handleGitLabError.
1375
+ if (isGitLabUserApiRateLimitBody(errorBody) || !isGitLabInsufficientUserScopeBody(errorBody)) {
1376
+ return;
1377
+ }
1378
+ throw new GitLabApiError(USER_API_INSUFFICIENT_SCOPE_MESSAGE);
1379
+ }
1380
+ async function authenticateWithJobTokenFallbackAsync(response) {
1381
+ if (response.ok ||
1382
+ (response.status !== 401 && response.status !== 403) ||
1383
+ !(GITLAB_JOB_TOKEN || usesJobTokenHeader())) {
1384
+ return false;
1385
+ }
1386
+ const jobResponse = await fetch(`${getEffectiveApiUrl()}/job`, getFetchConfig());
1387
+ return jobResponse.ok;
1388
+ }
1376
1389
  /**
1377
1390
  * @param {string} projectId - The project ID parameter passed to the function
1378
1391
  * @returns {string} The project ID to use for the API call
@@ -1812,7 +1825,9 @@ async function resolveNamesToIds(projectPath, namespaceKind, labelNames, usernam
1812
1825
  // One alias per label — exact title match via the `title` argument, includes ancestor
1813
1826
  // group labels, single round trip with no pagination needed.
1814
1827
  const varDefs = labelNames.map((_, i) => `$l${i}: String!`).join(", ");
1815
- const aliases = labelNames.map((_, i) => `l${i}: labels(title: $l${i}, includeAncestorGroups: true, first: 1) { nodes { id } }`).join(" ");
1828
+ const aliases = labelNames
1829
+ .map((_, i) => `l${i}: labels(title: $l${i}, includeAncestorGroups: true, first: 1) { nodes { id } }`)
1830
+ .join(" ");
1816
1831
  const rootField = namespaceKind === "group" ? "group" : "project";
1817
1832
  const data = await executeGraphQL(`query($path: ID!, $usernames: [String!]!${varDefs ? `, ${varDefs}` : ""}) {
1818
1833
  ${rootField}(fullPath: $path) { ${aliases || "__typename"} }
@@ -3407,7 +3422,9 @@ async function updateIssueNote(projectId, issueIid, discussionId, noteId, body,
3407
3422
  async function createIssueNote(projectId, issueIid, discussionId, body, createdAt) {
3408
3423
  projectId = decodeURIComponent(projectId); // Decode project ID
3409
3424
  const basePath = `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/issues/${encodeGitLabPathSegment(issueIid)}`;
3410
- const url = new URL(discussionId ? `${basePath}/discussions/${encodeGitLabPathSegment(discussionId)}/notes` : `${basePath}/notes`);
3425
+ const url = new URL(discussionId
3426
+ ? `${basePath}/discussions/${encodeGitLabPathSegment(discussionId)}/notes`
3427
+ : `${basePath}/notes`);
3411
3428
  const payload = { body };
3412
3429
  if (createdAt) {
3413
3430
  payload.created_at = createdAt;
@@ -3816,7 +3833,7 @@ async function getMergeRequest(projectId, mergeRequestIid, branchName) {
3816
3833
  url = new URL(`${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(getEffectiveProjectId(projectId))}/merge_requests?source_branch=${encodeURIComponent(branchName)}`);
3817
3834
  }
3818
3835
  else {
3819
- throw new Error("Either mergeRequestIid or branchName must be provided");
3836
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
3820
3837
  }
3821
3838
  const response = await fetch(url.toString(), {
3822
3839
  ...getFetchConfig(),
@@ -4050,7 +4067,7 @@ async function buildMergeRequestDeploymentSummary(projectId, mergeRequest) {
4050
4067
  async function getMergeRequestDiffs(projectId, mergeRequestIid, branchName, view) {
4051
4068
  projectId = decodeURIComponent(projectId); // Decode project ID
4052
4069
  if (!mergeRequestIid && !branchName) {
4053
- throw new Error("Either mergeRequestIid or branchName must be provided");
4070
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4054
4071
  }
4055
4072
  if (branchName && !mergeRequestIid) {
4056
4073
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4080,7 +4097,7 @@ async function getMergeRequestDiffs(projectId, mergeRequestIid, branchName, view
4080
4097
  async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, page, perPage, unidiff) {
4081
4098
  projectId = decodeURIComponent(projectId); // Decode project ID
4082
4099
  if (!mergeRequestIid && !branchName) {
4083
- throw new Error("Either mergeRequestIid or branchName must be provided");
4100
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4084
4101
  }
4085
4102
  if (branchName && !mergeRequestIid) {
4086
4103
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4116,7 +4133,7 @@ async function listMergeRequestDiffs(projectId, mergeRequestIid, branchName, pag
4116
4133
  async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchName, excludedFilePatterns) {
4117
4134
  projectId = decodeURIComponent(projectId);
4118
4135
  if (!mergeRequestIid && !branchName) {
4119
- throw new Error("Either mergeRequestIid or branchName must be provided");
4136
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4120
4137
  }
4121
4138
  if (branchName && !mergeRequestIid) {
4122
4139
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4150,7 +4167,7 @@ async function listMergeRequestChangedFiles(projectId, mergeRequestIid, branchNa
4150
4167
  async function getMergeRequestFileDiff(projectId, filePaths, mergeRequestIid, branchName, unidiff) {
4151
4168
  projectId = decodeURIComponent(projectId);
4152
4169
  if (!mergeRequestIid && !branchName) {
4153
- throw new Error("Either mergeRequestIid or branchName must be provided");
4170
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4154
4171
  }
4155
4172
  if (branchName && !mergeRequestIid) {
4156
4173
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -4235,7 +4252,7 @@ async function getBranchDiffs(projectId, from, to, straight) {
4235
4252
  async function updateMergeRequest(projectId, options, mergeRequestIid, branchName) {
4236
4253
  projectId = decodeURIComponent(projectId); // Decode project ID
4237
4254
  if (!mergeRequestIid && !branchName) {
4238
- throw new Error("Either mergeRequestIid or branchName must be provided");
4255
+ throw new ToolInputError(MERGE_REQUEST_IDENTIFIER_REQUIRED);
4239
4256
  }
4240
4257
  if (branchName && !mergeRequestIid) {
4241
4258
  const mergeRequest = await getMergeRequest(projectId, undefined, branchName);
@@ -5380,7 +5397,8 @@ async function deploymentRequest(projectId, path = "", method = "GET", body, que
5380
5397
  url.searchParams.set(key, String(value));
5381
5398
  }
5382
5399
  const response = await fetch(url.toString(), {
5383
- ...getFetchConfig(), method,
5400
+ ...getFetchConfig(),
5401
+ method,
5384
5402
  body: body === undefined ? undefined : JSON.stringify(body),
5385
5403
  });
5386
5404
  await handleGitLabError(response);
@@ -5430,7 +5448,11 @@ async function environmentRequest(projectId, path, method, body, query) {
5430
5448
  for (const [key, value] of Object.entries(query ?? {}))
5431
5449
  if (value !== undefined)
5432
5450
  url.searchParams.set(key, String(value));
5433
- const response = await fetch(url.toString(), { ...getFetchConfig(), method, body: body === undefined ? undefined : JSON.stringify(body) });
5451
+ const response = await fetch(url.toString(), {
5452
+ ...getFetchConfig(),
5453
+ method,
5454
+ body: body === undefined ? undefined : JSON.stringify(body),
5455
+ });
5434
5456
  await handleGitLabError(response);
5435
5457
  return response.status === 204 ? { success: true } : response.json();
5436
5458
  }
@@ -5450,7 +5472,11 @@ async function triggerPipeline(projectId, token, ref, variables, inputs) {
5450
5472
  body.variables = variables;
5451
5473
  if (inputs)
5452
5474
  body.inputs = inputs;
5453
- const response = await fetch(url.toString(), { ...getFetchConfig(), method: "POST", body: Object.keys(body).length ? JSON.stringify(body) : undefined });
5475
+ const response = await fetch(url.toString(), {
5476
+ ...getFetchConfig(),
5477
+ method: "POST",
5478
+ body: Object.keys(body).length ? JSON.stringify(body) : undefined,
5479
+ });
5454
5480
  await handleGitLabError(response);
5455
5481
  return response.json();
5456
5482
  }
@@ -6607,6 +6633,9 @@ async function getCurrentUser() {
6607
6633
  }
6608
6634
  }
6609
6635
  }
6636
+ if (response.status === 403) {
6637
+ await throwUserApiScopeErrorUnlessRateLimitedAsync(response);
6638
+ }
6610
6639
  await handleGitLabError(response);
6611
6640
  throw new Error(`GitLab API error: ${response.status} ${response.statusText}`);
6612
6641
  }
@@ -8021,8 +8050,7 @@ async function handleToolCall(params) {
8021
8050
  }
8022
8051
  case "push_files": {
8023
8052
  const args = PushFilesSchema.parse(params.arguments);
8024
- if (GITLAB_PERMISSION_MODE === "modify" &&
8025
- fileOperationsIncludeDeleteOrMove(args.files)) {
8053
+ if (GITLAB_PERMISSION_MODE === "modify" && fileOperationsIncludeDeleteOrMove(args.files)) {
8026
8054
  throw new Error("push_files does not allow delete or move actions in modify mode");
8027
8055
  }
8028
8056
  const result = await createCommit(args.project_id, args.commit_message, args.branch, args.files.map(f => ({
@@ -8527,6 +8555,9 @@ async function handleToolCall(params) {
8527
8555
  const response = await fetch(url.toString(), {
8528
8556
  ...getFetchConfig(),
8529
8557
  });
8558
+ if (response.status === 403) {
8559
+ await throwUserApiScopeErrorUnlessRateLimitedAsync(response);
8560
+ }
8530
8561
  await handleGitLabError(response);
8531
8562
  const data = await response.json();
8532
8563
  const user = GitLabCurrentUserSchema.parse(data);
@@ -8893,7 +8924,14 @@ async function handleToolCall(params) {
8893
8924
  const args = DeleteWorkItemEmojiReactionSchema.parse(params.arguments);
8894
8925
  const { workItemGID } = await resolveWorkItemGID(args.project_id, args.iid);
8895
8926
  const result = await removeGraphQLAwardEmoji(workItemGID, args.name);
8896
- return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "success", message: "Work item emoji reaction removed" }) }] };
8927
+ return {
8928
+ content: [
8929
+ {
8930
+ type: "text",
8931
+ text: JSON.stringify(result ?? { status: "success", message: "Work item emoji reaction removed" }),
8932
+ },
8933
+ ],
8934
+ };
8897
8935
  }
8898
8936
  case "create_work_item_note_emoji_reaction": {
8899
8937
  const args = CreateWorkItemNoteEmojiReactionSchema.parse(params.arguments);
@@ -8903,7 +8941,14 @@ async function handleToolCall(params) {
8903
8941
  case "delete_work_item_note_emoji_reaction": {
8904
8942
  const args = DeleteWorkItemNoteEmojiReactionSchema.parse(params.arguments);
8905
8943
  const result = await removeGraphQLAwardEmoji(args.note_id, args.name);
8906
- return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "success", message: "Work item note emoji reaction removed" }) }] };
8944
+ return {
8945
+ content: [
8946
+ {
8947
+ type: "text",
8948
+ text: JSON.stringify(result ?? { status: "success", message: "Work item note emoji reaction removed" }),
8949
+ },
8950
+ ],
8951
+ };
8907
8952
  }
8908
8953
  case "get_timeline_events": {
8909
8954
  const args = GetTimelineEventsSchema.parse(params.arguments);
@@ -9106,23 +9151,55 @@ async function handleToolCall(params) {
9106
9151
  }
9107
9152
  case "get_pipeline_variables": {
9108
9153
  const { project_id, pipeline_id, page, per_page } = GetPipelineVariablesSchema.parse(params.arguments);
9109
- return { content: [{ type: "text", text: JSON.stringify(await getPipelineVariables(project_id, pipeline_id, page, per_page)) }] };
9154
+ return {
9155
+ content: [
9156
+ {
9157
+ type: "text",
9158
+ text: JSON.stringify(await getPipelineVariables(project_id, pipeline_id, page, per_page)),
9159
+ },
9160
+ ],
9161
+ };
9110
9162
  }
9111
9163
  case "get_pipeline_test_report": {
9112
9164
  const { project_id, pipeline_id, page, per_page } = PipelineReportSchema.parse(params.arguments);
9113
- return { content: [{ type: "text", text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, false, page, per_page)) }] };
9165
+ return {
9166
+ content: [
9167
+ {
9168
+ type: "text",
9169
+ text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, false, page, per_page)),
9170
+ },
9171
+ ],
9172
+ };
9114
9173
  }
9115
9174
  case "get_pipeline_test_report_summary": {
9116
9175
  const { project_id, pipeline_id, page, per_page } = PipelineReportSchema.parse(params.arguments);
9117
- return { content: [{ type: "text", text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, true, page, per_page)) }] };
9176
+ return {
9177
+ content: [
9178
+ {
9179
+ type: "text",
9180
+ text: JSON.stringify(await getPipelineReport(project_id, pipeline_id, true, page, per_page)),
9181
+ },
9182
+ ],
9183
+ };
9118
9184
  }
9119
9185
  case "delete_pipeline": {
9120
9186
  const { project_id, pipeline_id } = DeletePipelineSchema.parse(params.arguments);
9121
- return { content: [{ type: "text", text: JSON.stringify(await deletePipeline(project_id, pipeline_id)) }] };
9187
+ return {
9188
+ content: [
9189
+ { type: "text", text: JSON.stringify(await deletePipeline(project_id, pipeline_id)) },
9190
+ ],
9191
+ };
9122
9192
  }
9123
9193
  case "update_pipeline_metadata": {
9124
9194
  const { project_id, pipeline_id, name } = UpdatePipelineMetadataSchema.parse(params.arguments);
9125
- return { content: [{ type: "text", text: JSON.stringify(await updatePipelineMetadata(project_id, pipeline_id, name)) }] };
9195
+ return {
9196
+ content: [
9197
+ {
9198
+ type: "text",
9199
+ text: JSON.stringify(await updatePipelineMetadata(project_id, pipeline_id, name)),
9200
+ },
9201
+ ],
9202
+ };
9126
9203
  }
9127
9204
  case "list_deployments": {
9128
9205
  const args = ListDeploymentsSchema.parse(params.arguments);
@@ -9141,23 +9218,58 @@ async function handleToolCall(params) {
9141
9218
  }
9142
9219
  case "create_deployment": {
9143
9220
  const { project_id, ...body } = CreateDeploymentSchema.parse(params.arguments);
9144
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, "", "POST", body)) }] };
9221
+ return {
9222
+ content: [
9223
+ {
9224
+ type: "text",
9225
+ text: JSON.stringify(await deploymentRequest(project_id, "", "POST", body)),
9226
+ },
9227
+ ],
9228
+ };
9145
9229
  }
9146
9230
  case "update_deployment": {
9147
9231
  const { project_id, deployment_id, ...body } = UpdateDeploymentSchema.parse(params.arguments);
9148
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "PUT", body)) }] };
9232
+ return {
9233
+ content: [
9234
+ {
9235
+ type: "text",
9236
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "PUT", body)),
9237
+ },
9238
+ ],
9239
+ };
9149
9240
  }
9150
9241
  case "delete_deployment": {
9151
9242
  const { project_id, deployment_id } = GetDeploymentSchema.parse(params.arguments);
9152
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "DELETE")) }] };
9243
+ return {
9244
+ content: [
9245
+ {
9246
+ type: "text",
9247
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}`, "DELETE")),
9248
+ },
9249
+ ],
9250
+ };
9153
9251
  }
9154
9252
  case "list_deployment_merge_requests": {
9155
9253
  const { project_id, deployment_id, ...query } = ListDeploymentMergeRequestsSchema.parse(params.arguments);
9156
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/merge_requests`, "GET", undefined, query)) }] };
9254
+ return {
9255
+ content: [
9256
+ {
9257
+ type: "text",
9258
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/merge_requests`, "GET", undefined, query)),
9259
+ },
9260
+ ],
9261
+ };
9157
9262
  }
9158
9263
  case "approve_deployment": {
9159
9264
  const { project_id, deployment_id, ...body } = DeploymentApprovalSchema.parse(params.arguments);
9160
- return { content: [{ type: "text", text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/approval`, "POST", body)) }] };
9265
+ return {
9266
+ content: [
9267
+ {
9268
+ type: "text",
9269
+ text: JSON.stringify(await deploymentRequest(project_id, `/${encodeGitLabPathSegment(deployment_id)}/approval`, "POST", body)),
9270
+ },
9271
+ ],
9272
+ };
9161
9273
  }
9162
9274
  case "list_environments": {
9163
9275
  const args = ListEnvironmentsSchema.parse(params.arguments);
@@ -9176,47 +9288,124 @@ async function handleToolCall(params) {
9176
9288
  }
9177
9289
  case "update_environment": {
9178
9290
  const { project_id, environment_id, ...body } = UpdateEnvironmentSchema.parse(params.arguments);
9179
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "PUT", body)) }] };
9291
+ return {
9292
+ content: [
9293
+ {
9294
+ type: "text",
9295
+ text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "PUT", body)),
9296
+ },
9297
+ ],
9298
+ };
9180
9299
  }
9181
9300
  case "delete_environment": {
9182
9301
  const { project_id, environment_id } = GetEnvironmentSchema.parse(params.arguments);
9183
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "DELETE")) }] };
9302
+ return {
9303
+ content: [
9304
+ {
9305
+ type: "text",
9306
+ text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}`, "DELETE")),
9307
+ },
9308
+ ],
9309
+ };
9184
9310
  }
9185
9311
  case "stop_environment": {
9186
9312
  const { project_id, environment_id, force } = StopEnvironmentSchema.parse(params.arguments);
9187
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}/stop`, "POST", undefined, { force })) }] };
9313
+ return {
9314
+ content: [
9315
+ {
9316
+ type: "text",
9317
+ text: JSON.stringify(await environmentRequest(project_id, `/${encodeGitLabPathSegment(environment_id)}/stop`, "POST", undefined, { force })),
9318
+ },
9319
+ ],
9320
+ };
9188
9321
  }
9189
9322
  case "stop_stale_environments": {
9190
9323
  const { project_id, before } = StopStaleEnvironmentsSchema.parse(params.arguments);
9191
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, "/stop_stale", "POST", undefined, { before })) }] };
9324
+ return {
9325
+ content: [
9326
+ {
9327
+ type: "text",
9328
+ text: JSON.stringify(await environmentRequest(project_id, "/stop_stale", "POST", undefined, { before })),
9329
+ },
9330
+ ],
9331
+ };
9192
9332
  }
9193
9333
  case "delete_review_app_environments": {
9194
9334
  const { project_id, ...query } = DeleteReviewAppEnvironmentsSchema.parse(params.arguments);
9195
- return { content: [{ type: "text", text: JSON.stringify(await environmentRequest(project_id, "/review_apps", "DELETE", undefined, query)) }] };
9335
+ return {
9336
+ content: [
9337
+ {
9338
+ type: "text",
9339
+ text: JSON.stringify(await environmentRequest(project_id, "/review_apps", "DELETE", undefined, query)),
9340
+ },
9341
+ ],
9342
+ };
9196
9343
  }
9197
9344
  case "list_pipeline_triggers": {
9198
9345
  const { project_id } = ListPipelineTriggersSchema.parse(params.arguments);
9199
- return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id))) }] };
9346
+ return {
9347
+ content: [
9348
+ {
9349
+ type: "text",
9350
+ text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id))),
9351
+ },
9352
+ ],
9353
+ };
9200
9354
  }
9201
9355
  case "get_pipeline_trigger": {
9202
9356
  const { project_id, trigger_id } = PipelineTriggerIdSchema.parse(params.arguments);
9203
- return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`))) }] };
9357
+ return {
9358
+ content: [
9359
+ {
9360
+ type: "text",
9361
+ text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`))),
9362
+ },
9363
+ ],
9364
+ };
9204
9365
  }
9205
9366
  case "create_pipeline_trigger": {
9206
9367
  const { project_id, ...body } = CreatePipelineTriggerSchema.parse(params.arguments);
9207
- return { content: [{ type: "text", text: JSON.stringify(await pipelineTriggerRequest(project_id, "", "POST", body)) }] };
9368
+ return {
9369
+ content: [
9370
+ {
9371
+ type: "text",
9372
+ text: JSON.stringify(await pipelineTriggerRequest(project_id, "", "POST", body)),
9373
+ },
9374
+ ],
9375
+ };
9208
9376
  }
9209
9377
  case "update_pipeline_trigger": {
9210
9378
  const { project_id, trigger_id, ...body } = UpdatePipelineTriggerSchema.parse(params.arguments);
9211
- return { content: [{ type: "text", text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "PUT", body))) }] };
9379
+ return {
9380
+ content: [
9381
+ {
9382
+ type: "text",
9383
+ text: JSON.stringify(redactSensitiveGitLabFields(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "PUT", body))),
9384
+ },
9385
+ ],
9386
+ };
9212
9387
  }
9213
9388
  case "delete_pipeline_trigger": {
9214
9389
  const { project_id, trigger_id } = PipelineTriggerIdSchema.parse(params.arguments);
9215
- return { content: [{ type: "text", text: JSON.stringify(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "DELETE")) }] };
9390
+ return {
9391
+ content: [
9392
+ {
9393
+ type: "text",
9394
+ text: JSON.stringify(await pipelineTriggerRequest(project_id, `/${encodeGitLabPathSegment(trigger_id)}`, "DELETE")),
9395
+ },
9396
+ ],
9397
+ };
9216
9398
  }
9217
9399
  case "trigger_pipeline": {
9218
9400
  const { project_id, token, ref, variables, inputs } = TriggerPipelineSchema.parse(params.arguments);
9219
- return { content: [{ type: "text", text: JSON.stringify(await triggerPipeline(project_id, token, ref, variables, inputs)) }] };
9401
+ return {
9402
+ content: [
9403
+ {
9404
+ type: "text",
9405
+ text: JSON.stringify(await triggerPipeline(project_id, token, ref, variables, inputs)),
9406
+ },
9407
+ ],
9408
+ };
9220
9409
  }
9221
9410
  case "list_pipeline_jobs": {
9222
9411
  const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse(params.arguments);
@@ -9583,7 +9772,7 @@ async function handleToolCall(params) {
9583
9772
  };
9584
9773
  }
9585
9774
  case "play_pipeline_jobs": {
9586
- const { project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds } = PlayPipelineJobsSchema.parse(params.arguments);
9775
+ const { project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds, } = PlayPipelineJobsSchema.parse(params.arguments);
9587
9776
  const jobs = await playPipelineJobs(project_id, job_ids, job_variables_attributes, timeout_seconds, poll_interval_seconds);
9588
9777
  return { content: [{ type: "text", text: JSON.stringify(jobs) }] };
9589
9778
  }
@@ -9613,7 +9802,11 @@ async function handleToolCall(params) {
9613
9802
  }
9614
9803
  case "erase_pipeline_job": {
9615
9804
  const { project_id, job_id } = ErasePipelineJobSchema.parse(params.arguments);
9616
- return { content: [{ type: "text", text: JSON.stringify(await erasePipelineJob(project_id, job_id)) }] };
9805
+ return {
9806
+ content: [
9807
+ { type: "text", text: JSON.stringify(await erasePipelineJob(project_id, job_id)) },
9808
+ ],
9809
+ };
9617
9810
  }
9618
9811
  case "wait_for_pipeline": {
9619
9812
  const { project_id, pipeline_id, timeout_seconds, poll_interval_seconds } = WaitForPipelineSchema.parse(params.arguments);
@@ -9645,7 +9838,15 @@ async function handleToolCall(params) {
9645
9838
  }
9646
9839
  const downloadUrl = buildDownloadUrl("job-artifacts", { project_id, job_id });
9647
9840
  return {
9648
- content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: `artifacts_job_${job_id}.zip` }) }],
9841
+ content: [
9842
+ {
9843
+ type: "text",
9844
+ text: JSON.stringify({
9845
+ download_url: downloadUrl,
9846
+ filename: `artifacts_job_${job_id}.zip`,
9847
+ }),
9848
+ },
9849
+ ],
9649
9850
  };
9650
9851
  }
9651
9852
  const filePath = await downloadJobArtifacts(project_id, job_id, local_path);
@@ -10148,7 +10349,12 @@ async function handleToolCall(params) {
10148
10349
  filename: args.filename,
10149
10350
  });
10150
10351
  return {
10151
- content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: args.filename }) }],
10352
+ content: [
10353
+ {
10354
+ type: "text",
10355
+ text: JSON.stringify({ download_url: downloadUrl, filename: args.filename }),
10356
+ },
10357
+ ],
10152
10358
  };
10153
10359
  }
10154
10360
  const result = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
@@ -10254,7 +10460,15 @@ async function handleToolCall(params) {
10254
10460
  direct_asset_path: args.direct_asset_path,
10255
10461
  });
10256
10462
  return {
10257
- content: [{ type: "text", text: JSON.stringify({ download_url: downloadUrl, filename: args.direct_asset_path.split("/").pop() || args.direct_asset_path }) }],
10463
+ content: [
10464
+ {
10465
+ type: "text",
10466
+ text: JSON.stringify({
10467
+ download_url: downloadUrl,
10468
+ filename: args.direct_asset_path.split("/").pop() || args.direct_asset_path,
10469
+ }),
10470
+ },
10471
+ ],
10258
10472
  };
10259
10473
  }
10260
10474
  const assetContent = await downloadReleaseAsset(args.project_id, args.tag_name, args.direct_asset_path);
@@ -10437,17 +10651,13 @@ async function handleToolCall(params) {
10437
10651
  HealthCheckSchema.parse(params.arguments ?? {});
10438
10652
  const url = new URL(`${getEffectiveApiUrl()}/user`);
10439
10653
  const response = await fetch(url.toString(), getFetchConfig());
10440
- let authenticated = response.ok;
10441
- if (!authenticated &&
10442
- (response.status === 401 || response.status === 403) &&
10443
- (GITLAB_JOB_TOKEN || usesJobTokenHeader())) {
10444
- const jobUrl = new URL(`${getEffectiveApiUrl()}/job`);
10445
- const jobResponse = await fetch(jobUrl.toString(), getFetchConfig());
10446
- authenticated = jobResponse.ok;
10447
- }
10448
- const versionMetadata = authenticated
10449
- ? await fetchGitLabVersionMetadata()
10450
- : null;
10654
+ const jobAuthenticated = await authenticateWithJobTokenFallbackAsync(response);
10655
+ // insufficient_scope and insufficient_granular_scope mean the token was accepted
10656
+ // but User API scope is missing.
10657
+ // Rate-limit and authentication-ban 403s stay unauthenticated.
10658
+ const insufficientUserScope = !jobAuthenticated && (await isInsufficientUserScopeForbiddenAsync(response));
10659
+ const authenticated = response.ok || jobAuthenticated || insufficientUserScope;
10660
+ const versionMetadata = authenticated && !insufficientUserScope ? await fetchGitLabVersionMetadata() : null;
10451
10661
  return {
10452
10662
  content: [
10453
10663
  {
@@ -10457,6 +10667,7 @@ async function handleToolCall(params) {
10457
10667
  authenticated,
10458
10668
  gitlab_url: getEffectiveApiUrl(),
10459
10669
  mcp_server_version: SERVER_VERSION,
10670
+ ...(insufficientUserScope ? { user_api_warning: USER_API_HEALTH_WARNING } : {}),
10460
10671
  ...(versionMetadata ?? {}),
10461
10672
  }),
10462
10673
  },
@@ -10510,7 +10721,9 @@ async function handleToolCall(params) {
10510
10721
  });
10511
10722
  await handleGitLabError(response);
10512
10723
  return {
10513
- content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }) }],
10724
+ content: [
10725
+ { type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }) },
10726
+ ],
10514
10727
  };
10515
10728
  }
10516
10729
  case "list_protected_branches": {
@@ -10585,7 +10798,12 @@ async function handleToolCall(params) {
10585
10798
  });
10586
10799
  await handleGitLabError(response);
10587
10800
  return {
10588
- content: [{ type: "text", text: JSON.stringify({ status: "unprotected", branch: args.branch_name }) }],
10801
+ content: [
10802
+ {
10803
+ type: "text",
10804
+ text: JSON.stringify({ status: "unprotected", branch: args.branch_name }),
10805
+ },
10806
+ ],
10589
10807
  };
10590
10808
  }
10591
10809
  case "update_default_branch": {
@@ -10601,7 +10819,16 @@ async function handleToolCall(params) {
10601
10819
  await handleGitLabError(response);
10602
10820
  const data = await response.json();
10603
10821
  return {
10604
- content: [{ type: "text", text: JSON.stringify({ status: "updated", default_branch: args.default_branch, project: data }) }],
10822
+ content: [
10823
+ {
10824
+ type: "text",
10825
+ text: JSON.stringify({
10826
+ status: "updated",
10827
+ default_branch: args.default_branch,
10828
+ project: data,
10829
+ }),
10830
+ },
10831
+ ],
10605
10832
  };
10606
10833
  }
10607
10834
  default:
@@ -10656,6 +10883,7 @@ function buildDownloadProxyDeps() {
10656
10883
  * Start server with traditional SSE transport
10657
10884
  */
10658
10885
  async function startSSEServer() {
10886
+ const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack();
10659
10887
  const app = express();
10660
10888
  const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
10661
10889
  if (MCP_TRUST_PROXY) {
@@ -10692,7 +10920,8 @@ async function startSSEServer() {
10692
10920
  const host = req.headers.host;
10693
10921
  const forwardedHost = MCP_TRUST_PROXY ? getForwardedRequestHost(req, true) : undefined;
10694
10922
  const requestHosts = [host, forwardedHost].filter((value) => Boolean(value));
10695
- if (requestHosts.length === 0 || !requestHosts.some(requestHost => effectiveHosts.has(requestHost))) {
10923
+ if (requestHosts.length === 0 ||
10924
+ !requestHosts.some(requestHost => effectiveHosts.has(requestHost))) {
10696
10925
  res.status(403).json({ error: "Invalid Host header" });
10697
10926
  return;
10698
10927
  }
@@ -10867,6 +11096,7 @@ async function startSSEServer() {
10867
11096
  * Start server with Streamable HTTP transport
10868
11097
  */
10869
11098
  async function startStreamableHTTPServer() {
11099
+ const { express, rateLimit, ipKeyGenerator, StreamableHTTPServerTransport, mcpAuthRouter, requireBearerAuth, } = await loadRemoteHttpStack();
10870
11100
  const app = express();
10871
11101
  // Session IDs are untrusted map keys. A null prototype prevents inherited
10872
11102
  // names such as "constructor" from masquerading as live transports.
@@ -11399,9 +11629,7 @@ async function startStreamableHTTPServer() {
11399
11629
  // ipKeyGenerator for correct IPv6 subnet handling.
11400
11630
  const rateLimitOptions = { keyGenerator: mcpRateLimitKeyGenerator };
11401
11631
  const parsedRegisterLimit = Number(process.env.OAUTH_REGISTER_RATE_LIMIT_PER_HOUR);
11402
- const OAUTH_REGISTER_RATE_LIMIT_PER_HOUR = Number.isInteger(parsedRegisterLimit) && parsedRegisterLimit >= 1
11403
- ? parsedRegisterLimit
11404
- : 20;
11632
+ const OAUTH_REGISTER_RATE_LIMIT_PER_HOUR = Number.isInteger(parsedRegisterLimit) && parsedRegisterLimit >= 1 ? parsedRegisterLimit : 20;
11405
11633
  const clientRegistrationRateLimitOptions = {
11406
11634
  ...rateLimitOptions,
11407
11635
  windowMs: 60 * 60 * 1000, // 1 hour
@@ -11501,9 +11729,7 @@ async function startStreamableHTTPServer() {
11501
11729
  ? staticMcpBearerAuth
11502
11730
  : (_req, _res, next) => next();
11503
11731
  const rejectUnknownStatefulSession = (req, res, next) => {
11504
- const usesStatelessSessions = OAUTH_STATELESS_MODE &&
11505
- STATELESS_MATERIAL &&
11506
- (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH);
11732
+ const usesStatelessSessions = OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH);
11507
11733
  const sessionId = readMcpSessionIdHeader(req);
11508
11734
  if (!usesStatelessSessions && sessionId && !streamableTransports[sessionId]) {
11509
11735
  res.status(404).json({ error: "Session not found" });
@@ -11521,7 +11747,9 @@ async function startStreamableHTTPServer() {
11521
11747
  // entirely and derive the session auth from either the current request
11522
11748
  // headers (init) or a sealed Mcp-Session-Id (subsequent requests).
11523
11749
  // Rate limiting is disabled here because there is no shared counter.
11524
- if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11750
+ if (OAUTH_STATELESS_MODE &&
11751
+ STATELESS_MATERIAL &&
11752
+ (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11525
11753
  await handleStatelessMcpRequest(req, res, STATELESS_MATERIAL, OAUTH_STATELESS_SESSION_TTL_SECONDS);
11526
11754
  return;
11527
11755
  }
@@ -11802,7 +12030,9 @@ async function startStreamableHTTPServer() {
11802
12030
  });
11803
12031
  return;
11804
12032
  }
11805
- if (OAUTH_STATELESS_MODE && STATELESS_MATERIAL && (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
12033
+ if (OAUTH_STATELESS_MODE &&
12034
+ STATELESS_MATERIAL &&
12035
+ (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH)) {
11806
12036
  res.setHeader("Allow", "POST");
11807
12037
  res.status(405).json({
11808
12038
  error: "Method Not Allowed",
@@ -12059,16 +12289,16 @@ async function runServer() {
12059
12289
  }
12060
12290
  logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.join(", ")}`);
12061
12291
  logger.info(`Default GitLab API URL: ${GITLAB_API_URL}`);
12062
- if (GITLAB_ALLOWED_GROUPS_RAW) {
12063
- if (GITLAB_OAUTH_ALLOWED_GROUPS_RAW) {
12064
- logger.warn("GITLAB_ALLOWED_GROUPS is set but ignored — GITLAB_OAUTH_ALLOWED_GROUPS takes precedence.");
12065
- }
12066
- else {
12067
- logger.warn("GITLAB_ALLOWED_GROUPS is deprecated. Use GITLAB_OAUTH_ALLOWED_GROUPS instead.");
12068
- }
12069
- }
12070
- if (GITLAB_READ_ONLY_MODE) {
12071
- logger.warn("GITLAB_READ_ONLY_MODE is deprecated. Use GITLAB_PERMISSION_MODE=readonly or --permission-mode=readonly instead.");
12292
+ for (const warning of getDeprecatedEnvWarnings({
12293
+ readOnlyMode: GITLAB_READ_ONLY_MODE,
12294
+ permissionModeRaw: GITLAB_PERMISSION_MODE_RAW,
12295
+ allowedGroupsRaw: GITLAB_ALLOWED_GROUPS_RAW,
12296
+ oauthAllowedGroupsRaw: GITLAB_OAUTH_ALLOWED_GROUPS_RAW,
12297
+ useWikiRaw: USE_GITLAB_WIKI_RAW,
12298
+ useMilestoneRaw: USE_MILESTONE_RAW,
12299
+ usePipelineRaw: USE_PIPELINE_RAW,
12300
+ })) {
12301
+ logger.warn(warning);
12072
12302
  }
12073
12303
  if (GITLAB_OAUTH_ALLOWED_GROUPS) {
12074
12304
  logger.info(`Group access control enabled for: ${GITLAB_OAUTH_ALLOWED_GROUPS.join(", ")}`);