@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 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 App, Cline, Roo Code, Cursor, Kilo Code
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
- const createCookieJar = () => {
240
- if (!GITLAB_AUTH_COOKIE_PATH)
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
- const cookiePath = GITLAB_AUTH_COOKIE_PATH.startsWith("~/")
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("Error loading cookie file:", 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
- // Initialize cookie jar and fetch
283
- const cookieJar = createCookieJar();
284
- const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch;
285
- // Ensure session is established for the current request
286
- async function ensureSessionForRequest() {
287
- if (!cookieJar || !GITLAB_AUTH_COOKIE_PATH)
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
- // Extract the base URL from GITLAB_API_URL
290
- const apiUrl = new URL(GITLAB_API_URL);
291
- const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`;
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
- // Establish session with a lightweight request
298
- await fetch(`${GITLAB_API_URL}/user`, {
299
- ...getFetchConfig(),
300
- redirect: "follow",
301
- }).catch(() => {
302
- // Ignore errors - the important thing is that cookies get set during redirects
303
- });
304
- // Small delay to ensure cookies are fully processed
305
- await new Promise(resolve => setTimeout(resolve, 100));
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
- // Intentionally ignored: session establishment errors are non-critical
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 url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/members`);
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
- if (args.excluded_file_patterns?.length) {
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(diffs, null, 2) }],
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
+ });