@zereight/mcp-gitlab 2.0.23 → 2.0.24
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.md +4 -1
- package/build/index.js +232 -73
- package/build/schemas.js +58 -1
- package/build/test/test-list-project-members.js +132 -0
- package/build/test/test-merge-request-approvals.js +187 -0
- package/build/test/test-mr-diffs-filter.js +132 -0
- package/build/test/utils/mock-gitlab-server.js +54 -0
- package/package.json +4 -4
- package/build/test/readonly-mcp-tests.js +0 -381
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ GitLab MCP(Model Context Protocol) Server. **Includes bug fixes and improvements
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
-
### Using with Claude
|
|
13
|
+
### Using with Claude Code, Codex, Antigravity, OpenCode, Copilot, Cline, Roo Code, Cursor, Kilo Code, Amp Code
|
|
14
14
|
|
|
15
15
|
When using with the Claude App, you need to set up your API key and URLs directly.
|
|
16
16
|
|
|
@@ -545,6 +545,9 @@ The token is stored per session (identified by `mcp-session-id` header) and reus
|
|
|
545
545
|
93. `delete_release` - Delete a release from a GitLab project (does not delete the associated tag)
|
|
546
546
|
94. `create_release_evidence` - Create release evidence for an existing release (GitLab Premium/Ultimate only)
|
|
547
547
|
95. `download_release_asset` - Download a release asset file by direct asset path
|
|
548
|
+
96. `approve_merge_request` - Approve a merge request (requires appropriate permissions)
|
|
549
|
+
97. `unapprove_merge_request` - Unapprove a previously approved merge request
|
|
550
|
+
98. `get_merge_request_approval_state` - Get the approval state of a merge request including approval rules and who has approved
|
|
548
551
|
<!-- TOOLS-END -->
|
|
549
552
|
|
|
550
553
|
</details>
|
package/build/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
26
26
|
import express from "express";
|
|
27
27
|
import fetchCookie from "fetch-cookie";
|
|
28
28
|
import fs from "node:fs";
|
|
29
|
+
import os from "node:os";
|
|
29
30
|
import { HttpProxyAgent } from "http-proxy-agent";
|
|
30
31
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
31
32
|
import nodeFetch from "node-fetch";
|
|
@@ -49,7 +50,7 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
49
50
|
GitLabDiscussionSchema,
|
|
50
51
|
// Draft Notes Schemas
|
|
51
52
|
GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabTreeSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
52
|
-
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
|
|
53
|
+
ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, } from "./schemas.js";
|
|
53
54
|
import { randomUUID } from "node:crypto";
|
|
54
55
|
import { pino } from "pino";
|
|
55
56
|
const logger = pino({
|
|
@@ -236,77 +237,113 @@ const clientPool = new GitLabClientPool({
|
|
|
236
237
|
poolMaxSize: GITLAB_POOL_MAX_SIZE,
|
|
237
238
|
});
|
|
238
239
|
// Create cookie jar with clean Netscape file parsing
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
// Resolve cookie path once using os.homedir() for cross-platform support
|
|
241
|
+
const resolvedCookiePath = GITLAB_AUTH_COOKIE_PATH
|
|
242
|
+
? GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
|
|
243
|
+
? path.join(os.homedir(), GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
244
|
+
: GITLAB_AUTH_COOKIE_PATH
|
|
245
|
+
: null;
|
|
246
|
+
const createCookieJar = async () => {
|
|
247
|
+
if (!resolvedCookiePath)
|
|
241
248
|
return null;
|
|
249
|
+
let cookieContent;
|
|
242
250
|
try {
|
|
243
|
-
|
|
244
|
-
? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2))
|
|
245
|
-
: GITLAB_AUTH_COOKIE_PATH;
|
|
246
|
-
const jar = new CookieJar();
|
|
247
|
-
const cookieContent = fs.readFileSync(cookiePath, "utf8");
|
|
248
|
-
cookieContent.split("\n").forEach(line => {
|
|
249
|
-
// Handle #HttpOnly_ prefix
|
|
250
|
-
if (line.startsWith("#HttpOnly_")) {
|
|
251
|
-
line = line.slice(10);
|
|
252
|
-
}
|
|
253
|
-
// Skip comments and empty lines
|
|
254
|
-
if (line.startsWith("#") || !line.trim()) {
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
258
|
-
const parts = line.split("\t");
|
|
259
|
-
if (parts.length >= 7) {
|
|
260
|
-
const [domain, , path, secure, expires, name, value] = parts;
|
|
261
|
-
// Build cookie string in standard format
|
|
262
|
-
const secureFlag = secure === "TRUE" ? "; Secure" : "";
|
|
263
|
-
const expiresFlag = expires === "0"
|
|
264
|
-
? ""
|
|
265
|
-
: `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
|
|
266
|
-
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secureFlag}${expiresFlag}`;
|
|
267
|
-
// Use tough-cookie's parse function for robust parsing
|
|
268
|
-
const cookie = parseCookie(cookieStr);
|
|
269
|
-
if (cookie) {
|
|
270
|
-
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
271
|
-
jar.setCookieSync(cookie, url);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
return jar;
|
|
251
|
+
cookieContent = await fs.promises.readFile(resolvedCookiePath, "utf8");
|
|
276
252
|
}
|
|
277
253
|
catch (error) {
|
|
278
|
-
logger.error("
|
|
254
|
+
logger.error({ error, path: resolvedCookiePath }, "Failed to read cookie file");
|
|
279
255
|
return null;
|
|
280
256
|
}
|
|
257
|
+
const jar = new CookieJar();
|
|
258
|
+
for (let line of cookieContent.split("\n")) {
|
|
259
|
+
// Handle #HttpOnly_ prefix
|
|
260
|
+
if (line.startsWith("#HttpOnly_")) {
|
|
261
|
+
line = line.slice(10);
|
|
262
|
+
}
|
|
263
|
+
// Skip comments and empty lines
|
|
264
|
+
if (line.startsWith("#") || !line.trim()) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Parse Netscape format: domain, flag, path, secure, expires, name, value
|
|
268
|
+
const parts = line.split("\t");
|
|
269
|
+
if (parts.length >= 7) {
|
|
270
|
+
const [domain, , cookiePath, secure, expires, name, value] = parts;
|
|
271
|
+
// Build cookie string in standard format
|
|
272
|
+
const secureFlag = secure === "TRUE" ? "; Secure" : "";
|
|
273
|
+
const expiresFlag = expires === "0"
|
|
274
|
+
? ""
|
|
275
|
+
: `; Expires=${new Date(Number.parseInt(expires, 10) * 1000).toUTCString()}`;
|
|
276
|
+
const cookieStr = `${name}=${value}; Domain=${domain}; Path=${cookiePath}${secureFlag}${expiresFlag}`;
|
|
277
|
+
// Use tough-cookie's parse function for robust parsing
|
|
278
|
+
const cookie = parseCookie(cookieStr);
|
|
279
|
+
if (cookie) {
|
|
280
|
+
const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`;
|
|
281
|
+
jar.setCookieSync(cookie, url);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return jar;
|
|
281
286
|
};
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
287
|
+
// Cookie jar and fetch - reloaded when cookie file changes
|
|
288
|
+
let cookieJar = null;
|
|
289
|
+
let fetch = nodeFetch;
|
|
290
|
+
let lastCookieMtime = 0;
|
|
291
|
+
let cookieReloadLock = null; // Mutex to prevent parallel reloads
|
|
292
|
+
// Auth proxies may redirect and set cookies on the first request. We make a throwaway
|
|
293
|
+
// request so subsequent requests have the correct cookies. Reset when cookies reload.
|
|
294
|
+
let initialSessionRequestMade = false;
|
|
295
|
+
// Cookie jar is loaded on first request via reloadCookiesIfChanged (lastCookieMtime=0 triggers load)
|
|
296
|
+
async function reloadCookiesIfChanged() {
|
|
297
|
+
if (!resolvedCookiePath)
|
|
288
298
|
return;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// Check if we already have GitLab session cookies
|
|
293
|
-
const gitlabCookies = cookieJar.getCookiesSync(baseUrl);
|
|
294
|
-
const hasSessionCookie = gitlabCookies.some(cookie => cookie.key === "_gitlab_session" || cookie.key === "remember_user_token");
|
|
295
|
-
if (!hasSessionCookie) {
|
|
299
|
+
if (cookieReloadLock)
|
|
300
|
+
return cookieReloadLock;
|
|
301
|
+
cookieReloadLock = (async () => {
|
|
296
302
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
const mtime = (await fs.promises.stat(resolvedCookiePath)).mtimeMs;
|
|
304
|
+
if (mtime !== lastCookieMtime) {
|
|
305
|
+
logger.info({ oldMtime: lastCookieMtime, newMtime: mtime }, lastCookieMtime === 0 ? "Loading cookie file" : "Cookie file changed, reloading");
|
|
306
|
+
lastCookieMtime = mtime;
|
|
307
|
+
const newJar = await createCookieJar();
|
|
308
|
+
cookieJar = newJar;
|
|
309
|
+
fetch = newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch;
|
|
310
|
+
initialSessionRequestMade = false;
|
|
311
|
+
}
|
|
306
312
|
}
|
|
307
313
|
catch {
|
|
308
|
-
//
|
|
314
|
+
// File deleted or inaccessible - clear cached cookies
|
|
315
|
+
if (cookieJar) {
|
|
316
|
+
logger.info("Cookie file removed, clearing cached cookies");
|
|
317
|
+
cookieJar = null;
|
|
318
|
+
fetch = nodeFetch;
|
|
319
|
+
lastCookieMtime = 0;
|
|
320
|
+
initialSessionRequestMade = false;
|
|
321
|
+
}
|
|
309
322
|
}
|
|
323
|
+
})();
|
|
324
|
+
try {
|
|
325
|
+
await cookieReloadLock;
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
cookieReloadLock = null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async function ensureSessionForRequest() {
|
|
332
|
+
if (!resolvedCookiePath)
|
|
333
|
+
return;
|
|
334
|
+
await reloadCookiesIfChanged();
|
|
335
|
+
if (!cookieJar || initialSessionRequestMade)
|
|
336
|
+
return;
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetch(`${getEffectiveApiUrl()}/user`, {
|
|
339
|
+
...getFetchConfig(),
|
|
340
|
+
redirect: "follow",
|
|
341
|
+
});
|
|
342
|
+
// 401 means auth failed but the request completed - cookies were still exchanged
|
|
343
|
+
initialSessionRequestMade = response.ok || response.status === 401;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
logger.debug("Session warmup request failed, will retry on next request");
|
|
310
347
|
}
|
|
311
348
|
}
|
|
312
349
|
const sessionAuthStore = new AsyncLocalStorage();
|
|
@@ -424,6 +461,21 @@ const allTools = [
|
|
|
424
461
|
description: "Merge a merge request in a GitLab project",
|
|
425
462
|
inputSchema: toJSONSchema(MergeMergeRequestSchema),
|
|
426
463
|
},
|
|
464
|
+
{
|
|
465
|
+
name: "approve_merge_request",
|
|
466
|
+
description: "Approve a merge request. Requires appropriate permissions.",
|
|
467
|
+
inputSchema: toJSONSchema(ApproveMergeRequestSchema),
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: "unapprove_merge_request",
|
|
471
|
+
description: "Unapprove a previously approved merge request. Requires appropriate permissions.",
|
|
472
|
+
inputSchema: toJSONSchema(UnapproveMergeRequestSchema),
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
name: "get_merge_request_approval_state",
|
|
476
|
+
description: "Get the approval state of a merge request including approval rules and who has approved",
|
|
477
|
+
inputSchema: toJSONSchema(GetMergeRequestApprovalStateSchema),
|
|
478
|
+
},
|
|
427
479
|
{
|
|
428
480
|
name: "execute_graphql",
|
|
429
481
|
description: "Execute a GitLab GraphQL query",
|
|
@@ -998,6 +1050,7 @@ const readOnlyTools = new Set([
|
|
|
998
1050
|
"list_releases",
|
|
999
1051
|
"get_release",
|
|
1000
1052
|
"download_release_asset",
|
|
1053
|
+
"get_merge_request_approval_state",
|
|
1001
1054
|
]);
|
|
1002
1055
|
// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
|
|
1003
1056
|
const wikiToolNames = new Set([
|
|
@@ -2177,6 +2230,68 @@ async function mergeMergeRequest(projectId, options, mergeRequestIid) {
|
|
|
2177
2230
|
await handleGitLabError(response);
|
|
2178
2231
|
return GitLabMergeRequestSchema.parse(await response.json());
|
|
2179
2232
|
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Approve a merge request
|
|
2235
|
+
*
|
|
2236
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2237
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2238
|
+
* @param {string} sha - Optional SHA to approve (for validation that MR hasn't changed)
|
|
2239
|
+
* @param {string} approvalPassword - Optional password for approvals requiring re-authentication
|
|
2240
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after approving
|
|
2241
|
+
*/
|
|
2242
|
+
async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPassword) {
|
|
2243
|
+
projectId = decodeURIComponent(projectId);
|
|
2244
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approve`);
|
|
2245
|
+
const body = {};
|
|
2246
|
+
if (sha) {
|
|
2247
|
+
body.sha = sha;
|
|
2248
|
+
}
|
|
2249
|
+
if (approvalPassword) {
|
|
2250
|
+
body.approval_password = approvalPassword;
|
|
2251
|
+
}
|
|
2252
|
+
const response = await fetch(url.toString(), {
|
|
2253
|
+
...getFetchConfig(),
|
|
2254
|
+
method: "POST",
|
|
2255
|
+
body: JSON.stringify(body),
|
|
2256
|
+
});
|
|
2257
|
+
await handleGitLabError(response);
|
|
2258
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Unapprove a previously approved merge request
|
|
2262
|
+
*
|
|
2263
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2264
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2265
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after unapproving
|
|
2266
|
+
*/
|
|
2267
|
+
async function unapproveMergeRequest(projectId, mergeRequestIid) {
|
|
2268
|
+
projectId = decodeURIComponent(projectId);
|
|
2269
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/unapprove`);
|
|
2270
|
+
const response = await fetch(url.toString(), {
|
|
2271
|
+
...getFetchConfig(),
|
|
2272
|
+
method: "POST",
|
|
2273
|
+
body: JSON.stringify({}),
|
|
2274
|
+
});
|
|
2275
|
+
await handleGitLabError(response);
|
|
2276
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Get the approval state of a merge request
|
|
2280
|
+
*
|
|
2281
|
+
* @param {string} projectId - The ID or URL-encoded path of the project
|
|
2282
|
+
* @param {string | number} mergeRequestIid - The internal ID of the merge request
|
|
2283
|
+
* @returns {Promise<GitLabMergeRequestApprovalState>} The approval state
|
|
2284
|
+
*/
|
|
2285
|
+
async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
2286
|
+
projectId = decodeURIComponent(projectId);
|
|
2287
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
|
|
2288
|
+
const response = await fetch(url.toString(), {
|
|
2289
|
+
...getFetchConfig(),
|
|
2290
|
+
method: "GET",
|
|
2291
|
+
});
|
|
2292
|
+
await handleGitLabError(response);
|
|
2293
|
+
return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
|
|
2294
|
+
}
|
|
2180
2295
|
/**
|
|
2181
2296
|
* Create a new note (comment) on an issue or merge request
|
|
2182
2297
|
* 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
|
|
@@ -3572,7 +3687,8 @@ async function myIssues(options = {}) {
|
|
|
3572
3687
|
async function listProjectMembers(projectId, options = {}) {
|
|
3573
3688
|
projectId = decodeURIComponent(projectId);
|
|
3574
3689
|
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
3575
|
-
const
|
|
3690
|
+
const membersPath = options.include_inheritance ? "members/all" : "members";
|
|
3691
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/${membersPath}`);
|
|
3576
3692
|
// Add query parameters
|
|
3577
3693
|
if (options.query)
|
|
3578
3694
|
url.searchParams.append("query", options.query);
|
|
@@ -3924,6 +4040,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3924
4040
|
// Fallback for non-remote-auth mode or if session is not found
|
|
3925
4041
|
return handleToolCall(request.params);
|
|
3926
4042
|
});
|
|
4043
|
+
/**
|
|
4044
|
+
* Filter diffs by excluded file patterns
|
|
4045
|
+
* Safely handles invalid regex patterns by logging and ignoring them
|
|
4046
|
+
*
|
|
4047
|
+
* @param diffs - Array of diff objects with new_path property
|
|
4048
|
+
* @param excludedFilePatterns - Array of regex patterns to exclude
|
|
4049
|
+
* @returns Filtered array of diffs
|
|
4050
|
+
*/
|
|
4051
|
+
function filterDiffsByPatterns(diffs, excludedFilePatterns) {
|
|
4052
|
+
if (!excludedFilePatterns?.length)
|
|
4053
|
+
return diffs;
|
|
4054
|
+
const regexPatterns = excludedFilePatterns
|
|
4055
|
+
.map((pattern) => {
|
|
4056
|
+
try {
|
|
4057
|
+
return new RegExp(pattern);
|
|
4058
|
+
}
|
|
4059
|
+
catch (e) {
|
|
4060
|
+
console.warn(`Invalid regex pattern ignored: ${pattern}`);
|
|
4061
|
+
return null;
|
|
4062
|
+
}
|
|
4063
|
+
})
|
|
4064
|
+
.filter((regex) => regex !== null);
|
|
4065
|
+
if (regexPatterns.length === 0)
|
|
4066
|
+
return diffs;
|
|
4067
|
+
const matchesAnyPattern = (path) => {
|
|
4068
|
+
if (!path)
|
|
4069
|
+
return false;
|
|
4070
|
+
return regexPatterns.some((regex) => regex.test(path));
|
|
4071
|
+
};
|
|
4072
|
+
return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
|
|
4073
|
+
}
|
|
3927
4074
|
async function handleToolCall(params) {
|
|
3928
4075
|
try {
|
|
3929
4076
|
if (!params.arguments) {
|
|
@@ -4026,17 +4173,7 @@ async function handleToolCall(params) {
|
|
|
4026
4173
|
case "get_branch_diffs": {
|
|
4027
4174
|
const args = GetBranchDiffsSchema.parse(params.arguments);
|
|
4028
4175
|
const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight);
|
|
4029
|
-
|
|
4030
|
-
const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern));
|
|
4031
|
-
// Helper function to check if a path matches any regex pattern
|
|
4032
|
-
const matchesAnyPattern = (path) => {
|
|
4033
|
-
if (!path)
|
|
4034
|
-
return false;
|
|
4035
|
-
return regexPatterns.some(regex => regex.test(path));
|
|
4036
|
-
};
|
|
4037
|
-
// Filter out files that match any of the regex patterns on new files
|
|
4038
|
-
diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path));
|
|
4039
|
-
}
|
|
4176
|
+
diffResp.diffs = filterDiffsByPatterns(diffResp.diffs, args.excluded_file_patterns);
|
|
4040
4177
|
return {
|
|
4041
4178
|
content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }],
|
|
4042
4179
|
};
|
|
@@ -4178,8 +4315,9 @@ async function handleToolCall(params) {
|
|
|
4178
4315
|
case "get_merge_request_diffs": {
|
|
4179
4316
|
const args = GetMergeRequestDiffsSchema.parse(params.arguments);
|
|
4180
4317
|
const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.view);
|
|
4318
|
+
const filteredDiffs = filterDiffsByPatterns(diffs, args.excluded_file_patterns);
|
|
4181
4319
|
return {
|
|
4182
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
4320
|
+
content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
|
|
4183
4321
|
};
|
|
4184
4322
|
}
|
|
4185
4323
|
case "list_merge_request_diffs": {
|
|
@@ -4219,6 +4357,27 @@ async function handleToolCall(params) {
|
|
|
4219
4357
|
content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
|
|
4220
4358
|
};
|
|
4221
4359
|
}
|
|
4360
|
+
case "approve_merge_request": {
|
|
4361
|
+
const args = ApproveMergeRequestSchema.parse(params.arguments);
|
|
4362
|
+
const approvalState = await approveMergeRequest(args.project_id, args.merge_request_iid, args.sha, args.approval_password);
|
|
4363
|
+
return {
|
|
4364
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4365
|
+
};
|
|
4366
|
+
}
|
|
4367
|
+
case "unapprove_merge_request": {
|
|
4368
|
+
const args = UnapproveMergeRequestSchema.parse(params.arguments);
|
|
4369
|
+
const approvalState = await unapproveMergeRequest(args.project_id, args.merge_request_iid);
|
|
4370
|
+
return {
|
|
4371
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4374
|
+
case "get_merge_request_approval_state": {
|
|
4375
|
+
const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
|
|
4376
|
+
const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
|
|
4377
|
+
return {
|
|
4378
|
+
content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
|
|
4379
|
+
};
|
|
4380
|
+
}
|
|
4222
4381
|
case "mr_discussions": {
|
|
4223
4382
|
const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
|
|
4224
4383
|
const { project_id, merge_request_iid, ...options } = args;
|
package/build/schemas.js
CHANGED
|
@@ -1003,7 +1003,7 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
|
|
|
1003
1003
|
excluded_file_patterns: z
|
|
1004
1004
|
.array(z.string())
|
|
1005
1005
|
.optional()
|
|
1006
|
-
.describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
|
|
1006
|
+
.describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
|
|
1007
1007
|
});
|
|
1008
1008
|
export const GetMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1009
1009
|
merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
|
|
@@ -1039,8 +1039,61 @@ export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
|
|
|
1039
1039
|
squash_commit_message: z.string().optional().describe("Custom squash commit message"),
|
|
1040
1040
|
squash: z.boolean().optional().default(false).describe("Squash commits into a single commit when merging"),
|
|
1041
1041
|
});
|
|
1042
|
+
// Merge Request Approval schemas
|
|
1043
|
+
export const ApproveMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1044
|
+
merge_request_iid: z.coerce.string().describe("The IID of the merge request to approve"),
|
|
1045
|
+
sha: z.string().optional().describe("The HEAD of the merge request. Optional, but used to ensure the merge request hasn't changed since you last reviewed it"),
|
|
1046
|
+
approval_password: z.string().optional().describe("Current user's password. Required if 'Require user re-authentication to approve' is enabled in the project settings"),
|
|
1047
|
+
});
|
|
1048
|
+
export const UnapproveMergeRequestSchema = ProjectParamsSchema.extend({
|
|
1049
|
+
merge_request_iid: z.coerce.string().describe("The IID of the merge request to unapprove"),
|
|
1050
|
+
});
|
|
1051
|
+
// Merge Request Approval State response schema
|
|
1052
|
+
export const GitLabApprovalUserSchema = z.object({
|
|
1053
|
+
id: z.coerce.string(),
|
|
1054
|
+
username: z.string(),
|
|
1055
|
+
name: z.string(),
|
|
1056
|
+
state: z.string(),
|
|
1057
|
+
avatar_url: z.string().nullable().optional(),
|
|
1058
|
+
web_url: z.string(),
|
|
1059
|
+
});
|
|
1060
|
+
export const GitLabApprovalRuleSchema = z.object({
|
|
1061
|
+
id: z.coerce.string(),
|
|
1062
|
+
name: z.string(),
|
|
1063
|
+
rule_type: z.string(),
|
|
1064
|
+
eligible_approvers: z.array(GitLabApprovalUserSchema).optional(),
|
|
1065
|
+
approvals_required: z.number(),
|
|
1066
|
+
users: z.array(GitLabApprovalUserSchema).optional(),
|
|
1067
|
+
groups: z.array(z.object({
|
|
1068
|
+
id: z.coerce.string(),
|
|
1069
|
+
name: z.string(),
|
|
1070
|
+
path: z.string(),
|
|
1071
|
+
full_path: z.string(),
|
|
1072
|
+
avatar_url: z.string().nullable().optional(),
|
|
1073
|
+
web_url: z.string(),
|
|
1074
|
+
})).optional(),
|
|
1075
|
+
contains_hidden_groups: z.boolean().optional(),
|
|
1076
|
+
approved_by: z.array(GitLabApprovalUserSchema).optional(),
|
|
1077
|
+
source_rule: z.object({
|
|
1078
|
+
id: z.coerce.string().optional(),
|
|
1079
|
+
name: z.string().optional(),
|
|
1080
|
+
rule_type: z.string().optional(),
|
|
1081
|
+
}).nullable().optional(),
|
|
1082
|
+
approved: z.boolean().optional(),
|
|
1083
|
+
});
|
|
1084
|
+
export const GitLabMergeRequestApprovalStateSchema = z.object({
|
|
1085
|
+
approval_rules_overwritten: z.boolean().optional(),
|
|
1086
|
+
rules: z.array(GitLabApprovalRuleSchema).optional(),
|
|
1087
|
+
});
|
|
1088
|
+
export const GetMergeRequestApprovalStateSchema = ProjectParamsSchema.extend({
|
|
1089
|
+
merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
|
|
1090
|
+
});
|
|
1042
1091
|
export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
|
|
1043
1092
|
view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
|
|
1093
|
+
excluded_file_patterns: z
|
|
1094
|
+
.array(z.string())
|
|
1095
|
+
.optional()
|
|
1096
|
+
.describe('Array of regex patterns to exclude files from the diff results. Each pattern is a JavaScript-compatible regular expression that matches file paths to ignore. Examples: ["^vendor/", "^test/mocks/", "\\.spec\\.ts$", "package-lock\\.json"]'),
|
|
1044
1097
|
});
|
|
1045
1098
|
export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
|
|
1046
1099
|
page: z.number().optional().describe("Page number for pagination (default: 1)"),
|
|
@@ -1674,6 +1727,10 @@ export const ListProjectMembersSchema = z.object({
|
|
|
1674
1727
|
query: z.string().optional().describe("Search for members by name or username"),
|
|
1675
1728
|
user_ids: z.array(z.number()).optional().describe("Filter by user IDs"),
|
|
1676
1729
|
skip_users: z.array(z.number()).optional().describe("User IDs to exclude"),
|
|
1730
|
+
include_inheritance: z
|
|
1731
|
+
.boolean()
|
|
1732
|
+
.optional()
|
|
1733
|
+
.describe("Include inherited members. Defaults to false."),
|
|
1677
1734
|
per_page: z.number().optional().describe("Number of items per page (default: 20, max: 100)"),
|
|
1678
1735
|
page: z.number().optional().describe("Page number for pagination (default: 1)"),
|
|
1679
1736
|
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, test, before, after, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
|
|
5
|
+
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
6
|
+
const TEST_PROJECT_ID = '123';
|
|
7
|
+
const directMembers = [
|
|
8
|
+
{
|
|
9
|
+
id: 1,
|
|
10
|
+
username: 'direct-user',
|
|
11
|
+
name: 'Direct User',
|
|
12
|
+
state: 'active',
|
|
13
|
+
avatar_url: null,
|
|
14
|
+
web_url: 'https://gitlab.mock/users/1',
|
|
15
|
+
access_level: 30,
|
|
16
|
+
created_at: '2024-01-01T00:00:00Z'
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
const inheritedMembers = [
|
|
20
|
+
{
|
|
21
|
+
id: 2,
|
|
22
|
+
username: 'inherited-user',
|
|
23
|
+
name: 'Inherited User',
|
|
24
|
+
state: 'active',
|
|
25
|
+
avatar_url: null,
|
|
26
|
+
web_url: 'https://gitlab.mock/users/2',
|
|
27
|
+
access_level: 20,
|
|
28
|
+
created_at: '2024-01-02T00:00:00Z'
|
|
29
|
+
}
|
|
30
|
+
];
|
|
31
|
+
async function callListProjectMembers(args, env) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const proc = spawn('node', ['build/index.js'], {
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
env: {
|
|
36
|
+
...process.env,
|
|
37
|
+
...env,
|
|
38
|
+
GITLAB_READ_ONLY_MODE: 'true'
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
let output = '';
|
|
42
|
+
let errorOutput = '';
|
|
43
|
+
proc.stdout?.on('data', d => output += d);
|
|
44
|
+
proc.stderr?.on('data', d => errorOutput += d);
|
|
45
|
+
proc.on('close', (code) => {
|
|
46
|
+
if (code !== 0)
|
|
47
|
+
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
48
|
+
const line = output.split('\n').find(l => l.startsWith('{'));
|
|
49
|
+
if (!line)
|
|
50
|
+
return reject(new Error('No JSON output found'));
|
|
51
|
+
try {
|
|
52
|
+
const response = JSON.parse(line);
|
|
53
|
+
if (response.error) {
|
|
54
|
+
reject(response.error);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const content = response.result?.content?.[0]?.text;
|
|
58
|
+
if (content) {
|
|
59
|
+
try {
|
|
60
|
+
resolve(JSON.parse(content));
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
resolve(response.result);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
reject(e);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
proc.stdin?.end(JSON.stringify({
|
|
76
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
77
|
+
params: { name: "list_project_members", arguments: args }
|
|
78
|
+
}) + '\n');
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
describe('list_project_members', () => {
|
|
82
|
+
let mockGitLab;
|
|
83
|
+
let mockGitLabUrl;
|
|
84
|
+
let directEndpointHit = false;
|
|
85
|
+
let inheritedEndpointHit = false;
|
|
86
|
+
before(async () => {
|
|
87
|
+
const mockPort = await findMockServerPort(9000);
|
|
88
|
+
mockGitLab = new MockGitLabServer({
|
|
89
|
+
port: mockPort,
|
|
90
|
+
validTokens: [MOCK_TOKEN]
|
|
91
|
+
});
|
|
92
|
+
await mockGitLab.start();
|
|
93
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
94
|
+
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/members`, (req, res) => {
|
|
95
|
+
directEndpointHit = true;
|
|
96
|
+
res.json(directMembers);
|
|
97
|
+
});
|
|
98
|
+
mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/members/all`, (req, res) => {
|
|
99
|
+
inheritedEndpointHit = true;
|
|
100
|
+
res.json([...directMembers, ...inheritedMembers]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
directEndpointHit = false;
|
|
105
|
+
inheritedEndpointHit = false;
|
|
106
|
+
});
|
|
107
|
+
after(async () => {
|
|
108
|
+
await mockGitLab.stop();
|
|
109
|
+
});
|
|
110
|
+
test('lists direct project members', async () => {
|
|
111
|
+
const members = await callListProjectMembers({ project_id: TEST_PROJECT_ID }, {
|
|
112
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
113
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
114
|
+
});
|
|
115
|
+
assert.ok(Array.isArray(members), 'Response should be an array');
|
|
116
|
+
assert.strictEqual(members.length, 1, 'Should return direct members');
|
|
117
|
+
assert.strictEqual(members[0].username, 'direct-user');
|
|
118
|
+
assert.strictEqual(directEndpointHit, true, 'Direct members endpoint should be called');
|
|
119
|
+
assert.strictEqual(inheritedEndpointHit, false, 'Inherited members endpoint should not be called');
|
|
120
|
+
});
|
|
121
|
+
test('lists project members including inheritance', async () => {
|
|
122
|
+
const members = await callListProjectMembers({ project_id: TEST_PROJECT_ID, include_inheritance: true }, {
|
|
123
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
124
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
125
|
+
});
|
|
126
|
+
assert.ok(Array.isArray(members), 'Response should be an array');
|
|
127
|
+
assert.strictEqual(members.length, 2, 'Should return inherited members');
|
|
128
|
+
assert.strictEqual(members[1].username, 'inherited-user');
|
|
129
|
+
assert.strictEqual(inheritedEndpointHit, true, 'Inherited members endpoint should be called');
|
|
130
|
+
assert.strictEqual(directEndpointHit, false, 'Direct members endpoint should not be called');
|
|
131
|
+
});
|
|
132
|
+
});
|