@zereight/mcp-gitlab 2.2.5 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/oauth.js CHANGED
@@ -44,6 +44,117 @@ export function isAuthInvalidTokenResponse(status, bodyText) {
44
44
  return false;
45
45
  }
46
46
  }
47
+ // Shared OAuth callback servers started by this process
48
+ const sharedOAuthServers = new Set();
49
+ /**
50
+ * Stop the shared OAuth callback servers started by this process.
51
+ */
52
+ export async function closeSharedOAuthServersAsync() {
53
+ const servers = [...sharedOAuthServers];
54
+ sharedOAuthServers.clear();
55
+ await Promise.all(servers.map(server => new Promise(resolve => {
56
+ server.close(() => resolve());
57
+ server.closeAllConnections();
58
+ })));
59
+ }
60
+ const SHARED_OAUTH_KEY_HEADER = "x-gitlab-mcp-oauth-key";
61
+ /**
62
+ * Key file for the shared OAuth callback server on `port`. Sibling processes
63
+ * compute the same path, so they need the same OS user and home directory.
64
+ */
65
+ function sharedOAuthKeyPath(port) {
66
+ return path.join(os.homedir(), `.gitlab-mcp-oauth-${port}.key`);
67
+ }
68
+ /**
69
+ * Write the key to an owner-only file without following symlinks. The caller
70
+ * holds the port, so an existing regular file of ours is left from an earlier run.
71
+ */
72
+ function writeSharedOAuthKey(keyPath, key) {
73
+ const flags = fs.constants.O_WRONLY |
74
+ fs.constants.O_CREAT |
75
+ fs.constants.O_EXCL |
76
+ (fs.constants.O_NOFOLLOW ?? 0);
77
+ let fd;
78
+ try {
79
+ fd = fs.openSync(keyPath, flags, 0o600);
80
+ }
81
+ catch (error) {
82
+ if (error.code !== "EEXIST") {
83
+ throw error;
84
+ }
85
+ const stat = fs.lstatSync(keyPath);
86
+ const uid = process.getuid?.();
87
+ if (!stat.isFile() || (uid !== undefined && stat.uid !== uid)) {
88
+ throw new Error(`Refusing to replace ${keyPath}: not a regular file owned by the current user`);
89
+ }
90
+ fs.unlinkSync(keyPath);
91
+ fd = fs.openSync(keyPath, flags, 0o600);
92
+ }
93
+ try {
94
+ fs.writeSync(fd, key);
95
+ }
96
+ finally {
97
+ fs.closeSync(fd);
98
+ }
99
+ }
100
+ function removeSharedOAuthKey(keyPath, key) {
101
+ try {
102
+ if (fs.readFileSync(keyPath, "utf8").trim() === key) {
103
+ fs.unlinkSync(keyPath);
104
+ }
105
+ }
106
+ catch {
107
+ // Best effort
108
+ }
109
+ }
110
+ async function readSharedOAuthKeyAsync(port) {
111
+ const keyPath = sharedOAuthKeyPath(port);
112
+ let problem = "ENOENT";
113
+ // The other process writes its key right after it binds the port.
114
+ for (let attempt = 0; attempt < 5; attempt++) {
115
+ if (attempt > 0) {
116
+ await new Promise(resolve => setTimeout(resolve, 100));
117
+ }
118
+ try {
119
+ const key = fs.readFileSync(keyPath, "utf8").trim();
120
+ if (/^[0-9a-f]{64}$/.test(key)) {
121
+ return key;
122
+ }
123
+ problem = "unexpected content";
124
+ }
125
+ catch (error) {
126
+ problem = error.code ?? String(error);
127
+ if (problem !== "ENOENT") {
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ throw new Error(`cannot read OAuth server key ${keyPath} (${problem}); the process holding port ${port} ` +
133
+ "must run as the same OS user with the same home directory");
134
+ }
135
+ /**
136
+ * Host headers the shared OAuth callback server answers: loopback names with
137
+ * its port, plus the configured redirect URI host. The listener is bound to
138
+ * 127.0.0.1, so any other Host comes from a name that merely resolves there
139
+ * (DNS rebinding).
140
+ */
141
+ function sharedOAuthAllowedHosts(port, redirectUri) {
142
+ const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
143
+ try {
144
+ hosts.add(new URL(redirectUri).host.toLowerCase());
145
+ }
146
+ catch {
147
+ // Malformed redirectUri: loopback names only
148
+ }
149
+ return hosts;
150
+ }
151
+ function sharedOAuthKeyMatches(provided, expected) {
152
+ if (typeof provided !== "string") {
153
+ return false;
154
+ }
155
+ const digest = (value) => crypto.createHash("sha256").update(value).digest();
156
+ return crypto.timingSafeEqual(digest(provided), digest(expected));
157
+ }
47
158
  /**
48
159
  * Check if a port is already in use
49
160
  */
@@ -69,12 +180,14 @@ async function isPortInUse(port) {
69
180
  * Request authentication from an existing OAuth server
70
181
  */
71
182
  async function requestAuthFromExistingServer(port, requestId) {
183
+ const key = await readSharedOAuthKeyAsync(port);
72
184
  return new Promise((resolve, reject) => {
73
185
  const options = {
74
186
  hostname: "127.0.0.1",
75
187
  port: port,
76
188
  path: `/auth-request?requestId=${requestId}`,
77
189
  method: "GET",
190
+ headers: { [SHARED_OAUTH_KEY_HEADER]: key },
78
191
  };
79
192
  const req = http.request(options, res => {
80
193
  let data = "";
@@ -91,6 +204,9 @@ async function requestAuthFromExistingServer(port, requestId) {
91
204
  reject(new Error(`Failed to parse token data: ${error}`));
92
205
  }
93
206
  }
207
+ else if (res.statusCode === 403) {
208
+ reject(new Error(`OAuth server on port ${port} rejected the request key; it may run as a different OS user or an older version`));
209
+ }
94
210
  else {
95
211
  reject(new Error(`Auth request failed with status ${res.statusCode}: ${data}`));
96
212
  }
@@ -288,7 +404,8 @@ export class GitLabOAuth {
288
404
  }
289
405
  catch (error) {
290
406
  logger.error({ err: error }, "Failed to connect to existing OAuth server");
291
- throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server. Please close other instances or use a different port.`);
407
+ const reason = error instanceof Error ? error.message : String(error);
408
+ throw new Error(`Port ${callbackPort} is in use but cannot connect to existing OAuth server: ${reason}. Please close other instances or use a different port.`);
292
409
  }
293
410
  }
294
411
  // Port is free, start the shared OAuth server
@@ -300,6 +417,9 @@ export class GitLabOAuth {
300
417
  async startSharedOAuthServer(callbackPort, initialRequestId) {
301
418
  const stateToRequestId = new Map();
302
419
  const requestIdToOAuthInstance = new Map();
420
+ const allowedHosts = sharedOAuthAllowedHosts(callbackPort, this.config.redirectUri);
421
+ const keyPath = sharedOAuthKeyPath(callbackPort);
422
+ const key = crypto.randomBytes(32).toString("hex");
303
423
  return new Promise((resolve, reject) => {
304
424
  // Create initial request
305
425
  const state = crypto.randomUUID();
@@ -312,9 +432,20 @@ export class GitLabOAuth {
312
432
  pendingAuthRequests.set(initialRequestId, { resolve, reject, timeout });
313
433
  const server = http.createServer(async (req, res) => {
314
434
  try {
435
+ // Only answer requests addressed to this listener by an expected name
436
+ if (!allowedHosts.has((req.headers.host || "").toLowerCase())) {
437
+ res.writeHead(403, { "Content-Type": "text/plain" });
438
+ res.end("Forbidden");
439
+ return;
440
+ }
315
441
  const parsedUrl = url.parse(req.url || "", true);
316
442
  // Handle auth requests from other MCP instances
317
443
  if (parsedUrl.pathname === "/auth-request") {
444
+ if (!sharedOAuthKeyMatches(req.headers[SHARED_OAUTH_KEY_HEADER], key)) {
445
+ res.writeHead(403, { "Content-Type": "text/plain" });
446
+ res.end("Forbidden");
447
+ return;
448
+ }
318
449
  const newRequestId = parsedUrl.query.requestId;
319
450
  if (!newRequestId) {
320
451
  res.writeHead(400, { "Content-Type": "text/plain" });
@@ -331,7 +462,7 @@ export class GitLabOAuth {
331
462
  const authUrl = await this.getAuthorizationUrl(newState);
332
463
  logger.info("Opening browser for new authentication request...");
333
464
  logger.info(`If browser doesn't open, visit: ${redactUrlSecretsInText(authUrl)}`);
334
- open(authUrl).catch(err => {
465
+ (this.config.openBrowser ?? open)(authUrl).catch(err => {
335
466
  logger.error({ err }, "Failed to open browser");
336
467
  logger.info(`Please manually open: ${redactUrlSecretsInText(authUrl)}`);
337
468
  });
@@ -483,11 +614,24 @@ export class GitLabOAuth {
483
614
  }
484
615
  });
485
616
  server.listen(callbackPort, "127.0.0.1", async () => {
617
+ sharedOAuthServers.add(server);
618
+ try {
619
+ writeSharedOAuthKey(keyPath, key);
620
+ const removeKey = () => removeSharedOAuthKey(keyPath, key);
621
+ process.once("exit", removeKey);
622
+ server.once("close", () => {
623
+ process.removeListener("exit", removeKey);
624
+ removeKey();
625
+ });
626
+ }
627
+ catch (error) {
628
+ logger.warn({ err: error }, `Failed to write ${keyPath}; other MCP instances cannot use this OAuth callback server`);
629
+ }
486
630
  logger.info(`Shared OAuth callback server listening on port ${callbackPort}`);
487
631
  const authUrl = await this.getAuthorizationUrl(state);
488
632
  logger.info("Opening browser for authentication...");
489
633
  logger.info(`If browser doesn't open, visit: ${redactUrlSecretsInText(authUrl)}`);
490
- open(authUrl).catch(err => {
634
+ (this.config.openBrowser ?? open)(authUrl).catch(err => {
491
635
  logger.error({ err }, "Failed to open browser");
492
636
  logger.info(`Please manually open: ${redactUrlSecretsInText(authUrl)}`);
493
637
  });
package/build/schemas.js CHANGED
@@ -2253,6 +2253,30 @@ export const ApproveMergeRequestSchema = ProjectParamsSchema.extend({
2253
2253
  export const UnapproveMergeRequestSchema = ProjectParamsSchema.extend({
2254
2254
  merge_request_iid: z.coerce.string().describe("The IID of the merge request to unapprove"),
2255
2255
  });
2256
+ export const RebaseMergeRequestSchema = ProjectParamsSchema.extend({
2257
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request to rebase"),
2258
+ skip_ci: coerceBooleanString
2259
+ .optional()
2260
+ .describe("If true, GitLab does not create a CI pipeline for the rebased source branch"),
2261
+ wait: coerceBooleanString
2262
+ .optional()
2263
+ .default(true)
2264
+ .describe("If true, poll the merge request until the rebase ends or timeout_seconds passes. If false, return GitLab's enqueue response at once."),
2265
+ timeout_seconds: z.coerce
2266
+ .number()
2267
+ .int()
2268
+ .min(1)
2269
+ .max(600)
2270
+ .optional()
2271
+ .describe("Maximum seconds to wait for the rebase to end"),
2272
+ poll_interval_seconds: z.coerce
2273
+ .number()
2274
+ .int()
2275
+ .min(1)
2276
+ .max(30)
2277
+ .optional()
2278
+ .describe("Seconds between status polls while waiting for the rebase"),
2279
+ });
2256
2280
  // Merge Request Approval State response schema
2257
2281
  export const GitLabApprovalUserSchema = z.object({
2258
2282
  id: z.coerce.string(),
@@ -3450,7 +3474,7 @@ export const DownloadAttachmentSchema = z.object({
3450
3474
  local_path: z
3451
3475
  .string()
3452
3476
  .optional()
3453
- .describe("Local path to save the file (optional, defaults to current directory)"),
3477
+ .describe("Subdirectory of the download directory to save the file in (optional, defaults to the download directory itself). Names starting with a dot are rejected"),
3454
3478
  });
3455
3479
  export const DownloadAttachmentRemoteSchema = z.object({
3456
3480
  project_id: z.string().describe("Project ID or URL-encoded path of the project"),
@@ -15,4 +15,6 @@ export const STATELESS_PURPOSES = {
15
15
  STORED_TOKENS: "pc",
16
16
  /** Sealed Mcp-Session-Id, carries session auth across pod hops. */
17
17
  SESSION_ID: "sid",
18
+ /** HMAC key for remembered callback-proxy consent cookies. */
19
+ OAUTH_CONSENT: "oc",
18
20
  };
@@ -38,3 +38,17 @@ export function selectExposedTools(input) {
38
38
  }
39
39
  return withoutHidden.filter(tool => !isExcludedBySlimProfile(tool.name, true, input.individuallyEnabledTools));
40
40
  }
41
+ /**
42
+ * Hidden tools that would be listed if the hidden policy were ignored.
43
+ * Hiding a tool keeps it callable only while toolsets, `GITLAB_TOOLS`, legacy
44
+ * flags, permission mode, and the regex denylist still expose it.
45
+ */
46
+ export function selectCallableHiddenToolNames(input) {
47
+ if (input.hiddenToolNames.size === 0) {
48
+ return new Set();
49
+ }
50
+ const exposedIgnoringHidden = selectExposedTools({ ...input, hiddenToolNames: new Set() });
51
+ return new Set(exposedIgnoringHidden
52
+ .map(tool => tool.name)
53
+ .filter(toolName => input.hiddenToolNames.has(toolName)));
54
+ }
@@ -1,7 +1,7 @@
1
1
  import { toJSONSchema } from "../utils/schema.js";
2
2
  import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, SSE, STREAMABLE_HTTP } from "../config.js";
3
3
  import { getToolDescription } from "./tool-descriptions.js";
4
- import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, ErasePipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWebhookSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, ProtectTagSchema, UnprotectBranchSchema, UnprotectTagSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWebhookSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, CreateDeploymentSchema, UpdateDeploymentSchema, DeploymentApprovalSchema, ListDeploymentMergeRequestsSchema, GetDraftNoteSchema, GetEnvironmentSchema, UpdateEnvironmentSchema, StopEnvironmentSchema, StopStaleEnvironmentsSchema, DeleteReviewAppEnvironmentsSchema, PipelineTriggerIdSchema, ListPipelineTriggersSchema, CreatePipelineTriggerSchema, UpdatePipelineTriggerSchema, TriggerPipelineSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestDiscussionSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetRunnerSchema, GetPipelineVariablesSchema, UpdatePipelineMetadataSchema, DeletePipelineSchema, PipelineReportSchema, WaitForPipelineSchema, WaitForPipelineJobSchema, PlayPipelineJobsSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetProtectedTagSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupMilestonesSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListPipelinesSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, ListGroupMembersSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListProjectRunnersSchema, ListReleasesSchema, ListTagsSchema, ListProtectedTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, ListSnippetsSchema, GetSnippetSchema, CreateSnippetSchema, UpdateSnippetSchema, DeleteSnippetSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWebhookSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, } from "../schemas.js";
4
+ import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, ErasePipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateGroupMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWebhookSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, ProtectTagSchema, UnprotectBranchSchema, UnprotectTagSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupMilestoneSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWebhookSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, EditGroupMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, CreateDeploymentSchema, UpdateDeploymentSchema, DeploymentApprovalSchema, ListDeploymentMergeRequestsSchema, GetDraftNoteSchema, GetEnvironmentSchema, UpdateEnvironmentSchema, StopEnvironmentSchema, StopStaleEnvironmentsSchema, DeleteReviewAppEnvironmentsSchema, PipelineTriggerIdSchema, ListPipelineTriggersSchema, CreatePipelineTriggerSchema, UpdatePipelineTriggerSchema, TriggerPipelineSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestDiscussionSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetGroupMilestoneSchema, GetGroupMilestoneIssuesSchema, GetGroupMilestoneMergeRequestsSchema, GetGroupMilestoneBurndownEventsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, PipelineJobControlSchema, GetPipelineSchema, GetRunnerSchema, GetPipelineVariablesSchema, UpdatePipelineMetadataSchema, DeletePipelineSchema, PipelineReportSchema, WaitForPipelineSchema, WaitForPipelineJobSchema, PlayPipelineJobsSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetProtectedTagSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupMilestonesSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListPipelinesSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, ListGroupMembersSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListProjectRunnersSchema, ListReleasesSchema, ListTagsSchema, ListProtectedTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, ListSnippetsSchema, GetSnippetSchema, CreateSnippetSchema, UpdateSnippetSchema, DeleteSnippetSchema, UnapproveMergeRequestSchema, RebaseMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWebhookSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, } from "../schemas.js";
5
5
  import { GitLabApiRequestSchema } from "./gitlab-api-request.js";
6
6
  const IS_REMOTE = SSE || STREAMABLE_HTTP;
7
7
  // Define all available tools
@@ -1007,7 +1007,7 @@ export const allTools = [
1007
1007
  name: "download_attachment",
1008
1008
  description: IS_REMOTE
1009
1009
  ? "Download an uploaded file from a project (images returned inline as base64, other files returned as download URL)"
1010
- : "Download an uploaded file from a project (images returned as base64; use local_path to save to disk)",
1010
+ : "Download an uploaded file from a project (images returned as base64; other files, or images with local_path, are saved as new files under the download directory)",
1011
1011
  inputSchema: IS_REMOTE
1012
1012
  ? toJSONSchema(DownloadAttachmentRemoteSchema)
1013
1013
  : toJSONSchema(DownloadAttachmentSchema),
@@ -1397,6 +1397,11 @@ export const allTools = [
1397
1397
  description: "Get one runner by ID",
1398
1398
  inputSchema: toJSONSchema(GetRunnerSchema),
1399
1399
  },
1400
+ {
1401
+ name: "rebase_merge_request",
1402
+ description: "Rebase the source branch of a merge request onto its target branch",
1403
+ inputSchema: toJSONSchema(RebaseMergeRequestSchema),
1404
+ },
1400
1405
  // --- Explicit opt-in raw API. Not in `all` and not activatable via discover_tools. ---
1401
1406
  {
1402
1407
  name: "gitlab_api_request",
@@ -1570,8 +1575,14 @@ export const readOnlyTools = new Set([
1570
1575
  // Stays in readOnlyTools so readonly mode can still run queries, but mutations
1571
1576
  // are allowed in other permission modes and rejected only at call time.
1572
1577
  const MIXED_ACCESS_TOOLS = new Set(["execute_graphql", "gitlab_api_request"]);
1578
+ // Read-only on GitLab, but in stdio mode they save files to the local disk. They stay
1579
+ // listed in readonly mode, where the handlers refuse anything that would write a file
1580
+ // (only inline images are returned), and never advertise readOnlyHint.
1581
+ const LOCAL_FILE_WRITE_TOOLS = new Set(IS_REMOTE ? [] : ["download_attachment", "download_job_artifacts"]);
1573
1582
  export function advertisesReadOnly(toolName) {
1574
- return readOnlyTools.has(toolName) && !MIXED_ACCESS_TOOLS.has(toolName);
1583
+ return (readOnlyTools.has(toolName) &&
1584
+ !MIXED_ACCESS_TOOLS.has(toolName) &&
1585
+ !LOCAL_FILE_WRITE_TOOLS.has(toolName));
1575
1586
  }
1576
1587
  // Define which tools are destructive (data loss potential)
1577
1588
  export const destructiveTools = new Set([
@@ -1614,6 +1625,7 @@ export const destructiveTools = new Set([
1614
1625
  "protect_tag",
1615
1626
  "update_default_branch",
1616
1627
  "merge_merge_request",
1628
+ "rebase_merge_request",
1617
1629
  "push_files",
1618
1630
  "delete_project_variable",
1619
1631
  "delete_group_variable",
@@ -1817,6 +1829,7 @@ export const TOOLSET_DEFINITIONS = [
1817
1829
  isDefault: false,
1818
1830
  tools: new Set([
1819
1831
  "merge_merge_request",
1832
+ "rebase_merge_request",
1820
1833
  "approve_merge_request",
1821
1834
  "unapprove_merge_request",
1822
1835
  "get_merge_request_approval_state",
@@ -2254,8 +2267,15 @@ const discoverTool = allTools.find(t => t.name === "discover_tools");
2254
2267
  if (discoverTool) {
2255
2268
  discoverTool.description = `Discover and activate additional tool categories for this session. Available categories: ${[...DISCOVERABLE_TOOLSET_IDS].join(", ")}. Already-active categories are listed in the response. Explicit-only toolsets are omitted; list them in GITLAB_TOOLSETS.`;
2256
2269
  }
2270
+ function acceptsProjectOrGroupId(inputSchema) {
2271
+ const properties = inputSchema?.properties;
2272
+ return (typeof properties === "object" &&
2273
+ properties !== null &&
2274
+ ("project_id" in properties || "group_id" in properties));
2275
+ }
2276
+ const allToolNames = new Set(allTools.map(tool => tool.name));
2257
2277
  for (const tool of allTools) {
2258
- tool.description = getToolDescription(tool.name, tool.description, readOnlyTools.has(tool.name), destructiveTools.has(tool.name));
2278
+ tool.description = getToolDescription(tool.name, tool.description, readOnlyTools.has(tool.name), destructiveTools.has(tool.name), acceptsProjectOrGroupId(tool.inputSchema), allToolNames);
2259
2279
  }
2260
2280
  export function parseEnabledToolsets(raw) {
2261
2281
  if (!raw || raw.trim() === "") {
@@ -2,6 +2,7 @@ const TOOL_GUIDANCE = {
2
2
  merge_merge_request: "Use this only after checking the merge request approval, conflict, and pipeline state; use `approve_merge_request` to approve rather than merge. The operation changes repository state and may squash commits, schedule auto-merge, or delete the source branch, so it requires merge permission and returns GitLab's merge result or a mergeability error. Pass `sha` from `get_merge_request` (`sha` or `diff_refs.head_sha`); GitLab 19.2+ groups may reject merges without it.",
3
3
  approve_merge_request: "Use this to record an approval on an existing merge request; it does not merge the request or change its source branch. The operation changes review state, may require re-authentication or approval permission, and returns the updated approval result or a permission/state error.",
4
4
  unapprove_merge_request: "Use this to remove the current user's approval from an existing merge request; use `merge_merge_request` only when you intend to merge. The operation changes review state and requires approval permission, and GitLab returns the updated result or an error when the request or approval is unavailable.",
5
+ rebase_merge_request: "Use this when the source branch is behind its target branch; use `get_merge_request_conflicts` to inspect conflicts and `merge_merge_request` to merge. The operation rewrites the source branch history with a force push and requires permission to push to that branch. With `wait` true (default), it polls until the rebase ends and returns the merge request: GitLab reports a failed rebase in `merge_error`, but that field can also keep a message from an earlier attempt, so compare `sha` with its value before the rebase. If the rebase still runs after `timeout_seconds`, it returns a timeout error while GitLab continues the rebase. With `wait` false, it returns GitLab's enqueue response (`rebase_in_progress` true) at once. Set `skip_ci` to skip the CI pipeline for the rebased branch.",
5
6
  get_merge_request_approval_state: "Use this to inspect approval rules and approvers before deciding whether a merge request can be merged; use `approve_merge_request` to change approval state. It is read-only and returns the approval-state response, while missing requests, unsupported GitLab versions, and permission failures are reported as errors.",
6
7
  get_merge_request_conflicts: "Use this to inspect merge conflicts before attempting `merge_merge_request`; it reports conflicts and does not resolve them. It is read-only, requires access to the project and merge request, and returns GitLab's conflict data or an error when the request cannot be evaluated.",
7
8
  list_merge_request_pipelines: "Use this to inspect pipelines associated with one merge request; use `list_pipelines` for project-wide pipeline filtering. It is read-only and paginated, requires project access, and returns pipeline records or GitLab errors for invalid identifiers, missing resources, or rate limits.",
@@ -34,8 +35,8 @@ const TOOL_GUIDANCE = {
34
35
  update_issue_description_patch: "Use this for a targeted search/replace or unified-diff change to an issue description; use `dry_run` before applying an uncertain patch and `create_note` when an audit summary is wanted. It changes the issue description when not dry-running, requires issue-edit permission, and returns the patch result or a mismatch/validation/permission error.",
35
36
  delete_issue: "Use this only after confirming the issue and intended permanent removal; use `update_issue` to close or edit an issue without deleting it. The operation permanently removes issue data, requires delete permission, and returns the deletion result or a missing-resource, permission, or policy error.",
36
37
  delete_issue_link: "Use this to remove an existing relationship between two issues; use `list_issue_links` or `get_issue_link` to verify the link first. The operation changes issue relationships, requires issue-edit permission, and returns the result or an error when the link is missing or access is denied.",
37
- download_job_artifacts: "Use this to retrieve a pipeline job's artifact archive; remote HTTP mode returns a download URL while local mode saves the archive to a local path. It is read-only but may create a local file in stdio mode, requires job/project access, and returns the download result or an artifact/permission error.",
38
- download_attachment: "Use this to retrieve a previously uploaded project attachment; remote mode returns inline base64 for images or a download URL, while local mode can save to a path. It is read-only with respect to GitLab, requires project access, and returns the file content or an attachment/permission error.",
38
+ download_job_artifacts: "Use this to retrieve a pipeline job's artifact archive; remote HTTP mode returns a download URL while local mode saves the archive to a local path. It is read-only on GitLab but creates a local file in stdio mode (rejected in readonly mode), requires job/project access, and returns the download result or an artifact/permission error.",
39
+ download_attachment: "Use this to retrieve a previously uploaded project attachment; remote mode returns inline base64 for images or a download URL, while local mode saves the file as a new file under the download directory (GITLAB_DOWNLOAD_DIR, default .gitlab-mcp-downloads). It is read-only with respect to GitLab but creates a local file in stdio mode (readonly mode returns images inline and rejects everything else), never replaces an existing file, requires project access, and returns the file content or an attachment/permission error.",
39
40
  gitlab_api_request: "Send one GitLab REST call under /api/v4. Readonly mode allows GET only, and modify mode rejects DELETE. Enable it only by listing api in GITLAB_TOOLSETS; all, discover_tools, and GITLAB_TOOLS do not. Prompt injection can drive this tool, so operators who rely on group, project, or tool restrictions must not enable it. GITLAB_ALLOWED_PROJECT_IDS is enforced only for /projects/:id paths; other endpoints are not constrained.",
40
41
  execute_graphql: "Use this only when a supported GitLab REST tool does not cover the requested operation; prefer a typed tool when one exists. The query is sent directly to GitLab and can include mutations when permission allows, so callers must treat it as potentially state-changing and handle GraphQL errors in the returned response.",
41
42
  discover_tools: "Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data.",
@@ -62,46 +63,80 @@ const TOOL_GUIDANCE = {
62
63
  orbit_get_status: "Use this to check whether Orbit indexing has completed before trusting `orbit_query` results; results reflect the last index cycle, not real-time state. It is read-only and free of credit charges, and returns the indexing status or a permission error.",
63
64
  orbit_list_tools: "Use this to see which MCP tool definitions Orbit itself exposes; use `orbit_query` to run graph queries directly. It is read-only and free of credit charges, and returns the tool definition list or a permission error.",
64
65
  };
65
- const PARAMETER_GUIDANCE = "When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented.";
66
+ const PARAMETER_GUIDANCE = "`project_id`/`group_id` take a numeric ID or URL-encoded path.";
66
67
  function ensureSentence(text) {
67
68
  const trimmed = text.trim();
68
69
  return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
69
70
  }
70
- function getLifecycleGuidance(name) {
71
+ // Candidate get_* names for a list_* resource: list_pipelines -> get_pipeline,
72
+ // list_branches -> get_branch, list_policies -> get_policy, list_diffs -> get_diffs.
73
+ function matchingGetToolNames(resource) {
74
+ const candidates = [`get_${resource}`];
75
+ if (resource.endsWith("ies"))
76
+ candidates.push(`get_${resource.slice(0, -3)}y`);
77
+ if (resource.endsWith("es"))
78
+ candidates.push(`get_${resource.slice(0, -2)}`);
79
+ if (resource.endsWith("s"))
80
+ candidates.push(`get_${resource.slice(0, -1)}`);
81
+ return candidates;
82
+ }
83
+ // Candidate list_*/search_* names for a get_* resource: get_pipeline -> list_pipelines,
84
+ // get_branch -> list_branches, get_policy -> list_policies, get_diffs -> list_diffs.
85
+ function matchingDiscoveryToolNames(resource) {
86
+ const plurals = [resource, `${resource}s`, `${resource}es`];
87
+ if (resource.endsWith("y"))
88
+ plurals.push(`${resource.slice(0, -1)}ies`);
89
+ return plurals.flatMap(plural => [`list_${plural}`, `search_${plural}`]);
90
+ }
91
+ // Sibling hints are only added when the sibling tool exists, so a description
92
+ // never points at a tool the server does not have.
93
+ function getLifecycleGuidance(name, toolNames) {
71
94
  const verb = name.split("_")[0] ?? "use";
95
+ const resource = name.slice(verb.length + 1);
72
96
  if (verb === "list") {
73
- return "Use this for a collection of resources; choose the corresponding get tool when you already know the single resource to inspect.";
97
+ return matchingGetToolNames(resource).some(candidate => toolNames.has(candidate))
98
+ ? "For one known resource, use the matching get tool."
99
+ : "";
74
100
  }
75
101
  if (verb === "get") {
76
- return "Use this for a known resource or result; choose the corresponding list or search tool when you need to discover multiple resources.";
102
+ return matchingDiscoveryToolNames(resource).some(candidate => toolNames.has(candidate))
103
+ ? "To find resources, use the matching list or search tool."
104
+ : "";
77
105
  }
78
106
  if (verb === "create") {
79
- return "Use this for a new resource or action; choose the corresponding update or edit tool when the resource already exists.";
107
+ return toolNames.has(`update_${resource}`) || toolNames.has(`edit_${resource}`)
108
+ ? "If the resource already exists, use the update tool."
109
+ : "";
80
110
  }
81
111
  if (verb === "update" || verb === "edit") {
82
- return "Use this for an existing resource; choose the corresponding create tool for a new resource and a note tool for discussion-only text.";
112
+ if (!toolNames.has(`create_${resource}`)) {
113
+ return "";
114
+ }
115
+ return toolNames.has(`create_${resource}_note`)
116
+ ? "Use the create tool for new resources and a note tool for comments."
117
+ : "Use the create tool for new resources.";
83
118
  }
84
119
  if (verb === "delete" || verb === "remove") {
85
- return "Use this only after verifying the target; choose a get or list tool first when you need to inspect state without changing it.";
120
+ return "Verify the target first with a get or list tool.";
86
121
  }
87
122
  if (verb === "search") {
88
- return "Use this to discover matching content; choose a typed get or list tool when the target identifier is already known.";
123
+ return "If the identifier is known, use a typed get or list tool.";
89
124
  }
90
125
  if (verb === "validate") {
91
- return "Use this to check configuration without applying it; choose a create or update tool only after validation succeeds.";
126
+ return "Checks without applying; create or update only after it passes.";
92
127
  }
93
- return "Use this for the specific operation described; choose a sibling tool when you need a different resource or lifecycle action.";
128
+ return "";
94
129
  }
95
130
  function getBehaviorGuidance(readOnly, destructive) {
96
131
  if (readOnly) {
97
- return "It is read-only and does not mutate GitLab data; missing resources, invalid identifiers, insufficient permission, and rate limits are returned as errors.";
132
+ return "Read-only; missing resources, denied access, and rate limits return errors.";
98
133
  }
99
134
  if (destructive) {
100
- return "It changes or removes remote GitLab data and may be irreversible; it requires the necessary project or group permission and returns validation, conflict, permission, or rate-limit errors.";
135
+ return "Changes or removes GitLab data and may be irreversible; needs permission.";
101
136
  }
102
- return "It changes remote GitLab state and requires the necessary project or group permission; GitLab returns validation, conflict, permission, or rate-limit errors instead of silently applying an invalid request.";
137
+ return "Changes GitLab data; needs permission and returns validation or conflict errors.";
103
138
  }
104
- export function getToolDescription(name, baseDescription, readOnly, destructive) {
139
+ export function getToolDescription(name, baseDescription, readOnly, destructive, acceptsProjectOrGroupId, toolNames) {
105
140
  const explicitGuidance = TOOL_GUIDANCE[name];
106
141
  if (explicitGuidance) {
107
142
  return `${ensureSentence(baseDescription)} ${explicitGuidance}`;
@@ -110,5 +145,12 @@ export function getToolDescription(name, baseDescription, readOnly, destructive)
110
145
  if (normalizedDescription.length >= 180) {
111
146
  return normalizedDescription;
112
147
  }
113
- return `${normalizedDescription} ${getLifecycleGuidance(name)} ${getBehaviorGuidance(readOnly, destructive)} ${PARAMETER_GUIDANCE}`;
148
+ return [
149
+ normalizedDescription,
150
+ getLifecycleGuidance(name, toolNames),
151
+ getBehaviorGuidance(readOnly, destructive),
152
+ acceptsProjectOrGroupId ? PARAMETER_GUIDANCE : "",
153
+ ]
154
+ .filter(Boolean)
155
+ .join(" ");
114
156
  }
@@ -112,8 +112,7 @@ function skipInsignificantGraphQL(source, index) {
112
112
  function extractTopLevelMutationFields(normalized) {
113
113
  const fields = [];
114
114
  const mutationRegex = /(?:^|[;},]\s*)mutation\b[^({]*(?:\([^)]*\))?\s*\{/g;
115
- let match;
116
- while ((match = mutationRegex.exec(normalized)) !== null) {
115
+ while (mutationRegex.exec(normalized) !== null) {
117
116
  let i = mutationRegex.lastIndex;
118
117
  let braceDepth = 1;
119
118
  let parenDepth = 0;
@@ -82,8 +82,11 @@ export function resolveSafeExistingPath(inputPath, label = "path", baseDir) {
82
82
  * it if needed. Rejects symlink components and verifies the final real path
83
83
  * remains inside the base.
84
84
  */
85
- export function resolveSafeOutputDir(inputDir, label = "local_path", baseDir) {
85
+ export function resolveSafeOutputDir(inputDir, label = "local_path", baseDir, options = {}) {
86
86
  const relative = assertSafeRelativePath(inputDir, label);
87
+ if (options.rejectDotNames && relative.split(path.sep).some(isDotName)) {
88
+ throw new Error(`Invalid ${label}: names starting with a dot are not allowed.`);
89
+ }
87
90
  const baseReal = resolveTrustedBaseDir(baseDir);
88
91
  assertNoSymlinkComponents(baseReal, relative, label);
89
92
  const absolute = path.resolve(baseReal, relative);
@@ -136,6 +139,11 @@ const WRITE_OPEN_FLAGS = fs.constants.O_WRONLY |
136
139
  fs.constants.O_CREAT |
137
140
  fs.constants.O_TRUNC |
138
141
  (fs.constants.O_NOFOLLOW ?? 0);
142
+ // O_EXCL also refuses a symlink in the final component.
143
+ const EXCLUSIVE_WRITE_OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL;
144
+ function isDotName(part) {
145
+ return part.startsWith(".") && part !== ".";
146
+ }
139
147
  /**
140
148
  * Validate and read a file in one step so callers never reuse a path string
141
149
  * after a symlink swap (final-component TOCTOU mitigation via O_NOFOLLOW).
@@ -171,7 +179,7 @@ export function readSafeExistingFile(inputPath, label = "path", baseDir) {
171
179
  * Validate an output destination and open a write stream immediately so the
172
180
  * path is not handed back for a separate createWriteStream call.
173
181
  */
174
- export function openSafeOutputWriteStream(filename, inputDir, label = "local_path", baseDir) {
182
+ export function openSafeOutputWriteStream(filename, inputDir, label = "local_path", baseDir, options = {}) {
175
183
  if (!filename ||
176
184
  filename === "." ||
177
185
  filename === ".." ||
@@ -180,10 +188,27 @@ export function openSafeOutputWriteStream(filename, inputDir, label = "local_pat
180
188
  filename.includes(path.sep)) {
181
189
  throw new Error(`Invalid ${label}: directory separators are not allowed in filename.`);
182
190
  }
191
+ if (options.rejectDotNames && isDotName(filename)) {
192
+ throw new Error("Invalid filename: names starting with a dot are not allowed.");
193
+ }
183
194
  const dirReal = inputDir
184
- ? resolveSafeOutputDir(inputDir, label, baseDir)
195
+ ? resolveSafeOutputDir(inputDir, label, baseDir, options)
185
196
  : resolveTrustedBaseDir(baseDir);
186
197
  const dest = path.join(dirReal, filename);
198
+ if (options.exclusive) {
199
+ let fd;
200
+ try {
201
+ fd = fs.openSync(dest, EXCLUSIVE_WRITE_OPEN_FLAGS, 0o666);
202
+ }
203
+ catch (err) {
204
+ if (err.code === "EEXIST") {
205
+ throw new Error(`${dest} already exists. Choose another ${label} or remove the file first.`);
206
+ }
207
+ throw err;
208
+ }
209
+ const stream = fs.createWriteStream(null, { fd, autoClose: true });
210
+ return { stream, path: dest };
211
+ }
187
212
  try {
188
213
  const st = fs.lstatSync(dest);
189
214
  if (st.isSymbolicLink()) {
@@ -203,6 +228,40 @@ export function openSafeOutputWriteStream(filename, inputDir, label = "local_pat
203
228
  });
204
229
  return { stream, path: dest };
205
230
  }
231
+ export const DEFAULT_DOWNLOAD_DIR = ".gitlab-mcp-downloads";
232
+ /**
233
+ * Resolve (and create on first use) the directory that downloads are saved under.
234
+ * The default sits in the working directory, which may be a cloned repository, so it
235
+ * must be a real directory rather than a symlink planted there. A directory the
236
+ * server creates gets a `.gitignore` so its contents are never committed by accident.
237
+ */
238
+ export function resolveDownloadRoot(configured) {
239
+ const root = path.resolve(configured || DEFAULT_DOWNLOAD_DIR);
240
+ let st;
241
+ try {
242
+ st = fs.lstatSync(root);
243
+ }
244
+ catch (err) {
245
+ if (err.code !== "ENOENT") {
246
+ throw err;
247
+ }
248
+ }
249
+ if (st?.isSymbolicLink() && !configured) {
250
+ throw new Error(`Invalid download directory: ${root} is a symbolic link. Remove it or set GITLAB_DOWNLOAD_DIR.`);
251
+ }
252
+ if (!st) {
253
+ fs.mkdirSync(root, { recursive: true });
254
+ try {
255
+ fs.writeFileSync(path.join(root, ".gitignore"), "*\n", { flag: "wx" });
256
+ }
257
+ catch (err) {
258
+ if (err.code !== "EEXIST") {
259
+ throw err;
260
+ }
261
+ }
262
+ }
263
+ return fs.realpathSync(root);
264
+ }
206
265
  /**
207
266
  * Estimate the number of merge commits that will be added based on the merge method.
208
267
  */
@@ -245,7 +304,7 @@ export function filterDiffsByPatterns(diffs, excludedFilePatterns) {
245
304
  try {
246
305
  return new RegExp(pattern);
247
306
  }
248
- catch (e) {
307
+ catch {
249
308
  console.warn(`Invalid regex pattern ignored: ${pattern}`);
250
309
  return null;
251
310
  }