@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.
- package/README.md +152 -151
- package/build/index.js +443 -25
- package/build/schemas.js +91 -0
- package/build/test/test-get-file-blame.js +145 -0
- package/build/test/test-geteffectiveprojectid.js +230 -6
- package/build/test/test-remote-downloads.js +336 -0
- package/build/test/test-toolset-filtering.js +4 -1
- package/build/tools/registry.js +43 -11
- package/package.json +2 -2
|
@@ -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:
|
|
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 = {}) {
|
package/build/tools/registry.js
CHANGED
|
@@ -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:
|
|
599
|
-
|
|
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:
|
|
704
|
-
|
|
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:
|
|
709
|
-
|
|
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:
|
|
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.
|
|
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",
|