@zereight/mcp-gitlab 2.1.12 → 2.1.14

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.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Remote Mode Download Proxy & Upload Tests
3
+ *
4
+ * Tests the /downloads/:type proxy endpoint and verifies that download/upload
5
+ * tools behave correctly in remote (StreamableHTTP) mode:
6
+ * - download_job_artifacts returns a download_url
7
+ * - download_attachment for non-image returns a download_url
8
+ * - download_attachment for image returns inline base64
9
+ * - upload_markdown with content+filename works
10
+ * - upload_markdown with file_path is rejected
11
+ */
12
+ import { describe, test, before, after } from 'node:test';
13
+ import assert from 'node:assert';
14
+ import { launchServer, TransportMode, HOST } from './utils/server-launcher.js';
15
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
16
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
17
+ const TEST_PROJECT_ID = '123';
18
+ const TEST_JOB_ID = '456';
19
+ const TEST_SECRET = 'testsecret';
20
+ // Minimal 1x1 transparent PNG
21
+ const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
22
+ const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
23
+ const FAKE_ZIP = Buffer.from('PK\x03\x04fake-zip-content-for-testing');
24
+ const MOCK_UPLOAD_RESPONSE = {
25
+ id: 99,
26
+ alt: 'test-file.txt',
27
+ url: '/uploads/abc123secret/test-file.txt',
28
+ full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
29
+ markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
30
+ };
31
+ function parseSSE(text) {
32
+ const lines = text.split('\n');
33
+ const dataLines = lines.filter(l => l.startsWith('data: '));
34
+ return dataLines.map(l => JSON.parse(l.slice(6)));
35
+ }
36
+ // --- Test suites ---
37
+ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () => {
38
+ let mockGitLab;
39
+ let server;
40
+ let serverPort;
41
+ before(async () => {
42
+ const mockPort = await findMockServerPort(9300);
43
+ mockGitLab = new MockGitLabServer({
44
+ port: mockPort,
45
+ validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN],
46
+ });
47
+ // Mock artifact download
48
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
49
+ res.set('Content-Type', 'application/zip');
50
+ res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
51
+ res.send(FAKE_ZIP);
52
+ });
53
+ // Mock image attachment
54
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => {
55
+ res.set('Content-Type', 'image/png');
56
+ res.send(MINIMAL_PNG);
57
+ });
58
+ // Mock text attachment
59
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => {
60
+ res.set('Content-Type', 'text/plain');
61
+ res.send('hello document content');
62
+ });
63
+ // Mock large artifact (2MB) to verify streaming works for big files
64
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts`, (_req, res) => {
65
+ res.set('Content-Type', 'application/zip');
66
+ res.set('Content-Disposition', 'attachment; filename="large_artifacts.zip"');
67
+ // Send 2MB of data in chunks
68
+ const chunk = Buffer.alloc(64 * 1024, 0x42); // 64KB of 'B'
69
+ res.writeHead(200);
70
+ let sent = 0;
71
+ const total = 2 * 1024 * 1024; // 2MB
72
+ const sendChunk = () => {
73
+ while (sent < total) {
74
+ const size = Math.min(chunk.length, total - sent);
75
+ const ok = res.write(chunk.subarray(0, size));
76
+ sent += size;
77
+ if (!ok) {
78
+ res.once('drain', sendChunk);
79
+ return;
80
+ }
81
+ }
82
+ res.end();
83
+ };
84
+ sendChunk();
85
+ });
86
+ await mockGitLab.start();
87
+ serverPort = 3500;
88
+ server = await launchServer({
89
+ mode: TransportMode.STREAMABLE_HTTP,
90
+ port: serverPort,
91
+ timeout: 10_000,
92
+ env: {
93
+ STREAMABLE_HTTP: 'true',
94
+ REMOTE_AUTHORIZATION: 'true',
95
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
96
+ USE_PIPELINE: 'true',
97
+ MAX_REQUESTS_PER_MINUTE: '2',
98
+ },
99
+ });
100
+ });
101
+ after(async () => {
102
+ if (server)
103
+ server.kill();
104
+ if (mockGitLab)
105
+ await mockGitLab.stop();
106
+ });
107
+ test('returns 401 without auth headers', async () => {
108
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=${TEST_JOB_ID}`);
109
+ assert.strictEqual(res.status, 401);
110
+ const body = await res.json();
111
+ assert.ok(body.error.toLowerCase().includes('auth'));
112
+ });
113
+ test('returns 400 for missing parameters', async () => {
114
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}`, { headers: { 'Private-Token': MOCK_TOKEN } });
115
+ assert.strictEqual(res.status, 400);
116
+ const body = await res.json();
117
+ assert.ok(body.error.includes('required'));
118
+ });
119
+ test('returns 400 for unknown download types', async () => {
120
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/unknown-type?project_id=${TEST_PROJECT_ID}`, { headers: { 'Private-Token': MOCK_TOKEN } });
121
+ assert.strictEqual(res.status, 400);
122
+ const body = await res.json();
123
+ assert.ok(body.error.toLowerCase().includes('unknown'));
124
+ });
125
+ test('streams large file (2MB) without buffering issues', async () => {
126
+ // Use a dedicated token to avoid rate limit interference from other tests
127
+ const largeFileToken = 'glpat-largefile-test-token';
128
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=999`, { headers: { 'Private-Token': largeFileToken } });
129
+ assert.strictEqual(res.status, 200, 'Should stream large file successfully');
130
+ const buf = Buffer.from(await res.arrayBuffer());
131
+ const expectedSize = 2 * 1024 * 1024;
132
+ assert.strictEqual(buf.length, expectedSize, `Should receive full 2MB (got ${buf.length} bytes)`);
133
+ });
134
+ test('returns 429 after exceeding rate limit', async () => {
135
+ // Use a different token to get a fresh rate limit counter
136
+ const rateLimitToken = 'glpat-ratelimit-test-token';
137
+ let got429 = false;
138
+ for (let i = 0; i < 10; i++) {
139
+ const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=${TEST_JOB_ID}`, { headers: { 'Private-Token': rateLimitToken } });
140
+ if (res.status === 429) {
141
+ got429 = true;
142
+ const body = await res.json();
143
+ assert.ok(body.error.toLowerCase().includes('rate limit'));
144
+ break;
145
+ }
146
+ // consume body (might be 401/403 from GitLab mock, but rate limit still increments)
147
+ await res.arrayBuffer();
148
+ }
149
+ assert.ok(got429, 'Should have received 429 within 10 requests (rate limit is 2/min)');
150
+ });
151
+ });
152
+ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000 }, () => {
153
+ let mockGitLab;
154
+ let server;
155
+ let serverPort;
156
+ let sessionId;
157
+ before(async () => {
158
+ const mockPort = await findMockServerPort(9310);
159
+ mockGitLab = new MockGitLabServer({
160
+ port: mockPort,
161
+ validTokens: [MOCK_TOKEN],
162
+ });
163
+ // Mock artifact download
164
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
165
+ res.set('Content-Type', 'application/zip');
166
+ res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
167
+ res.send(FAKE_ZIP);
168
+ });
169
+ // Mock image attachment
170
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => {
171
+ res.set('Content-Type', 'image/png');
172
+ res.send(MINIMAL_PNG);
173
+ });
174
+ // Mock text attachment
175
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => {
176
+ res.set('Content-Type', 'text/plain');
177
+ res.send('hello document content');
178
+ });
179
+ // Mock upload endpoint
180
+ mockGitLab.addMockHandler('post', `/projects/${TEST_PROJECT_ID}/uploads`, (req, res) => {
181
+ res.status(201).json(MOCK_UPLOAD_RESPONSE);
182
+ });
183
+ await mockGitLab.start();
184
+ serverPort = 3510;
185
+ server = await launchServer({
186
+ mode: TransportMode.STREAMABLE_HTTP,
187
+ port: serverPort,
188
+ timeout: 10_000,
189
+ env: {
190
+ STREAMABLE_HTTP: 'true',
191
+ REMOTE_AUTHORIZATION: 'true',
192
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
193
+ USE_PIPELINE: 'true',
194
+ },
195
+ });
196
+ // Initialize MCP session
197
+ const initRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
198
+ method: 'POST',
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'Accept': 'application/json, text/event-stream',
202
+ 'Private-Token': MOCK_TOKEN,
203
+ },
204
+ body: JSON.stringify({
205
+ jsonrpc: '2.0',
206
+ id: 1,
207
+ method: 'initialize',
208
+ params: {
209
+ protocolVersion: '2025-03-26',
210
+ capabilities: {},
211
+ clientInfo: { name: 'test-remote-downloads', version: '1.0' },
212
+ },
213
+ }),
214
+ });
215
+ assert.strictEqual(initRes.status, 200, 'Initialize should succeed');
216
+ sessionId = initRes.headers.get('mcp-session-id');
217
+ assert.ok(sessionId, 'Should receive a session ID');
218
+ });
219
+ after(async () => {
220
+ if (server)
221
+ server.kill();
222
+ if (mockGitLab)
223
+ await mockGitLab.stop();
224
+ });
225
+ async function callTool(id, name, args) {
226
+ const res = await fetch(`http://${HOST}:${serverPort}/mcp`, {
227
+ method: 'POST',
228
+ headers: {
229
+ 'Content-Type': 'application/json',
230
+ 'Accept': 'application/json, text/event-stream',
231
+ 'Private-Token': MOCK_TOKEN,
232
+ 'mcp-session-id': sessionId,
233
+ },
234
+ body: JSON.stringify({
235
+ jsonrpc: '2.0',
236
+ id,
237
+ method: 'tools/call',
238
+ params: { name, arguments: args },
239
+ }),
240
+ });
241
+ assert.strictEqual(res.status, 200, `Tool call ${name} should return 200`);
242
+ const text = await res.text();
243
+ const responses = parseSSE(text);
244
+ const result = responses.find(r => r.id === id);
245
+ assert.ok(result, `Should find response with id=${id} in SSE stream`);
246
+ return result;
247
+ }
248
+ test('download_job_artifacts returns download_url with embedded auth token', async () => {
249
+ const result = await callTool(10, 'download_job_artifacts', {
250
+ project_id: TEST_PROJECT_ID,
251
+ job_id: TEST_JOB_ID,
252
+ });
253
+ assert.ok(result.result, 'Should have a result');
254
+ const content = result.result.content;
255
+ assert.ok(content && content.length > 0, 'Should have content');
256
+ const textBlock = content.find(c => c.type === 'text');
257
+ assert.ok(textBlock?.text, 'Should have text content');
258
+ const parsed = JSON.parse(textBlock.text);
259
+ assert.ok(parsed.download_url, 'Should contain download_url');
260
+ assert.ok(parsed.download_url.includes('/downloads/job-artifacts'), 'URL should point to proxy endpoint');
261
+ assert.ok(parsed.download_url.includes(`project_id=${TEST_PROJECT_ID}`), 'URL should include project_id');
262
+ assert.ok(parsed.download_url.includes(`job_id=${TEST_JOB_ID}`), 'URL should include job_id');
263
+ assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
264
+ assert.ok(parsed.filename.includes('.zip'), 'Should have zip filename');
265
+ // The URL should work WITHOUT auth headers (token is embedded)
266
+ const downloadRes = await fetch(parsed.download_url);
267
+ assert.strictEqual(downloadRes.status, 200, 'Download URL should work without auth headers');
268
+ const buf = Buffer.from(await downloadRes.arrayBuffer());
269
+ assert.ok(buf.length > 0, 'Downloaded content should not be empty');
270
+ assert.ok(buf.includes(Buffer.from('PK')), 'Should contain zip magic bytes');
271
+ });
272
+ test('download_attachment for non-image returns download_url', async () => {
273
+ const result = await callTool(11, 'download_attachment', {
274
+ project_id: TEST_PROJECT_ID,
275
+ secret: TEST_SECRET,
276
+ filename: 'document.txt',
277
+ });
278
+ assert.ok(result.result, 'Should have a result');
279
+ const content = result.result.content;
280
+ assert.ok(content && content.length > 0, 'Should have content');
281
+ const textBlock = content.find(c => c.type === 'text');
282
+ assert.ok(textBlock?.text, 'Should have text content');
283
+ const parsed = JSON.parse(textBlock.text);
284
+ assert.ok(parsed.download_url, 'Should contain download_url');
285
+ assert.ok(parsed.download_url.includes('/downloads/attachment'), 'URL should point to attachment proxy');
286
+ assert.ok(parsed.download_url.includes(`project_id=${TEST_PROJECT_ID}`), 'URL should include project_id');
287
+ assert.ok(parsed.download_url.includes(`secret=${TEST_SECRET}`), 'URL should include secret');
288
+ assert.ok(parsed.download_url.includes('filename=document.txt'), 'URL should include filename');
289
+ assert.strictEqual(parsed.filename, 'document.txt', 'Should echo the filename');
290
+ });
291
+ test('download_attachment for image returns base64 inline', async () => {
292
+ const result = await callTool(12, 'download_attachment', {
293
+ project_id: TEST_PROJECT_ID,
294
+ secret: TEST_SECRET,
295
+ filename: 'image.png',
296
+ });
297
+ assert.ok(result.result, 'Should have a result');
298
+ const content = result.result.content;
299
+ assert.ok(content && content.length > 0, 'Should have content');
300
+ const imageBlock = content.find(c => c.type === 'image');
301
+ assert.ok(imageBlock, 'Should contain an image content block');
302
+ assert.strictEqual(imageBlock.mimeType, 'image/png', 'Should have image/png mime type');
303
+ assert.ok(imageBlock.data && imageBlock.data.length > 0, 'Should have non-empty base64 data');
304
+ });
305
+ test('upload_markdown with content+filename works', async () => {
306
+ const fileContent = Buffer.from('hello upload test').toString('base64');
307
+ const result = await callTool(13, 'upload_markdown', {
308
+ project_id: TEST_PROJECT_ID,
309
+ content: fileContent,
310
+ filename: 'test-file.txt',
311
+ });
312
+ assert.ok(result.result, 'Should have a result');
313
+ assert.ok(!result.error, `Should not have error: ${result.error?.message}`);
314
+ const content = result.result.content;
315
+ assert.ok(content && content.length > 0, 'Should have content');
316
+ const textBlock = content.find(c => c.type === 'text');
317
+ assert.ok(textBlock?.text, 'Should have text content');
318
+ const parsed = JSON.parse(textBlock.text);
319
+ assert.ok(parsed.markdown, 'Should have markdown field');
320
+ assert.ok(parsed.url, 'Should have url field');
321
+ });
322
+ test('upload_markdown with file_path is rejected in remote mode', async () => {
323
+ const result = await callTool(14, 'upload_markdown', {
324
+ project_id: TEST_PROJECT_ID,
325
+ file_path: '/tmp/some-file.txt',
326
+ });
327
+ // In remote mode the server uses MarkdownUploadRemoteSchema which
328
+ // requires content+filename and does not accept file_path. This should
329
+ // result in a validation error.
330
+ const hasError = !!result.error ||
331
+ (result.result?.content?.some(c => c.type === 'text' && c.text && (c.text.toLowerCase().includes('error') ||
332
+ c.text.toLowerCase().includes('required') ||
333
+ c.text.toLowerCase().includes('invalid'))));
334
+ assert.ok(hasError, 'Should reject file_path in remote mode (needs content+filename)');
335
+ });
336
+ });
@@ -19,7 +19,7 @@ const TOOLSET_TOOL_COUNTS = {
19
19
  merge_requests: 41,
20
20
  issues: 24,
21
21
  repositories: 7,
22
- branches: 9,
22
+ branches: 10,
23
23
  projects: 9,
24
24
  labels: 5,
25
25
  ci: 2,
@@ -32,6 +32,7 @@ const TOOLSET_TOOL_COUNTS = {
32
32
  search: 3,
33
33
  workitems: 18,
34
34
  webhooks: 3,
35
+ groups: 1,
35
36
  };
36
37
  const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + TOOLSET_TOOL_COUNTS.ci;
37
38
  const DEFAULT_TOOLSETS = [
@@ -43,6 +44,7 @@ const DEFAULT_TOOLSETS = [
43
44
  "labels",
44
45
  "ci",
45
46
  "users",
47
+ "groups",
46
48
  ];
47
49
  const NON_DEFAULT_TOOLSETS = [
48
50
  "pipelines",
@@ -75,6 +77,7 @@ const TOOLSET_SAMPLE_TOOLS = {
75
77
  users: ["get_users", "upload_markdown", "download_attachment"],
76
78
  search: ["search_code", "search_project_code", "search_group_code"],
77
79
  webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
80
+ groups: ["create_group"],
78
81
  };
79
82
  // --- Helpers ---
80
83
  async function launchMcpServer(mockGitLabUrl, mcpPort, extraEnv = {}) {
@@ -1,7 +1,8 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
2
  import { toJSONSchema } from "../utils/schema.js";
3
- import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, } from "../config.js";
4
- import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
3
+ import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, SSE, STREAMABLE_HTTP, } from "../config.js";
4
+ import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
5
+ const IS_REMOTE = SSE || STREAMABLE_HTTP;
5
6
  // Define all available tools
6
7
  export const allTools = [
7
8
  {
@@ -54,6 +55,11 @@ export const allTools = [
54
55
  description: "Create a new GitLab project",
55
56
  inputSchema: toJSONSchema(CreateRepositorySchema),
56
57
  },
58
+ {
59
+ name: "create_group",
60
+ description: "Create new group or subgroup",
61
+ inputSchema: toJSONSchema(CreateGroupSchema),
62
+ },
57
63
  {
58
64
  name: "get_file_contents",
59
65
  description: "Get contents of a file or directory from a GitLab project",
@@ -385,12 +391,12 @@ export const allTools = [
385
391
  },
386
392
  {
387
393
  name: "list_namespaces",
388
- description: "List all namespaces available to the current user",
394
+ description: "List all namespaces (users and groups) available to the current user. Filter by kind='group' for groups only.",
389
395
  inputSchema: toJSONSchema(ListNamespacesSchema),
390
396
  },
391
397
  {
392
398
  name: "get_namespace",
393
- description: "Get details of a namespace by ID or path",
399
+ description: "Get details of a namespace (user or group) by ID or path. Groups are namespaces with kind='group'.",
394
400
  inputSchema: toJSONSchema(GetNamespaceSchema),
395
401
  },
396
402
  {
@@ -595,8 +601,12 @@ export const allTools = [
595
601
  },
596
602
  {
597
603
  name: "download_job_artifacts",
598
- description: "Download job artifact archive (zip) to a local path",
599
- inputSchema: toJSONSchema(DownloadJobArtifactsSchema),
604
+ description: IS_REMOTE
605
+ ? "Get a download URL for a job's artifact archive (zip)"
606
+ : "Download job artifact archive (zip) and save to a local path",
607
+ inputSchema: IS_REMOTE
608
+ ? toJSONSchema(DownloadJobArtifactsRemoteSchema)
609
+ : toJSONSchema(DownloadJobArtifactsSchema),
600
610
  },
601
611
  {
602
612
  name: "get_job_artifact_file",
@@ -683,6 +693,11 @@ export const allTools = [
683
693
  description: "Get changes/diffs of a specific commit",
684
694
  inputSchema: toJSONSchema(GetCommitDiffSchema),
685
695
  },
696
+ {
697
+ name: "get_file_blame",
698
+ description: "Get git blame for a file at a given ref. Each entry maps a contiguous range of source lines to the commit that last changed them (id, author, authored_date, message). Use range_start/range_end to limit blame to specific lines.",
699
+ inputSchema: toJSONSchema(GetFileBlameSchema),
700
+ },
686
701
  {
687
702
  name: "list_commit_statuses",
688
703
  description: "List statuses for a commit",
@@ -700,13 +715,21 @@ export const allTools = [
700
715
  },
701
716
  {
702
717
  name: "upload_markdown",
703
- description: "Upload a file for use in markdown content",
704
- inputSchema: toJSONSchema(MarkdownUploadSchema),
718
+ description: IS_REMOTE
719
+ ? "Upload base64-encoded content for use in markdown"
720
+ : "Upload a file for use in markdown content",
721
+ inputSchema: IS_REMOTE
722
+ ? toJSONSchema(MarkdownUploadRemoteSchema)
723
+ : toJSONSchema(MarkdownUploadSchema),
705
724
  },
706
725
  {
707
726
  name: "download_attachment",
708
- description: "Download an uploaded file from a project (images returned as base64; use local_path to save to disk)",
709
- inputSchema: toJSONSchema(DownloadAttachmentSchema),
727
+ description: IS_REMOTE
728
+ ? "Download an uploaded file from a project (images returned inline as base64, other files returned as download URL)"
729
+ : "Download an uploaded file from a project (images returned as base64; use local_path to save to disk)",
730
+ inputSchema: IS_REMOTE
731
+ ? toJSONSchema(DownloadAttachmentRemoteSchema)
732
+ : toJSONSchema(DownloadAttachmentSchema),
710
733
  },
711
734
  {
712
735
  name: "health_check",
@@ -755,7 +778,9 @@ export const allTools = [
755
778
  },
756
779
  {
757
780
  name: "download_release_asset",
758
- description: "Download a release asset file by direct asset path",
781
+ description: IS_REMOTE
782
+ ? "Get a download URL for a release asset file"
783
+ : "Download a release asset file by direct asset path",
759
784
  inputSchema: toJSONSchema(DownloadReleaseAssetSchema),
760
785
  },
761
786
  {
@@ -995,6 +1020,7 @@ export const readOnlyTools = new Set([
995
1020
  "list_commits",
996
1021
  "get_commit",
997
1022
  "get_commit_diff",
1023
+ "get_file_blame",
998
1024
  "list_commit_statuses",
999
1025
  "list_group_iterations",
1000
1026
  "get_group_iteration",
@@ -1203,6 +1229,7 @@ export const TOOLSET_DEFINITIONS = [
1203
1229
  "list_commits",
1204
1230
  "get_commit",
1205
1231
  "get_commit_diff",
1232
+ "get_file_blame",
1206
1233
  "list_commit_statuses",
1207
1234
  "create_commit_status",
1208
1235
  ]),
@@ -1238,6 +1265,11 @@ export const TOOLSET_DEFINITIONS = [
1238
1265
  isDefault: true,
1239
1266
  tools: new Set(["validate_ci_lint", "validate_project_ci_lint"]),
1240
1267
  },
1268
+ {
1269
+ id: "groups",
1270
+ isDefault: true,
1271
+ tools: new Set(["create_group"]),
1272
+ },
1241
1273
  {
1242
1274
  id: "pipelines",
1243
1275
  isDefault: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.12",
3
+ "version": "2.1.14",
4
4
  "mcpName": "io.github.zereight/gitlab-mcp",
5
5
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
6
6
  "keywords": [
@@ -51,7 +51,7 @@
51
51
  "changelog": "auto-changelog -p",
52
52
  "test": "npm run test:all",
53
53
  "test:all": "npm run build && npm run test:mock && npm run test:live",
54
- "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
54
+ "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
55
55
  "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
56
56
  "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
57
57
  "test:live": "node test/validate-api.js",