@ttpears/gitlab-mcp-server 1.14.1 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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(),
@@ -902,6 +902,168 @@ const getNotesTool = {
902
902
  return noteable.notes;
903
903
  },
904
904
  };
905
+ const getIssueContextTool = {
906
+ name: 'get_issue_context',
907
+ title: 'Get Issue Context',
908
+ 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.',
909
+ requiresAuth: false,
910
+ requiresWrite: false,
911
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
912
+ inputSchema: withUserAuth(z.object({
913
+ projectPath: z.string().min(1).describe('Full project path (e.g. "my-group/my-project")'),
914
+ iid: z.string().min(1).describe('Issue IID (the number shown in the GitLab UI)'),
915
+ maxNotes: z.number().int().min(1).max(500).default(100).describe('Cap on notes fetched. Default 100.'),
916
+ includeSystemNotes: z.boolean().default(false).describe('Include system-generated notes (label changes, assignment events). Default false.'),
917
+ })),
918
+ handler: async (input, client, userConfig) => {
919
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
920
+ const projectPath = input.projectPath.trim();
921
+ const iid = input.iid.trim();
922
+ const [detail, notesResult, related, closedBy, links] = await Promise.all([
923
+ client.getIssueDetail(projectPath, iid, credentials),
924
+ client.getNotes(projectPath, 'issue', iid, input.maxNotes, undefined, true, credentials),
925
+ client.getIssueRelatedMergeRequests(projectPath, iid, credentials).catch(() => []),
926
+ client.getIssueClosedBy(projectPath, iid, credentials).catch(() => []),
927
+ client.getIssueLinks(projectPath, iid, credentials).catch(() => []),
928
+ ]);
929
+ const issue = detail?.project?.issue;
930
+ if (!issue) {
931
+ throw new Error(`Issue ${projectPath}#${iid} not found.`);
932
+ }
933
+ const allNotes = Array.isArray(notesResult?.nodes) ? notesResult.nodes : [];
934
+ const notes = input.includeSystemNotes ? allNotes : allNotes.filter((n) => !n.system);
935
+ const summarizeMR = (mr) => ({
936
+ iid: mr.iid,
937
+ title: mr.title,
938
+ state: mr.state,
939
+ webUrl: mr.web_url ?? mr.webUrl,
940
+ author: mr.author?.username ?? null,
941
+ sourceBranch: mr.source_branch ?? mr.sourceBranch,
942
+ targetBranch: mr.target_branch ?? mr.targetBranch,
943
+ });
944
+ const summarizeLink = (l) => ({
945
+ iid: String(l.iid),
946
+ title: l.title,
947
+ state: l.state,
948
+ linkType: l.link_type ?? null,
949
+ webUrl: l.web_url ?? null,
950
+ projectId: l.project_id ?? null,
951
+ });
952
+ return {
953
+ project: { fullPath: projectPath },
954
+ issue,
955
+ notes: {
956
+ count: notes.length,
957
+ truncated: !!notesResult?.hasMore,
958
+ nodes: notes,
959
+ },
960
+ relatedMergeRequests: related.map(summarizeMR),
961
+ closingMergeRequests: closedBy.map(summarizeMR),
962
+ linkedIssues: links.map(summarizeLink),
963
+ };
964
+ },
965
+ };
966
+ const getMergeRequestContextTool = {
967
+ name: 'get_merge_request_context',
968
+ title: 'Get Merge Request Context',
969
+ 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.',
970
+ requiresAuth: false,
971
+ requiresWrite: false,
972
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
973
+ inputSchema: withUserAuth(z.object({
974
+ projectPath: z.string().min(1).describe('Full project path (e.g. "my-group/my-project")'),
975
+ iid: z.string().min(1).describe('Merge request IID'),
976
+ maxNotes: z.number().int().min(1).max(500).default(100).describe('Cap on notes fetched. Default 100.'),
977
+ maxCommits: z.number().int().min(1).max(500).default(50).describe('Cap on commits fetched. Default 50.'),
978
+ includeSystemNotes: z.boolean().default(false).describe('Include system-generated notes. Default false.'),
979
+ })),
980
+ handler: async (input, client, userConfig) => {
981
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
982
+ const projectPath = input.projectPath.trim();
983
+ const iid = input.iid.trim();
984
+ const [detail, notesResult, commitsResult, pipelinesResult, reviewers, closesIssues] = await Promise.all([
985
+ client.getMergeRequestDetail(projectPath, iid, credentials),
986
+ client.getNotes(projectPath, 'merge_request', iid, input.maxNotes, undefined, true, credentials),
987
+ client.getMergeRequestCommits(projectPath, iid, input.maxCommits, undefined, true, credentials),
988
+ client.getMergeRequestPipelines(projectPath, iid, 5, undefined, false, credentials).catch(() => null),
989
+ client.getMergeRequestReviewers(projectPath, iid, credentials).catch(() => null),
990
+ client.getMergeRequestClosesIssues(projectPath, iid, credentials).catch(() => []),
991
+ ]);
992
+ const mr = detail?.project?.mergeRequest;
993
+ if (!mr) {
994
+ throw new Error(`Merge request ${projectPath}!${iid} not found.`);
995
+ }
996
+ const allNotes = Array.isArray(notesResult?.nodes) ? notesResult.nodes : [];
997
+ const notes = input.includeSystemNotes ? allNotes : allNotes.filter((n) => !n.system);
998
+ return {
999
+ project: { fullPath: projectPath },
1000
+ mergeRequest: mr,
1001
+ notes: {
1002
+ count: notes.length,
1003
+ truncated: !!notesResult?.hasMore,
1004
+ nodes: notes,
1005
+ },
1006
+ commits: {
1007
+ count: Array.isArray(commitsResult?.nodes) ? commitsResult.nodes.length : 0,
1008
+ truncated: !!commitsResult?.hasMore,
1009
+ nodes: commitsResult?.nodes ?? [],
1010
+ },
1011
+ pipelines: pipelinesResult ?? null,
1012
+ reviewers: reviewers ?? null,
1013
+ closesIssues: closesIssues.map((i) => ({
1014
+ iid: String(i.iid),
1015
+ title: i.title,
1016
+ state: i.state,
1017
+ webUrl: i.web_url ?? null,
1018
+ projectId: i.project_id ?? null,
1019
+ })),
1020
+ };
1021
+ },
1022
+ };
1023
+ const searchNotesTool = {
1024
+ name: 'search_notes',
1025
+ title: 'Search Notes (Comments)',
1026
+ 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.',
1027
+ requiresAuth: false,
1028
+ requiresWrite: false,
1029
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
1030
+ inputSchema: withUserAuth(z.object({
1031
+ search: z.string().min(1).describe('Search term. Trimmed; empty rejected.'),
1032
+ scope: z.enum(['global', 'project', 'group']).default('global').describe('Search scope. Default global.'),
1033
+ projectPath: z.string().optional().describe('Required when scope=project. Full project path.'),
1034
+ groupPath: z.string().optional().describe('Required when scope=group. Full group path.'),
1035
+ perPage: z.number().int().min(1).max(100).default(20).describe('Results per page. Default 20.'),
1036
+ page: z.number().int().min(1).default(1).describe('Page number. Default 1.'),
1037
+ })),
1038
+ handler: async (input, client, userConfig) => {
1039
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1040
+ const search = input.search.trim();
1041
+ if (!search)
1042
+ throw new Error('search_notes requires a non-empty search term.');
1043
+ if (input.scope === 'project' && !input.projectPath) {
1044
+ throw new Error('search_notes scope=project requires projectPath.');
1045
+ }
1046
+ if (input.scope === 'group' && !input.groupPath) {
1047
+ throw new Error('search_notes scope=group requires groupPath.');
1048
+ }
1049
+ const results = await client.searchNotes({
1050
+ search,
1051
+ scope: input.scope,
1052
+ projectPath: input.projectPath?.trim(),
1053
+ groupPath: input.groupPath?.trim(),
1054
+ perPage: input.perPage,
1055
+ page: input.page,
1056
+ }, credentials);
1057
+ return {
1058
+ scope: input.scope,
1059
+ search,
1060
+ page: input.page,
1061
+ perPage: input.perPage,
1062
+ count: Array.isArray(results) ? results.length : 0,
1063
+ nodes: results,
1064
+ };
1065
+ },
1066
+ };
905
1067
  const createNoteTool = {
906
1068
  name: 'create_note',
907
1069
  title: 'Create Note',
@@ -1612,6 +1774,100 @@ const analyticsUserSummaryTool = {
1612
1774
  return buildEnvelope({ type: 'user', identifier: user }, window, events, { byProject, byDay }, truncated);
1613
1775
  },
1614
1776
  };
1777
+ const analyticsGroupSummaryTool = {
1778
+ name: 'analytics_group_summary',
1779
+ title: 'Group Activity Summary',
1780
+ 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.',
1781
+ requiresAuth: false,
1782
+ requiresWrite: false,
1783
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
1784
+ inputSchema: withUserAuth(z.object({
1785
+ group: z
1786
+ .string()
1787
+ .min(1)
1788
+ .describe('Group full path (e.g. "my-group" or "my-group/sub-group")'),
1789
+ since: z
1790
+ .string()
1791
+ .describe('ISO date/datetime, inclusive lower bound (e.g. "2026-04-01" or "2026-04-01T00:00:00Z")'),
1792
+ until: z
1793
+ .string()
1794
+ .optional()
1795
+ .describe('ISO date/datetime, inclusive upper bound. Defaults to now.'),
1796
+ includeSubgroups: z
1797
+ .boolean()
1798
+ .default(true)
1799
+ .describe('Recurse into subgroup projects. Defaults to true.'),
1800
+ topContributors: z
1801
+ .number()
1802
+ .int()
1803
+ .min(1)
1804
+ .max(100)
1805
+ .default(10)
1806
+ .describe('Cap on the byUser[] list (sorted by event count desc). Default 10.'),
1807
+ maxEvents: z
1808
+ .number()
1809
+ .int()
1810
+ .min(1)
1811
+ .max(10000)
1812
+ .optional()
1813
+ .describe('Global cap on events fetched across all projects. Defaults to 2000; envelope flags truncated:true if reached.'),
1814
+ maxProjects: z
1815
+ .number()
1816
+ .int()
1817
+ .min(1)
1818
+ .max(2000)
1819
+ .optional()
1820
+ .describe('Cap on projects scanned. Defaults to 500.'),
1821
+ })),
1822
+ handler: async (input, client, userConfig) => {
1823
+ const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
1824
+ const group = input.group.trim();
1825
+ const window = resolveWindow(input.since, input.until);
1826
+ const { events, truncated, projects, projectsTruncated } = await fetchGroupEventsInWindow(client, group, {
1827
+ window,
1828
+ maxEvents: input.maxEvents,
1829
+ includeSubgroups: input.includeSubgroups,
1830
+ maxProjects: input.maxProjects,
1831
+ }, credentials);
1832
+ const projectPathById = new Map();
1833
+ for (const p of projects)
1834
+ projectPathById.set(p.id, p.fullPath);
1835
+ const byProject = [];
1836
+ for (const [projectId, bucket] of bucketBy(events, (e) => e.projectId)) {
1837
+ byProject.push({
1838
+ projectId,
1839
+ projectPath: projectPathById.get(projectId) ?? null,
1840
+ totals: countByAction(bucket),
1841
+ });
1842
+ }
1843
+ byProject.sort((a, b) => b.totals.events - a.totals.events);
1844
+ const byUserAll = [];
1845
+ for (const [username, bucket] of bucketBy(events, (e) => e.authorUsername)) {
1846
+ byUserAll.push({ username, totals: countByAction(bucket) });
1847
+ }
1848
+ byUserAll.sort((a, b) => b.totals.events - a.totals.events);
1849
+ const byUser = byUserAll.slice(0, input.topContributors);
1850
+ const byDay = [];
1851
+ for (const [date, bucket] of bucketBy(events, (e) => e.createdAt.toISOString().slice(0, 10))) {
1852
+ byDay.push({ date, totals: countByAction(bucket) });
1853
+ }
1854
+ byDay.sort((a, b) => a.date.localeCompare(b.date));
1855
+ const envelope = buildEnvelope({ type: 'group', identifier: group }, window, events, {
1856
+ byProject,
1857
+ byUser,
1858
+ byDay,
1859
+ projectsScanned: projects.length,
1860
+ contributorsTotal: byUserAll.length,
1861
+ }, truncated);
1862
+ if (projectsTruncated) {
1863
+ envelope.warnings = [
1864
+ ...envelope.warnings,
1865
+ `Group has more projects than maxProjects cap (${input.maxProjects ?? 500}); some projects were not scanned.`,
1866
+ ];
1867
+ }
1868
+ return envelope;
1869
+ },
1870
+ };
1615
1871
  const REVIEW_BUCKETS = ['<1d', '1-3d', '3-7d', '7-14d', '>14d'];
1616
1872
  function emptyReviewBuckets() {
1617
1873
  return { '<1d': 0, '1-3d': 0, '3-7d': 0, '7-14d': 0, '>14d': 0 };
@@ -1745,6 +2001,8 @@ export const readOnlyTools = [
1745
2001
  getMergeRequestDiffsTool,
1746
2002
  getMergeRequestCommitsTool,
1747
2003
  getNotesTool,
2004
+ getIssueContextTool,
2005
+ getMergeRequestContextTool,
1748
2006
  listMilestonesTool,
1749
2007
  listIterationsTool,
1750
2008
  getTimeTrackingTool,
@@ -1758,6 +2016,7 @@ export const readOnlyTools = [
1758
2016
  listProjectEventsTool,
1759
2017
  listMyEventsTool,
1760
2018
  analyticsUserSummaryTool,
2019
+ analyticsGroupSummaryTool,
1761
2020
  analyticsReviewBottlenecksTool,
1762
2021
  ];
1763
2022
  export const userAuthTools = [
@@ -1785,6 +2044,7 @@ export const searchTools = [
1785
2044
  searchUsersTool,
1786
2045
  searchGroupsTool,
1787
2046
  searchLabelsTool,
2047
+ searchNotesTool,
1788
2048
  browseRepositoryTool,
1789
2049
  getFileContentTool,
1790
2050
  listGroupMembersTool,