@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/README.ko.md +7 -4
- package/README.md +11 -7
- package/README.pt-BR.md +7 -4
- package/README.zh-CN.md +7 -4
- package/build/cli/args.js +2 -0
- package/build/config.js +4 -0
- package/build/deprecated-env.js +3 -1
- package/build/index.js +370 -86
- package/build/oauth-consent.js +323 -0
- package/build/oauth-proxy.js +99 -12
- package/build/oauth.js +147 -3
- package/build/schemas.js +25 -1
- package/build/stateless/types.js +2 -0
- package/build/tools/exposed-tools.js +14 -0
- package/build/tools/registry.js +24 -4
- package/build/tools/tool-descriptions.js +59 -17
- package/build/utils/graphql-query.js +1 -2
- package/build/utils/helpers.js +63 -4
- package/build/utils/safe-redirect-fetch.js +1 -1
- package/build/utils/url.js +1 -0
- package/package.json +4 -3
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
|
-
|
|
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("
|
|
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"),
|
package/build/stateless/types.js
CHANGED
|
@@ -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
|
+
}
|
package/build/tools/registry.js
CHANGED
|
@@ -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;
|
|
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) &&
|
|
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
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 "
|
|
120
|
+
return "Verify the target first with a get or list tool.";
|
|
86
121
|
}
|
|
87
122
|
if (verb === "search") {
|
|
88
|
-
return "
|
|
123
|
+
return "If the identifier is known, use a typed get or list tool.";
|
|
89
124
|
}
|
|
90
125
|
if (verb === "validate") {
|
|
91
|
-
return "
|
|
126
|
+
return "Checks without applying; create or update only after it passes.";
|
|
92
127
|
}
|
|
93
|
-
return "
|
|
128
|
+
return "";
|
|
94
129
|
}
|
|
95
130
|
function getBehaviorGuidance(readOnly, destructive) {
|
|
96
131
|
if (readOnly) {
|
|
97
|
-
return "
|
|
132
|
+
return "Read-only; missing resources, denied access, and rate limits return errors.";
|
|
98
133
|
}
|
|
99
134
|
if (destructive) {
|
|
100
|
-
return "
|
|
135
|
+
return "Changes or removes GitLab data and may be irreversible; needs permission.";
|
|
101
136
|
}
|
|
102
|
-
return "
|
|
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
|
|
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
|
-
|
|
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;
|
package/build/utils/helpers.js
CHANGED
|
@@ -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
|
|
307
|
+
catch {
|
|
249
308
|
console.warn(`Invalid regex pattern ignored: ${pattern}`);
|
|
250
309
|
return null;
|
|
251
310
|
}
|