@zereight/mcp-gitlab 2.0.23 → 2.0.25

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([
@@ -1762,7 +1815,7 @@ async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
1762
1815
  const data = await response.json();
1763
1816
  return GitLabDiscussionNoteSchema.parse(data);
1764
1817
  }
1765
- async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by) {
1818
+ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by, per_page, page) {
1766
1819
  projectId = decodeURIComponent(projectId); // Decode project ID
1767
1820
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes`);
1768
1821
  if (sort) {
@@ -1771,6 +1824,12 @@ async function getMergeRequestNotes(projectId, mergeRequestIid, sort, order_by)
1771
1824
  if (order_by) {
1772
1825
  url.searchParams.append("order_by", order_by);
1773
1826
  }
1827
+ if (per_page) {
1828
+ url.searchParams.append("per_page", per_page.toString());
1829
+ }
1830
+ if (page) {
1831
+ url.searchParams.append("page", page.toString());
1832
+ }
1774
1833
  const response = await fetch(url.toString(), {
1775
1834
  ...getFetchConfig(),
1776
1835
  method: "GET",
@@ -2177,6 +2236,68 @@ async function mergeMergeRequest(projectId, options, mergeRequestIid) {
2177
2236
  await handleGitLabError(response);
2178
2237
  return GitLabMergeRequestSchema.parse(await response.json());
2179
2238
  }
2239
+ /**
2240
+ * Approve a merge request
2241
+ *
2242
+ * @param {string} projectId - The ID or URL-encoded path of the project
2243
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
2244
+ * @param {string} sha - Optional SHA to approve (for validation that MR hasn't changed)
2245
+ * @param {string} approvalPassword - Optional password for approvals requiring re-authentication
2246
+ * @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after approving
2247
+ */
2248
+ async function approveMergeRequest(projectId, mergeRequestIid, sha, approvalPassword) {
2249
+ projectId = decodeURIComponent(projectId);
2250
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approve`);
2251
+ const body = {};
2252
+ if (sha) {
2253
+ body.sha = sha;
2254
+ }
2255
+ if (approvalPassword) {
2256
+ body.approval_password = approvalPassword;
2257
+ }
2258
+ const response = await fetch(url.toString(), {
2259
+ ...getFetchConfig(),
2260
+ method: "POST",
2261
+ body: JSON.stringify(body),
2262
+ });
2263
+ await handleGitLabError(response);
2264
+ return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2265
+ }
2266
+ /**
2267
+ * Unapprove a previously approved merge request
2268
+ *
2269
+ * @param {string} projectId - The ID or URL-encoded path of the project
2270
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
2271
+ * @returns {Promise<GitLabMergeRequestApprovalState>} The approval state after unapproving
2272
+ */
2273
+ async function unapproveMergeRequest(projectId, mergeRequestIid) {
2274
+ projectId = decodeURIComponent(projectId);
2275
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/unapprove`);
2276
+ const response = await fetch(url.toString(), {
2277
+ ...getFetchConfig(),
2278
+ method: "POST",
2279
+ body: JSON.stringify({}),
2280
+ });
2281
+ await handleGitLabError(response);
2282
+ return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2283
+ }
2284
+ /**
2285
+ * Get the approval state of a merge request
2286
+ *
2287
+ * @param {string} projectId - The ID or URL-encoded path of the project
2288
+ * @param {string | number} mergeRequestIid - The internal ID of the merge request
2289
+ * @returns {Promise<GitLabMergeRequestApprovalState>} The approval state
2290
+ */
2291
+ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
2292
+ projectId = decodeURIComponent(projectId);
2293
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/approval_state`);
2294
+ const response = await fetch(url.toString(), {
2295
+ ...getFetchConfig(),
2296
+ method: "GET",
2297
+ });
2298
+ await handleGitLabError(response);
2299
+ return GitLabMergeRequestApprovalStateSchema.parse(await response.json());
2300
+ }
2180
2301
  /**
2181
2302
  * Create a new note (comment) on an issue or merge request
2182
2303
  * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수
@@ -3572,7 +3693,8 @@ async function myIssues(options = {}) {
3572
3693
  async function listProjectMembers(projectId, options = {}) {
3573
3694
  projectId = decodeURIComponent(projectId);
3574
3695
  const effectiveProjectId = getEffectiveProjectId(projectId);
3575
- const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/members`);
3696
+ const membersPath = options.include_inheritance ? "members/all" : "members";
3697
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/${membersPath}`);
3576
3698
  // Add query parameters
3577
3699
  if (options.query)
3578
3700
  url.searchParams.append("query", options.query);
@@ -3924,6 +4046,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3924
4046
  // Fallback for non-remote-auth mode or if session is not found
3925
4047
  return handleToolCall(request.params);
3926
4048
  });
4049
+ /**
4050
+ * Filter diffs by excluded file patterns
4051
+ * Safely handles invalid regex patterns by logging and ignoring them
4052
+ *
4053
+ * @param diffs - Array of diff objects with new_path property
4054
+ * @param excludedFilePatterns - Array of regex patterns to exclude
4055
+ * @returns Filtered array of diffs
4056
+ */
4057
+ function filterDiffsByPatterns(diffs, excludedFilePatterns) {
4058
+ if (!excludedFilePatterns?.length)
4059
+ return diffs;
4060
+ const regexPatterns = excludedFilePatterns
4061
+ .map((pattern) => {
4062
+ try {
4063
+ return new RegExp(pattern);
4064
+ }
4065
+ catch (e) {
4066
+ console.warn(`Invalid regex pattern ignored: ${pattern}`);
4067
+ return null;
4068
+ }
4069
+ })
4070
+ .filter((regex) => regex !== null);
4071
+ if (regexPatterns.length === 0)
4072
+ return diffs;
4073
+ const matchesAnyPattern = (path) => {
4074
+ if (!path)
4075
+ return false;
4076
+ return regexPatterns.some((regex) => regex.test(path));
4077
+ };
4078
+ return diffs.filter((diff) => !matchesAnyPattern(diff.new_path));
4079
+ }
3927
4080
  async function handleToolCall(params) {
3928
4081
  try {
3929
4082
  if (!params.arguments) {
@@ -4026,17 +4179,7 @@ async function handleToolCall(params) {
4026
4179
  case "get_branch_diffs": {
4027
4180
  const args = GetBranchDiffsSchema.parse(params.arguments);
4028
4181
  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
- }
4182
+ diffResp.diffs = filterDiffsByPatterns(diffResp.diffs, args.excluded_file_patterns);
4040
4183
  return {
4041
4184
  content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }],
4042
4185
  };
@@ -4142,7 +4285,7 @@ async function handleToolCall(params) {
4142
4285
  }
4143
4286
  case "get_merge_request_notes": {
4144
4287
  const args = GetMergeRequestNotesSchema.parse(params.arguments);
4145
- const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by);
4288
+ const notes = await getMergeRequestNotes(args.project_id, args.merge_request_iid, args.sort, args.order_by, args.per_page, args.page);
4146
4289
  return {
4147
4290
  content: [{ type: "text", text: JSON.stringify(notes, null, 2) }],
4148
4291
  };
@@ -4178,8 +4321,9 @@ async function handleToolCall(params) {
4178
4321
  case "get_merge_request_diffs": {
4179
4322
  const args = GetMergeRequestDiffsSchema.parse(params.arguments);
4180
4323
  const diffs = await getMergeRequestDiffs(args.project_id, args.merge_request_iid, args.source_branch, args.view);
4324
+ const filteredDiffs = filterDiffsByPatterns(diffs, args.excluded_file_patterns);
4181
4325
  return {
4182
- content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }],
4326
+ content: [{ type: "text", text: JSON.stringify(filteredDiffs, null, 2) }],
4183
4327
  };
4184
4328
  }
4185
4329
  case "list_merge_request_diffs": {
@@ -4219,6 +4363,27 @@ async function handleToolCall(params) {
4219
4363
  content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }],
4220
4364
  };
4221
4365
  }
4366
+ case "approve_merge_request": {
4367
+ const args = ApproveMergeRequestSchema.parse(params.arguments);
4368
+ const approvalState = await approveMergeRequest(args.project_id, args.merge_request_iid, args.sha, args.approval_password);
4369
+ return {
4370
+ content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
4371
+ };
4372
+ }
4373
+ case "unapprove_merge_request": {
4374
+ const args = UnapproveMergeRequestSchema.parse(params.arguments);
4375
+ const approvalState = await unapproveMergeRequest(args.project_id, args.merge_request_iid);
4376
+ return {
4377
+ content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
4378
+ };
4379
+ }
4380
+ case "get_merge_request_approval_state": {
4381
+ const args = GetMergeRequestApprovalStateSchema.parse(params.arguments);
4382
+ const approvalState = await getMergeRequestApprovalState(args.project_id, args.merge_request_iid);
4383
+ return {
4384
+ content: [{ type: "text", text: JSON.stringify(approvalState, null, 2) }],
4385
+ };
4386
+ }
4222
4387
  case "mr_discussions": {
4223
4388
  const args = ListMergeRequestDiscussionsSchema.parse(params.arguments);
4224
4389
  const { project_id, merge_request_iid, ...options } = args;
package/build/schemas.js CHANGED
@@ -856,6 +856,8 @@ export const GetMergeRequestNotesSchema = ProjectParamsSchema.extend({
856
856
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
857
857
  sort: z.enum(["asc", "desc"]).optional().describe("The sort order of the notes"),
858
858
  order_by: z.enum(["created_at", "updated_at"]).optional().describe("The field to sort the notes by"),
859
+ per_page: z.coerce.number().optional().describe("Number of items per page"),
860
+ page: z.coerce.number().optional().describe("Page number for pagination"),
859
861
  });
860
862
  export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
861
863
  merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
@@ -1003,7 +1005,7 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1003
1005
  excluded_file_patterns: z
1004
1006
  .array(z.string())
1005
1007
  .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"]'),
1008
+ .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
1009
  });
1008
1010
  export const GetMergeRequestSchema = ProjectParamsSchema.extend({
1009
1011
  merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
@@ -1039,8 +1041,61 @@ export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
1039
1041
  squash_commit_message: z.string().optional().describe("Custom squash commit message"),
1040
1042
  squash: z.boolean().optional().default(false).describe("Squash commits into a single commit when merging"),
1041
1043
  });
1044
+ // Merge Request Approval schemas
1045
+ export const ApproveMergeRequestSchema = ProjectParamsSchema.extend({
1046
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request to approve"),
1047
+ 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"),
1048
+ approval_password: z.string().optional().describe("Current user's password. Required if 'Require user re-authentication to approve' is enabled in the project settings"),
1049
+ });
1050
+ export const UnapproveMergeRequestSchema = ProjectParamsSchema.extend({
1051
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request to unapprove"),
1052
+ });
1053
+ // Merge Request Approval State response schema
1054
+ export const GitLabApprovalUserSchema = z.object({
1055
+ id: z.coerce.string(),
1056
+ username: z.string(),
1057
+ name: z.string(),
1058
+ state: z.string(),
1059
+ avatar_url: z.string().nullable().optional(),
1060
+ web_url: z.string(),
1061
+ });
1062
+ export const GitLabApprovalRuleSchema = z.object({
1063
+ id: z.coerce.string(),
1064
+ name: z.string(),
1065
+ rule_type: z.string(),
1066
+ eligible_approvers: z.array(GitLabApprovalUserSchema).optional(),
1067
+ approvals_required: z.number(),
1068
+ users: z.array(GitLabApprovalUserSchema).optional(),
1069
+ groups: z.array(z.object({
1070
+ id: z.coerce.string(),
1071
+ name: z.string(),
1072
+ path: z.string(),
1073
+ full_path: z.string(),
1074
+ avatar_url: z.string().nullable().optional(),
1075
+ web_url: z.string(),
1076
+ })).optional(),
1077
+ contains_hidden_groups: z.boolean().optional(),
1078
+ approved_by: z.array(GitLabApprovalUserSchema).optional(),
1079
+ source_rule: z.object({
1080
+ id: z.coerce.string().optional(),
1081
+ name: z.string().optional(),
1082
+ rule_type: z.string().optional(),
1083
+ }).nullable().optional(),
1084
+ approved: z.boolean().optional(),
1085
+ });
1086
+ export const GitLabMergeRequestApprovalStateSchema = z.object({
1087
+ approval_rules_overwritten: z.boolean().optional(),
1088
+ rules: z.array(GitLabApprovalRuleSchema).optional(),
1089
+ });
1090
+ export const GetMergeRequestApprovalStateSchema = ProjectParamsSchema.extend({
1091
+ merge_request_iid: z.coerce.string().describe("The IID of the merge request"),
1092
+ });
1042
1093
  export const GetMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1043
1094
  view: z.enum(["inline", "parallel"]).optional().describe("Diff view type"),
1095
+ excluded_file_patterns: z
1096
+ .array(z.string())
1097
+ .optional()
1098
+ .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
1099
  });
1045
1100
  export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1046
1101
  page: z.number().optional().describe("Page number for pagination (default: 1)"),
@@ -1674,6 +1729,10 @@ export const ListProjectMembersSchema = z.object({
1674
1729
  query: z.string().optional().describe("Search for members by name or username"),
1675
1730
  user_ids: z.array(z.number()).optional().describe("Filter by user IDs"),
1676
1731
  skip_users: z.array(z.number()).optional().describe("User IDs to exclude"),
1732
+ include_inheritance: z
1733
+ .boolean()
1734
+ .optional()
1735
+ .describe("Include inherited members. Defaults to false."),
1677
1736
  per_page: z.number().optional().describe("Number of items per page (default: 20, max: 100)"),
1678
1737
  page: z.number().optional().describe("Page number for pagination (default: 1)"),
1679
1738
  });
@@ -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
+ });