@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/README.md +67 -10
- package/dist/analytics.d.ts +13 -1
- package/dist/analytics.d.ts.map +1 -1
- package/dist/analytics.js +38 -0
- package/dist/analytics.js.map +1 -1
- package/dist/gitlab-client.d.ts +21 -0
- package/dist/gitlab-client.d.ts.map +1 -1
- package/dist/gitlab-client.js +123 -0
- package/dist/gitlab-client.js.map +1 -1
- package/dist/index.d.ts +1 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -28
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +261 -1
- package/dist/tools.js.map +1 -1
- package/package.json +1 -2
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,
|