@ttpears/gitlab-mcp-server 1.14.1 → 1.15.1

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/dist/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { validateUserConfig } from './config.js';
3
- import { resolveWindow, fetchUserEventsInWindow, bucketBy, countByAction, buildEnvelope, } from './analytics.js';
3
+ import { resolveWindow, fetchUserEventsInWindow, fetchGroupEventsInWindow, bucketBy, countByAction, buildEnvelope, } from './analytics.js';
4
4
  // Schema for user credentials (empty object coerces to undefined for header-based auth)
5
5
  const UserCredentialsSchema = z.object({
6
6
  gitlabUrl: z.string().url().optional(),
@@ -173,9 +173,6 @@ const createIssueTool = {
173
173
  })),
174
174
  handler: async (input, client, userConfig) => {
175
175
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
176
- if (!credentials) {
177
- throw new Error('User authentication is required for creating issues. Please provide your GitLab credentials.');
178
- }
179
176
  const result = await client.createIssue(input.projectPath, input.title, input.description, credentials);
180
177
  const payload = result.createIssue;
181
178
  if (payload.errors && payload.errors.length > 0) {
@@ -204,9 +201,6 @@ const createMergeRequestTool = {
204
201
  })),
205
202
  handler: async (input, client, userConfig) => {
206
203
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
207
- if (!credentials) {
208
- throw new Error('User authentication is required for creating merge requests. Please provide your GitLab credentials.');
209
- }
210
204
  const result = await client.createMergeRequest(input.projectPath, input.title, input.sourceBranch, input.targetBranch, input.description, credentials);
211
205
  const payload = result.createMergeRequest;
212
206
  if (payload.errors && payload.errors.length > 0) {
@@ -234,9 +228,6 @@ const executeCustomQueryTool = {
234
228
  })),
235
229
  handler: async (input, client, userConfig) => {
236
230
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
237
- if (input.requiresWrite && !credentials) {
238
- throw new Error('User authentication is required for write operations. Please provide your GitLab credentials.');
239
- }
240
231
  return await client.query(input.query, input.variables, credentials, input.requiresWrite);
241
232
  },
242
233
  };
@@ -283,9 +274,6 @@ const updateIssueTool = {
283
274
  })),
284
275
  handler: async (input, client, userConfig) => {
285
276
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
286
- if (!credentials) {
287
- throw new Error('User authentication is required to update issues.');
288
- }
289
277
  const result = await client.updateIssueComposite(input.projectPath, input.iid, {
290
278
  title: input.title,
291
279
  description: input.description,
@@ -309,9 +297,6 @@ const deleteIssueTool = {
309
297
  })),
310
298
  handler: async (input, client, userConfig) => {
311
299
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
312
- if (!credentials) {
313
- throw new Error('User authentication is required for deleting issues.');
314
- }
315
300
  await client.destroyIssue(input.projectPath.trim(), input.iid.trim(), credentials);
316
301
  return { projectPath: input.projectPath, iid: input.iid, deleted: true };
317
302
  },
@@ -338,9 +323,6 @@ const updateMergeRequestTool = {
338
323
  })),
339
324
  handler: async (input, client, userConfig) => {
340
325
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
341
- if (!credentials) {
342
- throw new Error('User authentication is required to update merge requests.');
343
- }
344
326
  const result = await client.updateMergeRequestComposite(input.projectPath, input.iid, {
345
327
  title: input.title,
346
328
  description: input.description,
@@ -803,9 +785,6 @@ const managePipelineTool = {
803
785
  })),
804
786
  handler: async (input, client, userConfig) => {
805
787
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
806
- if (!credentials) {
807
- throw new Error('User authentication is required for pipeline management. Please provide your GitLab credentials.');
808
- }
809
788
  return await client.managePipeline(input.projectPath, input.pipelineIid, input.action, credentials);
810
789
  },
811
790
  };
@@ -902,6 +881,168 @@ const getNotesTool = {
902
881
  return noteable.notes;
903
882
  },
904
883
  };
884
+ const getIssueContextTool = {
885
+ name: 'get_issue_context',
886
+ title: 'Get Issue Context',
887
+ description: 'Bundle issue body, all notes (paginated up to maxNotes), related merge requests (mentioning), closing merge requests, linked issues (relates_to/blocks/is_blocked_by) into a single call. Use this instead of fanning out across get_issues + get_notes + search_merge_requests when investigating an issue.',
888
+ requiresAuth: false,
889
+ requiresWrite: false,
890
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
891
+ inputSchema: withUserAuth(z.object({
892
+ projectPath: z.string().min(1).describe('Full project path (e.g. "my-group/my-project")'),
893
+ iid: z.string().min(1).describe('Issue IID (the number shown in the GitLab UI)'),
894
+ maxNotes: z.number().int().min(1).max(500).default(100).describe('Cap on notes fetched. Default 100.'),
895
+ includeSystemNotes: z.boolean().default(false).describe('Include system-generated notes (label changes, assignment events). Default false.'),
896
+ })),
897
+ handler: async (input, client, userConfig) => {
898
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
899
+ const projectPath = input.projectPath.trim();
900
+ const iid = input.iid.trim();
901
+ const [detail, notesResult, related, closedBy, links] = await Promise.all([
902
+ client.getIssueDetail(projectPath, iid, credentials),
903
+ client.getNotes(projectPath, 'issue', iid, input.maxNotes, undefined, true, credentials),
904
+ client.getIssueRelatedMergeRequests(projectPath, iid, credentials).catch(() => []),
905
+ client.getIssueClosedBy(projectPath, iid, credentials).catch(() => []),
906
+ client.getIssueLinks(projectPath, iid, credentials).catch(() => []),
907
+ ]);
908
+ const issue = detail?.project?.issue;
909
+ if (!issue) {
910
+ throw new Error(`Issue ${projectPath}#${iid} not found.`);
911
+ }
912
+ const allNotes = Array.isArray(notesResult?.nodes) ? notesResult.nodes : [];
913
+ const notes = input.includeSystemNotes ? allNotes : allNotes.filter((n) => !n.system);
914
+ const summarizeMR = (mr) => ({
915
+ iid: mr.iid,
916
+ title: mr.title,
917
+ state: mr.state,
918
+ webUrl: mr.web_url ?? mr.webUrl,
919
+ author: mr.author?.username ?? null,
920
+ sourceBranch: mr.source_branch ?? mr.sourceBranch,
921
+ targetBranch: mr.target_branch ?? mr.targetBranch,
922
+ });
923
+ const summarizeLink = (l) => ({
924
+ iid: String(l.iid),
925
+ title: l.title,
926
+ state: l.state,
927
+ linkType: l.link_type ?? null,
928
+ webUrl: l.web_url ?? null,
929
+ projectId: l.project_id ?? null,
930
+ });
931
+ return {
932
+ project: { fullPath: projectPath },
933
+ issue,
934
+ notes: {
935
+ count: notes.length,
936
+ truncated: !!notesResult?.hasMore,
937
+ nodes: notes,
938
+ },
939
+ relatedMergeRequests: related.map(summarizeMR),
940
+ closingMergeRequests: closedBy.map(summarizeMR),
941
+ linkedIssues: links.map(summarizeLink),
942
+ };
943
+ },
944
+ };
945
+ const getMergeRequestContextTool = {
946
+ name: 'get_merge_request_context',
947
+ title: 'Get Merge Request Context',
948
+ description: 'Bundle MR body, all notes (paginated up to maxNotes, filtered to non-system by default), commits, pipeline summary, reviewers with approval state, and issues this MR will close into a single call. Use this instead of fanning out across get_merge_requests + get_notes + get_merge_request_commits + get_merge_request_pipelines when investigating an MR.',
949
+ requiresAuth: false,
950
+ requiresWrite: false,
951
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
952
+ inputSchema: withUserAuth(z.object({
953
+ projectPath: z.string().min(1).describe('Full project path (e.g. "my-group/my-project")'),
954
+ iid: z.string().min(1).describe('Merge request IID'),
955
+ maxNotes: z.number().int().min(1).max(500).default(100).describe('Cap on notes fetched. Default 100.'),
956
+ maxCommits: z.number().int().min(1).max(500).default(50).describe('Cap on commits fetched. Default 50.'),
957
+ includeSystemNotes: z.boolean().default(false).describe('Include system-generated notes. Default false.'),
958
+ })),
959
+ handler: async (input, client, userConfig) => {
960
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
961
+ const projectPath = input.projectPath.trim();
962
+ const iid = input.iid.trim();
963
+ const [detail, notesResult, commitsResult, pipelinesResult, reviewers, closesIssues] = await Promise.all([
964
+ client.getMergeRequestDetail(projectPath, iid, credentials),
965
+ client.getNotes(projectPath, 'merge_request', iid, input.maxNotes, undefined, true, credentials),
966
+ client.getMergeRequestCommits(projectPath, iid, input.maxCommits, undefined, true, credentials),
967
+ client.getMergeRequestPipelines(projectPath, iid, 5, undefined, false, credentials).catch(() => null),
968
+ client.getMergeRequestReviewers(projectPath, iid, credentials).catch(() => null),
969
+ client.getMergeRequestClosesIssues(projectPath, iid, credentials).catch(() => []),
970
+ ]);
971
+ const mr = detail?.project?.mergeRequest;
972
+ if (!mr) {
973
+ throw new Error(`Merge request ${projectPath}!${iid} not found.`);
974
+ }
975
+ const allNotes = Array.isArray(notesResult?.nodes) ? notesResult.nodes : [];
976
+ const notes = input.includeSystemNotes ? allNotes : allNotes.filter((n) => !n.system);
977
+ return {
978
+ project: { fullPath: projectPath },
979
+ mergeRequest: mr,
980
+ notes: {
981
+ count: notes.length,
982
+ truncated: !!notesResult?.hasMore,
983
+ nodes: notes,
984
+ },
985
+ commits: {
986
+ count: Array.isArray(commitsResult?.nodes) ? commitsResult.nodes.length : 0,
987
+ truncated: !!commitsResult?.hasMore,
988
+ nodes: commitsResult?.nodes ?? [],
989
+ },
990
+ pipelines: pipelinesResult ?? null,
991
+ reviewers: reviewers ?? null,
992
+ closesIssues: closesIssues.map((i) => ({
993
+ iid: String(i.iid),
994
+ title: i.title,
995
+ state: i.state,
996
+ webUrl: i.web_url ?? null,
997
+ projectId: i.project_id ?? null,
998
+ })),
999
+ };
1000
+ },
1001
+ };
1002
+ const searchNotesTool = {
1003
+ name: 'search_notes',
1004
+ title: 'Search Notes (Comments)',
1005
+ description: 'Full-text search across issue and merge request comments. Scope can be global, a project, or a group. NOTE: on self-hosted GitLab, the "notes" search scope requires Advanced Search (Elasticsearch) to be enabled — without it, this endpoint returns an error. search_gitlab does NOT search note bodies; this tool does.',
1006
+ requiresAuth: false,
1007
+ requiresWrite: false,
1008
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
1009
+ inputSchema: withUserAuth(z.object({
1010
+ search: z.string().min(1).describe('Search term. Trimmed; empty rejected.'),
1011
+ scope: z.enum(['global', 'project', 'group']).default('global').describe('Search scope. Default global.'),
1012
+ projectPath: z.string().optional().describe('Required when scope=project. Full project path.'),
1013
+ groupPath: z.string().optional().describe('Required when scope=group. Full group path.'),
1014
+ perPage: z.number().int().min(1).max(100).default(20).describe('Results per page. Default 20.'),
1015
+ page: z.number().int().min(1).default(1).describe('Page number. Default 1.'),
1016
+ })),
1017
+ handler: async (input, client, userConfig) => {
1018
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1019
+ const search = input.search.trim();
1020
+ if (!search)
1021
+ throw new Error('search_notes requires a non-empty search term.');
1022
+ if (input.scope === 'project' && !input.projectPath) {
1023
+ throw new Error('search_notes scope=project requires projectPath.');
1024
+ }
1025
+ if (input.scope === 'group' && !input.groupPath) {
1026
+ throw new Error('search_notes scope=group requires groupPath.');
1027
+ }
1028
+ const results = await client.searchNotes({
1029
+ search,
1030
+ scope: input.scope,
1031
+ projectPath: input.projectPath?.trim(),
1032
+ groupPath: input.groupPath?.trim(),
1033
+ perPage: input.perPage,
1034
+ page: input.page,
1035
+ }, credentials);
1036
+ return {
1037
+ scope: input.scope,
1038
+ search,
1039
+ page: input.page,
1040
+ perPage: input.perPage,
1041
+ count: Array.isArray(results) ? results.length : 0,
1042
+ nodes: results,
1043
+ };
1044
+ },
1045
+ };
905
1046
  const createNoteTool = {
906
1047
  name: 'create_note',
907
1048
  title: 'Create Note',
@@ -922,9 +1063,6 @@ const createNoteTool = {
922
1063
  })),
923
1064
  handler: async (input, client, userConfig) => {
924
1065
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
925
- if (!credentials) {
926
- throw new Error('User authentication is required for creating notes. Please provide your GitLab credentials.');
927
- }
928
1066
  const result = await client.createNote(input.projectPath, input.noteableType, input.iid, input.body, input.internal, credentials);
929
1067
  if (result.errors && result.errors.length > 0) {
930
1068
  throw new Error(`Failed to create note: ${result.errors.join(', ')}`);
@@ -944,9 +1082,6 @@ const deleteNoteTool = {
944
1082
  })),
945
1083
  handler: async (input, client, userConfig) => {
946
1084
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
947
- if (!credentials) {
948
- throw new Error('User authentication is required for deleting notes.');
949
- }
950
1085
  await client.destroyNote(input.noteId.trim(), credentials);
951
1086
  return { noteId: input.noteId, deleted: true };
952
1087
  },
@@ -964,9 +1099,6 @@ const updateNoteTool = {
964
1099
  })),
965
1100
  handler: async (input, client, userConfig) => {
966
1101
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
967
- if (!credentials) {
968
- throw new Error('User authentication is required for updating notes.');
969
- }
970
1102
  const result = await client.updateNote(input.noteId.trim(), input.body, credentials);
971
1103
  return result.note;
972
1104
  },
@@ -1363,9 +1495,6 @@ const createBroadcastMessageTool = {
1363
1495
  inputSchema: withUserAuth(z.object(BroadcastMessageFields)),
1364
1496
  handler: async (input, client, userConfig) => {
1365
1497
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1366
- if (!credentials) {
1367
- throw new Error('User authentication is required for creating broadcast messages.');
1368
- }
1369
1498
  const { userCredentials, ...body } = input;
1370
1499
  return client.createBroadcastMessage(body, credentials);
1371
1500
  },
@@ -1392,9 +1521,6 @@ const updateBroadcastMessageTool = {
1392
1521
  })),
1393
1522
  handler: async (input, client, userConfig) => {
1394
1523
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1395
- if (!credentials) {
1396
- throw new Error('User authentication is required for updating broadcast messages.');
1397
- }
1398
1524
  const { id, userCredentials, ...body } = input;
1399
1525
  return client.updateBroadcastMessage(id, body, credentials);
1400
1526
  },
@@ -1411,9 +1537,6 @@ const deleteBroadcastMessageTool = {
1411
1537
  })),
1412
1538
  handler: async (input, client, userConfig) => {
1413
1539
  const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1414
- if (!credentials) {
1415
- throw new Error('User authentication is required for deleting broadcast messages.');
1416
- }
1417
1540
  await client.deleteBroadcastMessage(input.id, credentials);
1418
1541
  return { id: input.id, deleted: true };
1419
1542
  },
@@ -1612,6 +1735,100 @@ const analyticsUserSummaryTool = {
1612
1735
  return buildEnvelope({ type: 'user', identifier: user }, window, events, { byProject, byDay }, truncated);
1613
1736
  },
1614
1737
  };
1738
+ const analyticsGroupSummaryTool = {
1739
+ name: 'analytics_group_summary',
1740
+ title: 'Group Activity Summary',
1741
+ description: 'Aggregated activity summary for an entire group (optionally including subgroups) over a time window — totals by action type (pushes, MRs opened/merged, comments, approvals), with breakdowns by project, by contributor, and by day. Use this to answer "what did this team do" without fanning out across list_project_events yourself.',
1742
+ requiresAuth: false,
1743
+ requiresWrite: false,
1744
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
1745
+ inputSchema: withUserAuth(z.object({
1746
+ group: z
1747
+ .string()
1748
+ .min(1)
1749
+ .describe('Group full path (e.g. "my-group" or "my-group/sub-group")'),
1750
+ since: z
1751
+ .string()
1752
+ .describe('ISO date/datetime, inclusive lower bound (e.g. "2026-04-01" or "2026-04-01T00:00:00Z")'),
1753
+ until: z
1754
+ .string()
1755
+ .optional()
1756
+ .describe('ISO date/datetime, inclusive upper bound. Defaults to now.'),
1757
+ includeSubgroups: z
1758
+ .boolean()
1759
+ .default(true)
1760
+ .describe('Recurse into subgroup projects. Defaults to true.'),
1761
+ topContributors: z
1762
+ .number()
1763
+ .int()
1764
+ .min(1)
1765
+ .max(100)
1766
+ .default(10)
1767
+ .describe('Cap on the byUser[] list (sorted by event count desc). Default 10.'),
1768
+ maxEvents: z
1769
+ .number()
1770
+ .int()
1771
+ .min(1)
1772
+ .max(10000)
1773
+ .optional()
1774
+ .describe('Global cap on events fetched across all projects. Defaults to 2000; envelope flags truncated:true if reached.'),
1775
+ maxProjects: z
1776
+ .number()
1777
+ .int()
1778
+ .min(1)
1779
+ .max(2000)
1780
+ .optional()
1781
+ .describe('Cap on projects scanned. Defaults to 500.'),
1782
+ })),
1783
+ handler: async (input, client, userConfig) => {
1784
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1785
+ const group = input.group.trim();
1786
+ const window = resolveWindow(input.since, input.until);
1787
+ const { events, truncated, projects, projectsTruncated } = await fetchGroupEventsInWindow(client, group, {
1788
+ window,
1789
+ maxEvents: input.maxEvents,
1790
+ includeSubgroups: input.includeSubgroups,
1791
+ maxProjects: input.maxProjects,
1792
+ }, credentials);
1793
+ const projectPathById = new Map();
1794
+ for (const p of projects)
1795
+ projectPathById.set(p.id, p.fullPath);
1796
+ const byProject = [];
1797
+ for (const [projectId, bucket] of bucketBy(events, (e) => e.projectId)) {
1798
+ byProject.push({
1799
+ projectId,
1800
+ projectPath: projectPathById.get(projectId) ?? null,
1801
+ totals: countByAction(bucket),
1802
+ });
1803
+ }
1804
+ byProject.sort((a, b) => b.totals.events - a.totals.events);
1805
+ const byUserAll = [];
1806
+ for (const [username, bucket] of bucketBy(events, (e) => e.authorUsername)) {
1807
+ byUserAll.push({ username, totals: countByAction(bucket) });
1808
+ }
1809
+ byUserAll.sort((a, b) => b.totals.events - a.totals.events);
1810
+ const byUser = byUserAll.slice(0, input.topContributors);
1811
+ const byDay = [];
1812
+ for (const [date, bucket] of bucketBy(events, (e) => e.createdAt.toISOString().slice(0, 10))) {
1813
+ byDay.push({ date, totals: countByAction(bucket) });
1814
+ }
1815
+ byDay.sort((a, b) => a.date.localeCompare(b.date));
1816
+ const envelope = buildEnvelope({ type: 'group', identifier: group }, window, events, {
1817
+ byProject,
1818
+ byUser,
1819
+ byDay,
1820
+ projectsScanned: projects.length,
1821
+ contributorsTotal: byUserAll.length,
1822
+ }, truncated);
1823
+ if (projectsTruncated) {
1824
+ envelope.warnings = [
1825
+ ...envelope.warnings,
1826
+ `Group has more projects than maxProjects cap (${input.maxProjects ?? 500}); some projects were not scanned.`,
1827
+ ];
1828
+ }
1829
+ return envelope;
1830
+ },
1831
+ };
1615
1832
  const REVIEW_BUCKETS = ['<1d', '1-3d', '3-7d', '7-14d', '>14d'];
1616
1833
  function emptyReviewBuckets() {
1617
1834
  return { '<1d': 0, '1-3d': 0, '3-7d': 0, '7-14d': 0, '>14d': 0 };
@@ -1745,6 +1962,8 @@ export const readOnlyTools = [
1745
1962
  getMergeRequestDiffsTool,
1746
1963
  getMergeRequestCommitsTool,
1747
1964
  getNotesTool,
1965
+ getIssueContextTool,
1966
+ getMergeRequestContextTool,
1748
1967
  listMilestonesTool,
1749
1968
  listIterationsTool,
1750
1969
  getTimeTrackingTool,
@@ -1758,6 +1977,7 @@ export const readOnlyTools = [
1758
1977
  listProjectEventsTool,
1759
1978
  listMyEventsTool,
1760
1979
  analyticsUserSummaryTool,
1980
+ analyticsGroupSummaryTool,
1761
1981
  analyticsReviewBottlenecksTool,
1762
1982
  ];
1763
1983
  export const userAuthTools = [
@@ -1785,6 +2005,7 @@ export const searchTools = [
1785
2005
  searchUsersTool,
1786
2006
  searchGroupsTool,
1787
2007
  searchLabelsTool,
2008
+ searchNotesTool,
1788
2009
  browseRepositoryTool,
1789
2010
  getFileContentTool,
1790
2011
  listGroupMembersTool,