@zereight/mcp-gitlab 2.1.61 → 2.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
2
+ import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_MASKING_ENABLED, GITLAB_MASKING_CONFIG, GITLAB_MASKING_POLICY_FILE, GITLAB_MASKING_WORKSPACE_DIR, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
3
3
  /** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
4
4
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
5
  const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
@@ -95,6 +95,7 @@ import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation
95
95
  import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
96
96
  import { encodeRepoFilePayloadContent, fileOperationsIncludeDeleteOrMove, toGitLabCommitActions, } from "./utils/gitlab-commit-actions.js";
97
97
  import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
98
+ import { createMaskingPolicyResolver, getManagedMaskingProjectIds, } from "./masking/index.js";
98
99
  import { checkForNewVersion } from "./utils/version-check.js";
99
100
  import { assertGitLabVersionAtLeast } from "./utils/gitlab-version-gate.js";
100
101
  import { parseGitLabVersionApiResponse, } from "./utils/gitlab-instance-version.js";
@@ -111,10 +112,18 @@ GitLabDiscussionNoteSchema, // Added
111
112
  GitLabDiscussionSchema,
112
113
  // Draft Notes Schemas
113
114
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineScheduleSchema, GitLabPipelineScheduleVariableSchema, GitLabPipelineSchedulePlayResultSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
114
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
115
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, GetMergeRequestDiscussionSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
115
116
  import { randomUUID, timingSafeEqual, } from "node:crypto";
116
117
  import { createLogger } from "./utils/logger.js";
117
118
  const logger = createLogger();
119
+ // Construct once at startup. When disabled this is undefined and the result
120
+ // path below returns the original objects without reading a config file.
121
+ const maskingPolicyResolver = createMaskingPolicyResolver({
122
+ enabled: GITLAB_MASKING_ENABLED,
123
+ configPath: GITLAB_MASKING_CONFIG,
124
+ policyFilePath: GITLAB_MASKING_POLICY_FILE,
125
+ workspaceDir: GITLAB_MASKING_WORKSPACE_DIR,
126
+ });
118
127
  const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
119
128
  /**
120
129
  * Static MCP prompt catalog. These wrap the multi-step tool workflows documented in
@@ -292,22 +301,74 @@ function createServer() {
292
301
  const sessionId = request.params.sessionId;
293
302
  const toolName = request.params.name;
294
303
  const start = Date.now();
304
+ let maskingEngine;
295
305
  const logCompletion = (result) => {
296
306
  const durationMs = Date.now() - start;
297
307
  logger.info({ tool: toolName, event: "tool_call_done", durationMs }, `tool_call_done: ${toolName} (${durationMs}ms)`);
298
- return result;
308
+ const isPlainTextResult = toolName === "get_pipeline_job_output" ||
309
+ toolName === "get_job_artifact_file" ||
310
+ (toolName === "download_release_asset" && !IS_REMOTE);
311
+ return maskingEngine
312
+ ? maskingEngine.maskToolResult(result, { textFormat: isPlainTextResult ? "plain" : "json" })
313
+ : result;
299
314
  };
300
315
  const logError = (error) => {
301
316
  const durationMs = Date.now() - start;
317
+ const safeError = maskingEngine ? maskingEngine.maskError(error) : error;
302
318
  logger.error({
303
319
  tool: toolName,
304
320
  event: "tool_call_error",
305
321
  durationMs,
306
- error: error instanceof Error ? error.message : String(error),
322
+ error: safeError instanceof Error ? safeError.message : String(safeError),
307
323
  }, `tool_call_error: ${toolName} (${durationMs}ms)`);
308
- throw error;
324
+ throw safeError;
309
325
  };
310
326
  try {
327
+ const authData = (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId
328
+ ? authBySession[sessionId]
329
+ : undefined;
330
+ const sessionContext = authData
331
+ ? {
332
+ sessionId,
333
+ header: authData.header,
334
+ token: authData.token,
335
+ lastUsed: authData.lastUsed,
336
+ apiUrl: authData.apiUrl,
337
+ publicBaseUrl: authData.publicBaseUrl,
338
+ allowedProjectIds: authData.allowedProjectIds,
339
+ }
340
+ : undefined;
341
+ const selectMaskingPolicy = () => {
342
+ const isToolDiscovery = toolName === "discover_tools";
343
+ const inputSchema = allTools.find(tool => tool.name === toolName)?.inputSchema;
344
+ // Optional project fields can be filters on global queries. Only supply
345
+ // the default when the corresponding handler actually uses it. Keep this
346
+ // list synchronized with getFileContents, listMyIssues, listIssues, and
347
+ // listMergeRequests: those are the handlers with optional project input
348
+ // that may call getEffectiveProjectId("") at runtime.
349
+ const usesDefaultProject = inputSchema?.required?.includes("project_id") ||
350
+ toolName === "get_file_contents" ||
351
+ (toolName === "my_issues" &&
352
+ (Boolean(GITLAB_PROJECT_ID) || getEffectiveAllowedProjectIds().length > 0)) ||
353
+ ((toolName === "list_issues" || toolName === "list_merge_requests") &&
354
+ ENABLE_STRICT_PROJECT_SCOPE &&
355
+ getEffectiveAllowedProjectIds().length > 0);
356
+ const maskingScope = maskingPolicyResolver
357
+ ? getManagedMaskingProjectIds(inputSchema, request.params.arguments, maskingPolicyResolver.managed && usesDefaultProject
358
+ ? () => getEffectiveProjectId("")
359
+ : undefined)
360
+ : undefined;
361
+ return maskingPolicyResolver?.select({
362
+ gitlabInstance: getEffectiveApiUrl(),
363
+ ...maskingScope,
364
+ // Tool discovery does not access GitLab data and must remain available
365
+ // under the default managed-policy setting.
366
+ allowBuiltinForUnscoped: isToolDiscovery,
367
+ });
368
+ };
369
+ maskingEngine = sessionContext
370
+ ? sessionAuthStore.run(sessionContext, selectMaskingPolicy)
371
+ : selectMaskingPolicy();
311
372
  // Handle discover_tools meta-tool directly (needs access to mcpServer and filteredTools)
312
373
  if (toolName === "discover_tools") {
313
374
  const category = request.params.arguments?.category?.trim()?.toLowerCase();
@@ -421,18 +482,9 @@ function createServer() {
421
482
  const { _confirmed, ...cleanArgs } = request.params.arguments || {};
422
483
  request.params.arguments = cleanArgs;
423
484
  }
424
- if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
425
- const authData = authBySession[sessionId];
426
- const sessionContext = {
427
- sessionId,
428
- header: authData.header,
429
- token: authData.token,
430
- lastUsed: authData.lastUsed,
431
- apiUrl: authData.apiUrl,
432
- publicBaseUrl: authData.publicBaseUrl,
433
- allowedProjectIds: authData.allowedProjectIds,
434
- };
435
- // Run the handler within the retrieved context
485
+ if (sessionContext) {
486
+ // Policy selection and the tool handler must use the same request-local
487
+ // API URL and project allowlist.
436
488
  const result = await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
437
489
  return logCompletion(result);
438
490
  }
@@ -1248,13 +1300,17 @@ async function handleGitLabError(response) {
1248
1300
  const errorBody = await response.text();
1249
1301
  // Check specifically for Rate Limit error
1250
1302
  if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) {
1251
- logger.error({ err: errorBody }, "GitLab API Rate Limit Exceeded");
1303
+ // Do not log the upstream body here; it is masked on the error object
1304
+ // before the common tool-call boundary returns/logs it.
1305
+ logger.error("GitLab API Rate Limit Exceeded");
1252
1306
  logger.error("User API Key Rate limit exceeded. Please try again later.");
1253
- throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1307
+ const error = new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`);
1308
+ throw error;
1254
1309
  }
1255
1310
  else {
1256
1311
  // Handle other API errors
1257
- throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1312
+ const error = new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
1313
+ throw error;
1258
1314
  }
1259
1315
  }
1260
1316
  }
@@ -3408,6 +3464,17 @@ async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
3408
3464
  const data = await response.json();
3409
3465
  return GitLabDiscussionNoteSchema.parse(data);
3410
3466
  }
3467
+ async function getMergeRequestDiscussion(projectId, mergeRequestIid, discussionId) {
3468
+ projectId = decodeURIComponent(projectId); // Decode project ID
3469
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/discussions/${encodeGitLabPathSegment(discussionId)}`);
3470
+ const response = await fetch(url.toString(), {
3471
+ ...getFetchConfig(),
3472
+ method: "GET",
3473
+ });
3474
+ await handleGitLabError(response);
3475
+ const data = await response.json();
3476
+ return GitLabDiscussionSchema.parse(data);
3477
+ }
3411
3478
  async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by, per_page, page) {
3412
3479
  projectId = decodeURIComponent(projectId); // Decode project ID
3413
3480
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${encodeGitLabPathSegment(mergeRequestIid)}/notes`);
@@ -7957,6 +8024,13 @@ async function handleToolCall(params) {
7957
8024
  content: [{ type: "text", text: JSON.stringify(note) }],
7958
8025
  };
7959
8026
  }
8027
+ case "get_merge_request_discussion": {
8028
+ const args = GetMergeRequestDiscussionSchema.parse(params.arguments);
8029
+ const discussion = await getMergeRequestDiscussion(args.project_id, args.merge_request_iid, args.discussion_id);
8030
+ return {
8031
+ content: [{ type: "text", text: JSON.stringify(discussion) }],
8032
+ };
8033
+ }
7960
8034
  case "get_merge_request_notes": {
7961
8035
  const args = GetMergeRequestNotesSchema.parse(params.arguments);
7962
8036
  const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by, args.per_page, args.page);
@@ -10233,6 +10307,7 @@ async function handleToolCall(params) {
10233
10307
  status: authenticated ? "ok" : "error",
10234
10308
  authenticated,
10235
10309
  gitlab_url: getEffectiveApiUrl(),
10310
+ mcp_server_version: SERVER_VERSION,
10236
10311
  ...(versionMetadata ?? {}),
10237
10312
  }),
10238
10313
  },
@@ -10389,7 +10464,7 @@ async function handleToolCall(params) {
10389
10464
  // Sensitive fields are also covered by REDACT_PATHS if arguments are logged elsewhere.
10390
10465
  logger.debug({ tool: params.name }, "Tool call failed");
10391
10466
  if (error instanceof z.ZodError) {
10392
- throw new Error(`Invalid arguments: ${error.errors
10467
+ throw new Error(`Invalid arguments: ${error.issues
10393
10468
  .map(e => `${e.path.join(".")}: ${e.message}`)
10394
10469
  .join(", ")}`);
10395
10470
  }
@@ -11583,6 +11658,7 @@ async function startStreamableHTTPServer() {
11583
11658
  }
11584
11659
  res.status(isHealthy ? 200 : 503).json({
11585
11660
  status: isHealthy ? "healthy" : "degraded",
11661
+ version: SERVER_VERSION,
11586
11662
  activeSessions,
11587
11663
  maxSessions: MAX_SESSIONS,
11588
11664
  uptime: process.uptime(),
@@ -0,0 +1,404 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const BUILTIN_RULES = [
4
+ {
5
+ id: "gitlab-token",
6
+ type: "regex",
7
+ // GitLab token prefixes documented at
8
+ // https://docs.gitlab.com/security/token_overview/#token-prefixes
9
+ pattern: "\\bgl(?:pat|rt|ptt|dt|cbt|soat|oas|agent|ft|imt)-[A-Za-z0-9_-]{20,}\\b",
10
+ flags: "g",
11
+ replacement: "[Token masked]",
12
+ },
13
+ {
14
+ id: "ipv4",
15
+ type: "regex",
16
+ pattern: "\\b(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}\\b",
17
+ flags: "g",
18
+ replacement: "[IP address]",
19
+ },
20
+ {
21
+ id: "ipv6",
22
+ type: "regex",
23
+ pattern: "(?<![0-9A-Fa-f:])(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))(?![0-9A-Fa-f:])",
24
+ flags: "g",
25
+ replacement: "[IP address]",
26
+ },
27
+ ];
28
+ const ALLOWED_REGEX_FLAGS = new Set(["g", "i", "m", "s", "u"]);
29
+ function assertString(value, field) {
30
+ if (typeof value !== "string")
31
+ throw new Error(`${field} must be a string`);
32
+ return value;
33
+ }
34
+ function compileRule(rule, order) {
35
+ if (!rule || typeof rule !== "object")
36
+ throw new Error(`Invalid mask rule at index ${order}`);
37
+ const id = assertString(rule.id, `mask rule ${order}.id`);
38
+ if (!id)
39
+ throw new Error(`mask rule ${order}.id must not be empty`);
40
+ const replacement = assertString(rule.replacement, `mask rule ${id}.replacement`);
41
+ let source;
42
+ let flags;
43
+ if (rule.type === "keyword") {
44
+ const match = assertString(rule.match, `mask rule ${id}.match`);
45
+ if (!match)
46
+ throw new Error(`mask rule ${id}.match must not be empty`);
47
+ source = escapeRegExp(match);
48
+ flags = rule.caseSensitive === false ? "gi" : "g";
49
+ }
50
+ else if (rule.type === "regex") {
51
+ source = assertString(rule.pattern, `mask rule ${id}.pattern`);
52
+ flags = rule.flags ?? "g";
53
+ if (!flags.includes("g"))
54
+ flags += "g";
55
+ if ([...flags].some(flag => !ALLOWED_REGEX_FLAGS.has(flag))) {
56
+ throw new Error(`mask rule ${id}.flags contains an unsupported flag`);
57
+ }
58
+ if (source.length === 0)
59
+ throw new Error(`mask rule ${id}.pattern must not be empty`);
60
+ }
61
+ else {
62
+ throw new Error(`mask rule ${id}.type must be "keyword" or "regex"`);
63
+ }
64
+ let expression;
65
+ try {
66
+ expression = new RegExp(source, flags);
67
+ }
68
+ catch (error) {
69
+ throw new Error(`Invalid regular expression in mask rule ${id}: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ // Empty matches can make a rule unexpectedly rewrite every character and are
72
+ // particularly easy to create accidentally with a configurable expression.
73
+ if (expression.test(""))
74
+ throw new Error(`mask rule ${id} must not match an empty string`);
75
+ return { id, replacement, expression, order };
76
+ }
77
+ function escapeRegExp(value) {
78
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ }
80
+ function mergeBuiltinRules(file) {
81
+ const overrides = file?.builtins ?? {};
82
+ return BUILTIN_RULES.flatMap(rule => {
83
+ const override = overrides[rule.id];
84
+ if (override?.enabled === false)
85
+ return [];
86
+ return [
87
+ {
88
+ ...rule,
89
+ ...(override?.replacement !== undefined ? { replacement: override.replacement } : {}),
90
+ },
91
+ ];
92
+ });
93
+ }
94
+ function loadFile(configPath) {
95
+ let parsed;
96
+ try {
97
+ parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
98
+ }
99
+ catch (error) {
100
+ throw new Error(`Unable to load masking config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
101
+ }
102
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
103
+ throw new Error(`Masking config ${configPath} must contain a JSON object`);
104
+ }
105
+ if (isManagedReference(parsed)) {
106
+ const value = parsed;
107
+ if (value.version !== 2 || typeof value.policyGroup !== "string" || !value.policyGroup.trim()) {
108
+ throw new Error(`Managed masking config ${configPath} requires version 2 and a non-empty policyGroup`);
109
+ }
110
+ return value;
111
+ }
112
+ validateMaskingFile(parsed, `Masking config ${configPath}`);
113
+ return parsed;
114
+ }
115
+ function loadJsonFile(configPath) {
116
+ try {
117
+ return JSON.parse(fs.readFileSync(configPath, "utf8"));
118
+ }
119
+ catch (error) {
120
+ throw new Error(`Unable to load masking config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
121
+ }
122
+ }
123
+ function normalizeGitLabInstance(value) {
124
+ let url;
125
+ try {
126
+ url = new URL(value);
127
+ }
128
+ catch {
129
+ throw new Error(`Invalid managed masking GitLab instance: ${value}`);
130
+ }
131
+ return `${url.protocol}//${url.host}${url.pathname.replace(/\/api\/v4\/?$/, "").replace(/\/+$/, "")}`;
132
+ }
133
+ function isManagedReference(value) {
134
+ return Boolean(value && typeof value === "object" && value.mode === "managed");
135
+ }
136
+ function loadManagedPolicyFile(configPath) {
137
+ const parsed = loadJsonFile(configPath);
138
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
139
+ throw new Error(`Managed masking policy file ${configPath} must contain a JSON object`);
140
+ }
141
+ const file = parsed;
142
+ if (file.version !== 1)
143
+ throw new Error("Unsupported managed masking policy version");
144
+ if (!file.policyGroups ||
145
+ typeof file.policyGroups !== "object" ||
146
+ Array.isArray(file.policyGroups)) {
147
+ throw new Error("Managed masking policy file policyGroups must be an object");
148
+ }
149
+ if (!Array.isArray(file.bindings))
150
+ throw new Error("Managed masking policy file bindings must be an array");
151
+ if (file.unboundProjectBehavior !== undefined &&
152
+ file.unboundProjectBehavior !== "deny" &&
153
+ file.unboundProjectBehavior !== "builtin") {
154
+ throw new Error('Managed masking policy file unboundProjectBehavior must be "deny" or "builtin"');
155
+ }
156
+ for (const [name, policy] of Object.entries(file.policyGroups)) {
157
+ if (!name.trim())
158
+ throw new Error("Managed masking policy group names must not be empty");
159
+ validateMaskingFile(policy, `managed masking policy group ${name}`);
160
+ }
161
+ for (const binding of file.bindings) {
162
+ if (!binding ||
163
+ typeof binding !== "object" ||
164
+ typeof binding.gitlabInstance !== "string" ||
165
+ typeof binding.policyGroup !== "string" ||
166
+ !Array.isArray(binding.projectIds)) {
167
+ throw new Error("Invalid managed masking policy binding");
168
+ }
169
+ normalizeGitLabInstance(binding.gitlabInstance);
170
+ if (!file.policyGroups[binding.policyGroup]) {
171
+ throw new Error(`Managed masking policy binding references unknown group: ${binding.policyGroup}`);
172
+ }
173
+ if (binding.projectIds.some(id => !Number.isSafeInteger(id) || id <= 0)) {
174
+ throw new Error("Managed masking policy binding projectIds must be positive integers");
175
+ }
176
+ }
177
+ return file;
178
+ }
179
+ function validateMaskingFile(value, description) {
180
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
181
+ throw new Error(`${description} must contain a JSON object`);
182
+ }
183
+ const file = value;
184
+ if (file.version !== undefined && file.version !== 1) {
185
+ throw new Error(`Unsupported masking config version: ${String(file.version)}`);
186
+ }
187
+ if (file.rules !== undefined && !Array.isArray(file.rules)) {
188
+ throw new Error(`${description}.rules must be an array`);
189
+ }
190
+ }
191
+ function maskStrings(value, mask, parentKey) {
192
+ if (typeof value === "string") {
193
+ const masked = mask(value);
194
+ return masked !== value && parentKey === "download_url" ? "[Download URL masked]" : masked;
195
+ }
196
+ if (Array.isArray(value))
197
+ return value.map(item => maskStrings(item, mask, parentKey));
198
+ if (value && typeof value === "object") {
199
+ const output = {};
200
+ for (const [key, item] of Object.entries(value))
201
+ output[key] = maskStrings(item, mask, key);
202
+ return output;
203
+ }
204
+ return value;
205
+ }
206
+ function maskJsonText(text, mask) {
207
+ try {
208
+ const parsed = JSON.parse(text);
209
+ if (!parsed || typeof parsed !== "object")
210
+ return mask(text);
211
+ }
212
+ catch {
213
+ return mask(text);
214
+ }
215
+ // Rewrite only JSON string values. Re-serializing the parsed object would
216
+ // change whitespace and lose precision in numeric values from GitLab.
217
+ let propertyKey;
218
+ return text.replace(/"(?:\\.|[^"\\])*"/g, (literal, offset) => {
219
+ const value = JSON.parse(literal);
220
+ if (/^\s*:/.test(text.slice(offset + literal.length))) {
221
+ propertyKey = value;
222
+ return literal;
223
+ }
224
+ const masked = mask(value);
225
+ if (masked === value)
226
+ return literal;
227
+ const isDownloadUrl = propertyKey === "download_url" && /:\s*$/.test(text.slice(0, offset));
228
+ return JSON.stringify(isDownloadUrl ? "[Download URL masked]" : masked);
229
+ });
230
+ }
231
+ function createMaskingEngineFromFile(file, configPath) {
232
+ const rules = [...(file.rules ?? []), ...mergeBuiltinRules(file)].map(compileRule);
233
+ const maskedErrors = new WeakSet();
234
+ const maskText = (text) => {
235
+ const matches = [];
236
+ for (const rule of rules) {
237
+ rule.expression.lastIndex = 0;
238
+ let match;
239
+ while ((match = rule.expression.exec(text)) !== null) {
240
+ const start = match.index;
241
+ const end = start + match[0].length;
242
+ if (end === start)
243
+ throw new Error(`mask rule ${rule.id} produced an empty match`);
244
+ matches.push({ start, end, replacement: rule.replacement, order: rule.order });
245
+ if (!rule.expression.global)
246
+ break;
247
+ }
248
+ }
249
+ if (matches.length === 0)
250
+ return text;
251
+ matches.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.order - b.order);
252
+ const coveredMatches = [];
253
+ for (const match of matches) {
254
+ const previous = coveredMatches[coveredMatches.length - 1];
255
+ if (previous && match.start < previous.end) {
256
+ // Preserve the selected replacement, but cover the entire overlapping
257
+ // interval so a longer secret cannot survive behind a shorter match.
258
+ previous.end = Math.max(previous.end, match.end);
259
+ }
260
+ else {
261
+ coveredMatches.push({ ...match });
262
+ }
263
+ }
264
+ let output = "";
265
+ let cursor = 0;
266
+ for (const match of coveredMatches) {
267
+ output += text.slice(cursor, match.start) + match.replacement;
268
+ cursor = match.end;
269
+ }
270
+ return output + text.slice(cursor);
271
+ };
272
+ return {
273
+ configPath,
274
+ maskText,
275
+ maskValue: (value) => maskStrings(value, maskText),
276
+ maskToolResult: (result, options) => {
277
+ const output = { ...result };
278
+ if (Array.isArray(output.content))
279
+ output.content = output.content.map(item => {
280
+ if (!item || typeof item !== "object")
281
+ return item;
282
+ const block = { ...item };
283
+ if (typeof block.text === "string") {
284
+ block.text =
285
+ options?.textFormat === "plain"
286
+ ? maskText(block.text)
287
+ : maskJsonText(block.text, maskText);
288
+ }
289
+ return block;
290
+ });
291
+ if (output.structuredContent !== undefined)
292
+ output.structuredContent = maskStrings(output.structuredContent, maskText);
293
+ return output;
294
+ },
295
+ maskError: (error) => {
296
+ if (error instanceof Error && !maskedErrors.has(error)) {
297
+ error.message = maskText(error.message);
298
+ maskedErrors.add(error);
299
+ }
300
+ return error;
301
+ },
302
+ };
303
+ }
304
+ export function createMaskingEngine(options) {
305
+ if (!options.enabled)
306
+ return undefined;
307
+ const workspaceDir = path.resolve(options.workspaceDir ?? process.cwd());
308
+ const configPath = options.configPath
309
+ ? path.resolve(workspaceDir, options.configPath)
310
+ : path.join(workspaceDir, ".gitlab-mcp-mask.json");
311
+ if (options.configPath && !fs.existsSync(configPath)) {
312
+ throw new Error(`Configured masking config does not exist: ${configPath}`);
313
+ }
314
+ const file = fs.existsSync(configPath) ? loadFile(configPath) : undefined;
315
+ if (isManagedReference(file))
316
+ throw new Error("Managed masking references require a managed policy resolver");
317
+ return createMaskingEngineFromFile(file ?? {}, fs.existsSync(configPath) ? configPath : undefined);
318
+ }
319
+ /**
320
+ * Selects a compiled policy for each tool request. In managed mode this keeps
321
+ * the secret rules on the server while the workspace carries only a group name.
322
+ */
323
+ export function createMaskingPolicyResolver(options) {
324
+ if (!options.enabled)
325
+ return undefined;
326
+ const workspaceDir = path.resolve(options.workspaceDir ?? process.cwd());
327
+ const localConfigPath = options.configPath
328
+ ? path.resolve(workspaceDir, options.configPath)
329
+ : path.join(workspaceDir, ".gitlab-mcp-mask.json");
330
+ if (options.configPath && !fs.existsSync(localConfigPath)) {
331
+ throw new Error(`Configured masking config does not exist: ${localConfigPath}`);
332
+ }
333
+ const localConfig = fs.existsSync(localConfigPath) ? loadFile(localConfigPath) : undefined;
334
+ const localGroup = isManagedReference(localConfig) ? localConfig.policyGroup : undefined;
335
+ if (!options.policyFilePath) {
336
+ if (localGroup)
337
+ throw new Error("Managed masking config requires GITLAB_MASKING_POLICY_FILE");
338
+ const localEngine = createMaskingEngineFromFile(localConfig ?? {}, localConfigPath);
339
+ return { managed: false, select: () => localEngine };
340
+ }
341
+ const policyPath = path.resolve(workspaceDir, options.policyFilePath);
342
+ const policyFile = loadManagedPolicyFile(policyPath);
343
+ if (localGroup && !policyFile.policyGroups[localGroup]) {
344
+ throw new Error(`Managed masking config references unknown policy group: ${localGroup}`);
345
+ }
346
+ const engines = new Map();
347
+ for (const [name, policy] of Object.entries(policyFile.policyGroups)) {
348
+ engines.set(name, createMaskingEngineFromFile(policy, policyPath));
349
+ }
350
+ const builtinEngine = createMaskingEngineFromFile({}, policyPath);
351
+ const bindings = new Map();
352
+ for (const binding of policyFile.bindings) {
353
+ const instance = normalizeGitLabInstance(binding.gitlabInstance);
354
+ for (const projectId of binding.projectIds) {
355
+ const key = `${instance}#${projectId}`;
356
+ const existing = bindings.get(key);
357
+ if (existing && existing !== binding.policyGroup) {
358
+ throw new Error(`Managed masking policy has conflicting bindings for ${key}`);
359
+ }
360
+ bindings.set(key, binding.policyGroup);
361
+ }
362
+ }
363
+ return {
364
+ managed: true,
365
+ select: ({ gitlabInstance, projectIds = [], hasProjectScope = projectIds.length > 0, allowBuiltinForUnscoped = false, }) => {
366
+ const numericProjectIds = projectIds.map(projectId => {
367
+ if (typeof projectId === "number" && Number.isSafeInteger(projectId) && projectId > 0) {
368
+ return projectId;
369
+ }
370
+ if (typeof projectId === "string" && /^\d+$/.test(projectId)) {
371
+ const numericProjectId = Number(projectId);
372
+ if (Number.isSafeInteger(numericProjectId) && numericProjectId > 0)
373
+ return numericProjectId;
374
+ }
375
+ throw new Error("Managed masking requires numeric project IDs");
376
+ });
377
+ if (numericProjectIds.length === 0) {
378
+ if (hasProjectScope)
379
+ throw new Error("Managed masking requires a project ID");
380
+ if (allowBuiltinForUnscoped || policyFile.unboundProjectBehavior === "builtin")
381
+ return builtinEngine;
382
+ throw new Error("Managed masking policy does not cover this project-less tool");
383
+ }
384
+ const groups = new Set();
385
+ for (const projectId of numericProjectIds) {
386
+ const group = bindings.get(`${normalizeGitLabInstance(gitlabInstance)}#${projectId}`);
387
+ if (!group)
388
+ throw new Error("No managed masking policy is bound to this GitLab project");
389
+ groups.add(group);
390
+ }
391
+ if (groups.size !== 1) {
392
+ throw new Error("Managed masking does not allow a tool call to span policy groups");
393
+ }
394
+ const [group] = groups;
395
+ if (localGroup && group !== localGroup) {
396
+ throw new Error("Managed masking policy group does not match the server project binding");
397
+ }
398
+ const engine = engines.get(group);
399
+ if (!engine)
400
+ throw new Error(`Managed masking policy group is unavailable: ${group}`);
401
+ return engine;
402
+ },
403
+ };
404
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./engine.js";
2
+ export * from "./project-scope.js";