@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/README.ko.md +1 -1
- package/README.md +120 -116
- package/README.zh-CN.md +5 -1
- package/build/config.js +7 -0
- package/build/index.js +97 -21
- package/build/masking/engine.js +404 -0
- package/build/masking/index.js +2 -0
- package/build/masking/project-scope.js +53 -0
- package/build/schemas.js +25 -18
- package/build/test/get-merge-request-discussion-schema.test.js +30 -0
- package/build/test/gitlab-pipeline-schedule-schema.test.js +32 -0
- package/build/test/group-milestone-schema.test.js +4 -4
- package/build/test/response-masking.test.js +412 -0
- package/build/test/streamable-http-concurrent-session.test.js +16 -0
- package/build/test/test-health-check.js +9 -0
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/test-update-project.js +1 -1
- package/build/test/utils/masking.test.js +384 -0
- package/build/test/utils/merge-request-position.test.js +1 -1
- package/build/tools/registry.js +10 -4
- package/build/tools/tool-descriptions.js +1 -0
- package/build/utils/schema.js +46 -23
- package/package.json +2 -3
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
|
-
|
|
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:
|
|
322
|
+
error: safeError instanceof Error ? safeError.message : String(safeError),
|
|
307
323
|
}, `tool_call_error: ${toolName} (${durationMs}ms)`);
|
|
308
|
-
throw
|
|
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 (
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|