@zereight/mcp-gitlab 2.1.11 → 2.1.13
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 -150
- package/build/index.js +205 -9
- package/build/schemas.js +124 -0
- package/build/test/test-get-file-blame.js +145 -0
- package/build/test/test-geteffectiveprojectid.js +230 -6
- package/build/test/test-issue-description-patch.js +256 -0
- package/build/test/test-token-optimizations.js +1 -1
- package/build/test/test-toolset-filtering.js +7 -3
- package/build/test/utils/mock-gitlab-server.js +46 -0
- package/build/tools/registry.js +52 -3
- package/build/utils/patch-helper.js +145 -0
- package/package.json +3 -2
|
@@ -6,7 +6,7 @@ import { describe, test, before, after } from 'node:test';
|
|
|
6
6
|
import assert from 'node:assert';
|
|
7
7
|
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST } from './utils/server-launcher.js';
|
|
8
8
|
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
|
|
9
|
-
import {
|
|
9
|
+
import { CustomHeaderClient } from './clients/custom-header-client.js';
|
|
10
10
|
// Use the same token that will be passed via GITLAB_TOKEN_TEST environment variable
|
|
11
11
|
const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || 'glpat-mock-token-12345';
|
|
12
12
|
const DEFAULT_PROJECT_ID = '123';
|
|
@@ -43,6 +43,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
43
43
|
timeout: 5000,
|
|
44
44
|
env: {
|
|
45
45
|
STREAMABLE_HTTP: 'true',
|
|
46
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
46
47
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
47
48
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
48
49
|
GITLAB_READ_ONLY_MODE: 'true',
|
|
@@ -50,7 +51,9 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
50
51
|
});
|
|
51
52
|
servers.push(server);
|
|
52
53
|
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
53
|
-
client = new
|
|
54
|
+
client = new CustomHeaderClient({
|
|
55
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
56
|
+
});
|
|
54
57
|
await client.connect(mcpUrl);
|
|
55
58
|
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
56
59
|
console.log(`MCP Server: ${mcpUrl}`);
|
|
@@ -113,7 +116,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
113
116
|
port: mcpPort,
|
|
114
117
|
timeout: 5000,
|
|
115
118
|
env: {
|
|
116
|
-
|
|
119
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
117
120
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
118
121
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
119
122
|
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
@@ -122,7 +125,9 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
122
125
|
});
|
|
123
126
|
servers.push(server);
|
|
124
127
|
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
125
|
-
client = new
|
|
128
|
+
client = new CustomHeaderClient({
|
|
129
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
130
|
+
});
|
|
126
131
|
await client.connect(mcpUrl);
|
|
127
132
|
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
128
133
|
console.log(`MCP Server: ${mcpUrl}`);
|
|
@@ -183,7 +188,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
183
188
|
port: mcpPort,
|
|
184
189
|
timeout: 5000,
|
|
185
190
|
env: {
|
|
186
|
-
|
|
191
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
187
192
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
188
193
|
GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
|
|
189
194
|
GITLAB_READ_ONLY_MODE: 'true',
|
|
@@ -191,7 +196,9 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
191
196
|
});
|
|
192
197
|
servers.push(server);
|
|
193
198
|
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
194
|
-
client = new
|
|
199
|
+
client = new CustomHeaderClient({
|
|
200
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
201
|
+
});
|
|
195
202
|
await client.connect(mcpUrl);
|
|
196
203
|
console.log(`Mock GitLab: ${mockGitLabUrl}`);
|
|
197
204
|
console.log(`MCP Server: ${mcpUrl}`);
|
|
@@ -242,4 +249,221 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
242
249
|
console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
|
|
243
250
|
});
|
|
244
251
|
});
|
|
252
|
+
describe('GITLAB_PROJECT_ID guards repository and group mutators', () => {
|
|
253
|
+
let mcpUrl;
|
|
254
|
+
let mockGitLab;
|
|
255
|
+
let servers = [];
|
|
256
|
+
let client;
|
|
257
|
+
before(async () => {
|
|
258
|
+
const mockPort = await findMockServerPort(9400);
|
|
259
|
+
mockGitLab = new MockGitLabServer({
|
|
260
|
+
port: mockPort,
|
|
261
|
+
validTokens: [MOCK_TOKEN]
|
|
262
|
+
});
|
|
263
|
+
await mockGitLab.start();
|
|
264
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
265
|
+
const mcpPort = await findAvailablePort(3400);
|
|
266
|
+
const server = await launchServer({
|
|
267
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
268
|
+
port: mcpPort,
|
|
269
|
+
timeout: 5000,
|
|
270
|
+
env: {
|
|
271
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
272
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
273
|
+
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
servers.push(server);
|
|
277
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
278
|
+
client = new CustomHeaderClient({
|
|
279
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
280
|
+
});
|
|
281
|
+
await client.connect(mcpUrl);
|
|
282
|
+
});
|
|
283
|
+
after(async () => {
|
|
284
|
+
if (client)
|
|
285
|
+
await client.disconnect();
|
|
286
|
+
cleanupServers(servers);
|
|
287
|
+
if (mockGitLab)
|
|
288
|
+
await mockGitLab.stop();
|
|
289
|
+
});
|
|
290
|
+
test('should reject create_repository when GITLAB_PROJECT_ID is set', async () => {
|
|
291
|
+
try {
|
|
292
|
+
await client.callTool('create_repository', { name: 'test-repo' });
|
|
293
|
+
assert.fail('Should have rejected create_repository');
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
assert.ok(error instanceof Error);
|
|
297
|
+
assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
test('should reject fork_repository when GITLAB_PROJECT_ID is set', async () => {
|
|
301
|
+
try {
|
|
302
|
+
await client.callTool('fork_repository', { project_id: '999' });
|
|
303
|
+
assert.fail('Should have rejected fork_repository');
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
assert.ok(error instanceof Error);
|
|
307
|
+
assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
test('should reject create_group when GITLAB_PROJECT_ID is set', async () => {
|
|
311
|
+
try {
|
|
312
|
+
await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
|
|
313
|
+
assert.fail('Should have rejected create_group');
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
assert.ok(error instanceof Error);
|
|
317
|
+
assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
test('should allow get_project (non-mutator) when GITLAB_PROJECT_ID is set', async () => {
|
|
321
|
+
const result = await client.callTool('get_project', { project_id: '' });
|
|
322
|
+
assert.ok(result.content, 'Should have content');
|
|
323
|
+
const content = result.content[0];
|
|
324
|
+
assert.ok('text' in content, 'Content should have text');
|
|
325
|
+
const project = JSON.parse(content.text);
|
|
326
|
+
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
describe('GITLAB_ALLOWED_PROJECT_IDS guards repository and group mutators (allowlist-only, no GITLAB_PROJECT_ID)', () => {
|
|
330
|
+
let mcpUrl;
|
|
331
|
+
let mockGitLab;
|
|
332
|
+
let servers = [];
|
|
333
|
+
let client;
|
|
334
|
+
before(async () => {
|
|
335
|
+
const mockPort = await findMockServerPort(9600);
|
|
336
|
+
mockGitLab = new MockGitLabServer({
|
|
337
|
+
port: mockPort,
|
|
338
|
+
validTokens: [MOCK_TOKEN]
|
|
339
|
+
});
|
|
340
|
+
await mockGitLab.start();
|
|
341
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
342
|
+
const mcpPort = await findAvailablePort(3600);
|
|
343
|
+
const server = await launchServer({
|
|
344
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
345
|
+
port: mcpPort,
|
|
346
|
+
timeout: 5000,
|
|
347
|
+
env: {
|
|
348
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
349
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
350
|
+
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
servers.push(server);
|
|
354
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
355
|
+
client = new CustomHeaderClient({
|
|
356
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
357
|
+
});
|
|
358
|
+
await client.connect(mcpUrl);
|
|
359
|
+
});
|
|
360
|
+
after(async () => {
|
|
361
|
+
if (client)
|
|
362
|
+
await client.disconnect();
|
|
363
|
+
cleanupServers(servers);
|
|
364
|
+
if (mockGitLab)
|
|
365
|
+
await mockGitLab.stop();
|
|
366
|
+
});
|
|
367
|
+
test('should reject create_repository with GITLAB_ALLOWED_PROJECT_IDS', async () => {
|
|
368
|
+
try {
|
|
369
|
+
await client.callTool('create_repository', { name: 'test-repo' });
|
|
370
|
+
assert.fail('Should have rejected create_repository');
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
assert.ok(error instanceof Error);
|
|
374
|
+
assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
test('should reject fork_repository with GITLAB_ALLOWED_PROJECT_IDS', async () => {
|
|
378
|
+
try {
|
|
379
|
+
await client.callTool('fork_repository', { project_id: '999' });
|
|
380
|
+
assert.fail('Should have rejected fork_repository');
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
assert.ok(error instanceof Error);
|
|
384
|
+
assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
test('should reject create_group with GITLAB_ALLOWED_PROJECT_IDS', async () => {
|
|
388
|
+
try {
|
|
389
|
+
await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
|
|
390
|
+
assert.fail('Should have rejected create_group');
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
assert.ok(error instanceof Error);
|
|
394
|
+
assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
test('should allow get_project (non-mutator) with GITLAB_ALLOWED_PROJECT_IDS', async () => {
|
|
398
|
+
const result = await client.callTool('get_project', { project_id: '' });
|
|
399
|
+
assert.ok(result.content, 'Should have content');
|
|
400
|
+
const content = result.content[0];
|
|
401
|
+
assert.ok('text' in content, 'Content should have text');
|
|
402
|
+
const project = JSON.parse(content.text);
|
|
403
|
+
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
describe('GITLAB_READ_ONLY_MODE enforces read-only for all write tools', () => {
|
|
407
|
+
let mcpUrl;
|
|
408
|
+
let mockGitLab;
|
|
409
|
+
let servers = [];
|
|
410
|
+
let client;
|
|
411
|
+
before(async () => {
|
|
412
|
+
const mockPort = await findMockServerPort(9500);
|
|
413
|
+
mockGitLab = new MockGitLabServer({
|
|
414
|
+
port: mockPort,
|
|
415
|
+
validTokens: [MOCK_TOKEN]
|
|
416
|
+
});
|
|
417
|
+
await mockGitLab.start();
|
|
418
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
419
|
+
const mcpPort = await findAvailablePort(3500);
|
|
420
|
+
const server = await launchServer({
|
|
421
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
422
|
+
port: mcpPort,
|
|
423
|
+
timeout: 5000,
|
|
424
|
+
env: {
|
|
425
|
+
REMOTE_AUTHORIZATION: 'true',
|
|
426
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
427
|
+
GITLAB_READ_ONLY_MODE: 'true',
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
servers.push(server);
|
|
431
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
432
|
+
client = new CustomHeaderClient({
|
|
433
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
434
|
+
});
|
|
435
|
+
await client.connect(mcpUrl);
|
|
436
|
+
});
|
|
437
|
+
after(async () => {
|
|
438
|
+
if (client)
|
|
439
|
+
await client.disconnect();
|
|
440
|
+
cleanupServers(servers);
|
|
441
|
+
if (mockGitLab)
|
|
442
|
+
await mockGitLab.stop();
|
|
443
|
+
});
|
|
444
|
+
test('should reject create_group in read-only mode (no project ID)', async () => {
|
|
445
|
+
try {
|
|
446
|
+
await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
|
|
447
|
+
assert.fail('Should have rejected create_group in read-only mode');
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
assert.ok(error instanceof Error);
|
|
451
|
+
assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
test('should reject create_repository in read-only mode', async () => {
|
|
455
|
+
try {
|
|
456
|
+
await client.callTool('create_repository', { name: 'test-repo' });
|
|
457
|
+
assert.fail('Should have rejected create_repository in read-only mode');
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
assert.ok(error instanceof Error);
|
|
461
|
+
assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
test('should allow get_project (read-only) in read-only mode', async () => {
|
|
465
|
+
const result = await client.callTool('get_project', { project_id: DEFAULT_PROJECT_ID });
|
|
466
|
+
assert.ok(result.content, 'Should have content');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
245
469
|
}); // end wrapper describe
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the update_issue_description_patch tool.
|
|
3
|
+
* Tests search_replace, unified_diff, dry_run, create_note, and edge cases.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, before, after } from "node:test";
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
import { launchServer, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
8
|
+
import { MockGitLabServer, findMockServerPort, } from "./utils/mock-gitlab-server.js";
|
|
9
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
10
|
+
import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "../utils/patch-helper.js";
|
|
11
|
+
const MOCK_TOKEN = "glpat-patch-test-token-12345";
|
|
12
|
+
// ---- Unit tests for patch helper ----
|
|
13
|
+
describe("parseSearchReplaceBlocks", () => {
|
|
14
|
+
test("parses a single block", () => {
|
|
15
|
+
const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\nold text\n=======\nnew text\n>>>>>>> REPLACE");
|
|
16
|
+
assert.strictEqual(blocks.length, 1);
|
|
17
|
+
assert.strictEqual(blocks[0].search, "old text");
|
|
18
|
+
assert.strictEqual(blocks[0].replace, "new text");
|
|
19
|
+
});
|
|
20
|
+
test("parses multiple blocks", () => {
|
|
21
|
+
const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\nfirst\n=======\nfirst new\n>>>>>>> REPLACE\n<<<<<<< SEARCH\nsecond\n=======\nsecond new\n>>>>>>> REPLACE");
|
|
22
|
+
assert.strictEqual(blocks.length, 2);
|
|
23
|
+
});
|
|
24
|
+
test("handles empty content", () => {
|
|
25
|
+
const blocks = parseSearchReplaceBlocks("");
|
|
26
|
+
assert.strictEqual(blocks.length, 0);
|
|
27
|
+
});
|
|
28
|
+
test("ignores text outside blocks", () => {
|
|
29
|
+
const blocks = parseSearchReplaceBlocks("prefix\n<<<<<<< SEARCH\nx\n=======\ny\n>>>>>>> REPLACE\nsuffix");
|
|
30
|
+
assert.strictEqual(blocks.length, 1);
|
|
31
|
+
});
|
|
32
|
+
test("rejects malformed block with missing REPLACE marker", () => {
|
|
33
|
+
assert.throws(() => parseSearchReplaceBlocks("<<<<<<< SEARCH\nfirst\n=======\nfirst new\n>>>>>>> REPLACE\n" +
|
|
34
|
+
"<<<<<<< SEARCH\nsecond\n=======\nsecond new\n>>>>>>> TYPO"), /malformed|Marker|marker/);
|
|
35
|
+
});
|
|
36
|
+
test("rejects block with missing ======= marker", () => {
|
|
37
|
+
assert.throws(() => parseSearchReplaceBlocks("<<<<<<< SEARCH\nfoo\n\nbar\n>>>>>>> REPLACE"), /malformed|Marker|marker/);
|
|
38
|
+
});
|
|
39
|
+
test("allows prose around valid blocks", () => {
|
|
40
|
+
const blocks = parseSearchReplaceBlocks("# Some notes\n\n<<<<<<< SEARCH\nfoo\n=======\nbar\n>>>>>>> REPLACE\n\nMore context");
|
|
41
|
+
assert.strictEqual(blocks.length, 1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("applySearchReplace", () => {
|
|
45
|
+
test("replaces single occurrence", () => {
|
|
46
|
+
const result = applySearchReplace("Status: In progress\nDone.", [
|
|
47
|
+
{ search: "Status: In progress", replace: "Status: Done" },
|
|
48
|
+
]);
|
|
49
|
+
assert.strictEqual(result.description, "Status: Done\nDone.");
|
|
50
|
+
assert.strictEqual(result.changes, 1);
|
|
51
|
+
assert.ok(result.summary.includes("Status: In progress"));
|
|
52
|
+
assert.ok(result.preview.includes("-Status: In progress"));
|
|
53
|
+
});
|
|
54
|
+
test("fails on no match", () => {
|
|
55
|
+
assert.throws(() => applySearchReplace("Some text.", [{ search: "Nonexistent", replace: "x" }]), /Search text not found/);
|
|
56
|
+
});
|
|
57
|
+
test("rejects empty SEARCH body", () => {
|
|
58
|
+
assert.throws(() => applySearchReplace("Some text.", [{ search: "", replace: "x" }], true), /Empty SEARCH/);
|
|
59
|
+
});
|
|
60
|
+
test("fails on duplicate without allowMultiple", () => {
|
|
61
|
+
assert.throws(() => applySearchReplace("x\ny\nx\n", [{ search: "x", replace: "z" }]), /matches 2 times/);
|
|
62
|
+
});
|
|
63
|
+
test("replaces all with allowMultiple", () => {
|
|
64
|
+
const result = applySearchReplace("x\ny\nx\n", [{ search: "x", replace: "z" }], true);
|
|
65
|
+
assert.strictEqual(result.changes, 2);
|
|
66
|
+
assert.strictEqual(result.description, "z\ny\nz\n");
|
|
67
|
+
});
|
|
68
|
+
test("fails on identical replacement", () => {
|
|
69
|
+
assert.throws(() => applySearchReplace("Keep this.", [{ search: "Keep this.", replace: "Keep this." }]), /did not change/);
|
|
70
|
+
});
|
|
71
|
+
test("preserves leading blank line in SEARCH block", () => {
|
|
72
|
+
const source = "\n\nStatus: In progress\n";
|
|
73
|
+
const result = applySearchReplace(source, [
|
|
74
|
+
{ search: "\n\nStatus: In progress", replace: "\n\nStatus: Done" },
|
|
75
|
+
]);
|
|
76
|
+
assert.strictEqual(result.description, "\n\nStatus: Done\n");
|
|
77
|
+
});
|
|
78
|
+
test("preserves leading blank line when patch starts with blank line", () => {
|
|
79
|
+
const blocks = parseSearchReplaceBlocks("<<<<<<< SEARCH\n\nfoo\n=======\n\nbar\n>>>>>>> REPLACE");
|
|
80
|
+
assert.strictEqual(blocks.length, 1);
|
|
81
|
+
assert.strictEqual(blocks[0].search, "\nfoo");
|
|
82
|
+
assert.strictEqual(blocks[0].replace, "\nbar");
|
|
83
|
+
});
|
|
84
|
+
test("replacement with leading blank line works", () => {
|
|
85
|
+
const result = applySearchReplace("Header\n\nContent\n", [
|
|
86
|
+
{ search: "Content", replace: "\nNewContent" },
|
|
87
|
+
]);
|
|
88
|
+
assert.strictEqual(result.description, "Header\n\n\nNewContent\n");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("applyUnifiedDiff", () => {
|
|
92
|
+
test("applies simple diff", () => {
|
|
93
|
+
const source = "Line 1\nLine 2\nLine 3\n";
|
|
94
|
+
const patch = "--- old\n+++ new\n@@ -1,3 +1,3 @@\n Line 1\n-Line 2\n+Line 2 modified\n Line 3\n";
|
|
95
|
+
const result = applyUnifiedDiff(source, patch);
|
|
96
|
+
assert.ok(result.description.includes("Line 2 modified"));
|
|
97
|
+
});
|
|
98
|
+
test("fails on non-matching diff", () => {
|
|
99
|
+
const source = "AAA\nBBB\n";
|
|
100
|
+
const patch = "--- old\n+++ new\n@@ -1,2 +1,2 @@\n-XXX\n+YYY\n BBB\n";
|
|
101
|
+
assert.throws(() => applyUnifiedDiff(source, patch), /could not be applied/);
|
|
102
|
+
});
|
|
103
|
+
test("fails on malformed patch", () => {
|
|
104
|
+
assert.throws(() => applyUnifiedDiff("text", "not a patch"), /no valid hunks/i);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
// ---- Integration tests via MCP client ----
|
|
108
|
+
describe("update_issue_description_patch MCP tool", () => {
|
|
109
|
+
let mockGitLab;
|
|
110
|
+
let server;
|
|
111
|
+
let client;
|
|
112
|
+
const MOCK_PORT_BASE = 9600;
|
|
113
|
+
const MCP_PORT_BASE = 3600;
|
|
114
|
+
let portCounter = 0;
|
|
115
|
+
async function launchMcp(mockGitLabUrl, extraEnv = {}) {
|
|
116
|
+
const port = MCP_PORT_BASE + portCounter++ * 10;
|
|
117
|
+
return launchServer({
|
|
118
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
119
|
+
port,
|
|
120
|
+
timeout: 10000,
|
|
121
|
+
env: {
|
|
122
|
+
STREAMABLE_HTTP: "true",
|
|
123
|
+
REMOTE_AUTHORIZATION: "true",
|
|
124
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
125
|
+
GITLAB_ACCESS_TOKEN: MOCK_TOKEN,
|
|
126
|
+
...extraEnv,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
async function getClient(port) {
|
|
131
|
+
const client = new CustomHeaderClient({
|
|
132
|
+
headers: {
|
|
133
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
await client.connect(`http://${HOST}:${port}/mcp`);
|
|
137
|
+
return client;
|
|
138
|
+
}
|
|
139
|
+
before(async () => {
|
|
140
|
+
const mockPort = await findMockServerPort(MOCK_PORT_BASE);
|
|
141
|
+
mockGitLab = new MockGitLabServer({
|
|
142
|
+
port: mockPort,
|
|
143
|
+
validTokens: [MOCK_TOKEN],
|
|
144
|
+
});
|
|
145
|
+
await mockGitLab.start();
|
|
146
|
+
const mockGitLabUrl = mockGitLab.getUrl();
|
|
147
|
+
server = await launchMcp(mockGitLabUrl, { GITLAB_TOOLSETS: "issues" });
|
|
148
|
+
client = await getClient(server.port ?? 0);
|
|
149
|
+
});
|
|
150
|
+
after(async () => {
|
|
151
|
+
await client?.disconnect();
|
|
152
|
+
cleanupServers([server]);
|
|
153
|
+
await mockGitLab?.stop();
|
|
154
|
+
});
|
|
155
|
+
test("tool appears in tool list", async () => {
|
|
156
|
+
const result = await client.listTools();
|
|
157
|
+
const names = result.tools.map((t) => t.name);
|
|
158
|
+
assert.ok(names.includes("update_issue_description_patch"), "tool should be in list");
|
|
159
|
+
});
|
|
160
|
+
test("dry_run: search_replace returns preview without modifying", async () => {
|
|
161
|
+
// Get current description
|
|
162
|
+
const getResult = await client.callTool("get_issue", {
|
|
163
|
+
project_id: "test/project",
|
|
164
|
+
issue_iid: "1",
|
|
165
|
+
});
|
|
166
|
+
const issue = JSON.parse(getResult.content[0]?.text || "{}");
|
|
167
|
+
const originalDesc = issue.description;
|
|
168
|
+
// Dry-run
|
|
169
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
170
|
+
project_id: "test/project",
|
|
171
|
+
issue_iid: "1",
|
|
172
|
+
patch_type: "search_replace",
|
|
173
|
+
patch: `<<<<<<< SEARCH\n${originalDesc}\n=======\nShould NOT persist\n>>>>>>> REPLACE`,
|
|
174
|
+
dry_run: true,
|
|
175
|
+
});
|
|
176
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
177
|
+
assert.strictEqual(data.status, "preview");
|
|
178
|
+
assert.strictEqual(data.dry_run, true);
|
|
179
|
+
assert.strictEqual(data.changes, 1);
|
|
180
|
+
// Verify NOT updated
|
|
181
|
+
const getAgain = await client.callTool("get_issue", {
|
|
182
|
+
project_id: "test/project",
|
|
183
|
+
issue_iid: "1",
|
|
184
|
+
});
|
|
185
|
+
const issueAgain = JSON.parse(getAgain.content[0]?.text || "{}");
|
|
186
|
+
assert.strictEqual(issueAgain.description, originalDesc, "should be unchanged after dry_run");
|
|
187
|
+
});
|
|
188
|
+
test("search_replace: applies the patch", async () => {
|
|
189
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
190
|
+
project_id: "test/project",
|
|
191
|
+
issue_iid: "1",
|
|
192
|
+
patch_type: "search_replace",
|
|
193
|
+
patch: `<<<<<<< SEARCH\nDescription for issue 1\n=======\nPatched description\n>>>>>>> REPLACE`,
|
|
194
|
+
});
|
|
195
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
196
|
+
assert.strictEqual(data.status, "success");
|
|
197
|
+
assert.strictEqual(data.changes, 1);
|
|
198
|
+
// Verify persisted
|
|
199
|
+
const getResult = await client.callTool("get_issue", {
|
|
200
|
+
project_id: "test/project",
|
|
201
|
+
issue_iid: "1",
|
|
202
|
+
});
|
|
203
|
+
const issue = JSON.parse(getResult.content[0]?.text || "{}");
|
|
204
|
+
assert.strictEqual(issue.description, "Patched description");
|
|
205
|
+
});
|
|
206
|
+
test("create_note: note is attempted after update", async () => {
|
|
207
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
208
|
+
project_id: "test/project",
|
|
209
|
+
issue_iid: "1",
|
|
210
|
+
patch_type: "search_replace",
|
|
211
|
+
patch: `<<<<<<< SEARCH\nPatched description\n=======\nDescription with note\n>>>>>>> REPLACE`,
|
|
212
|
+
create_note: true,
|
|
213
|
+
});
|
|
214
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
215
|
+
assert.strictEqual(data.status, "success");
|
|
216
|
+
assert.ok(data.note !== undefined, "note result should be present");
|
|
217
|
+
});
|
|
218
|
+
test("search_replace: fails on no match", async () => {
|
|
219
|
+
try {
|
|
220
|
+
await client.callTool("update_issue_description_patch", {
|
|
221
|
+
project_id: "test/project",
|
|
222
|
+
issue_iid: "1",
|
|
223
|
+
patch_type: "search_replace",
|
|
224
|
+
patch: `<<<<<<< SEARCH\nNonExistentText_{UNIQUE}_\n=======\nShould fail\n>>>>>>> REPLACE`,
|
|
225
|
+
});
|
|
226
|
+
assert.fail("Should have thrown");
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
const msg = err.message ?? String(err);
|
|
230
|
+
assert.ok(msg.includes("not found"), `Error should mention 'not found': ${msg}`);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
test("unified_diff: applies the patch", async () => {
|
|
234
|
+
// Set known state
|
|
235
|
+
await client.callTool("update_issue_description_patch", {
|
|
236
|
+
project_id: "test/project",
|
|
237
|
+
issue_iid: "1",
|
|
238
|
+
patch_type: "search_replace",
|
|
239
|
+
patch: `<<<<<<< SEARCH\nDescription with note\n=======\nLine 1\nLine 2\nLine 3\n>>>>>>> REPLACE`,
|
|
240
|
+
});
|
|
241
|
+
const result = await client.callTool("update_issue_description_patch", {
|
|
242
|
+
project_id: "test/project",
|
|
243
|
+
issue_iid: "1",
|
|
244
|
+
patch_type: "unified_diff",
|
|
245
|
+
patch: "--- old\n+++ new\n@@ -1,3 +1,3 @@\n Line 1\n-Line 2\n+Line 2 changed\n Line 3\n",
|
|
246
|
+
});
|
|
247
|
+
const data = JSON.parse(result.content[0]?.text || "{}");
|
|
248
|
+
assert.strictEqual(data.status, "success");
|
|
249
|
+
const getResult = await client.callTool("get_issue", {
|
|
250
|
+
project_id: "test/project",
|
|
251
|
+
issue_iid: "1",
|
|
252
|
+
});
|
|
253
|
+
const issue = JSON.parse(getResult.content[0]?.text || "{}");
|
|
254
|
+
assert.ok(issue.description.includes("Line 2 changed"));
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -389,7 +389,7 @@ describe("Policy Edge Cases", { concurrency: 1 }, () => {
|
|
|
389
389
|
test("hiding all toolset tools leaves only discover_tools", async () => {
|
|
390
390
|
const allIssueTools = [
|
|
391
391
|
"create_issue", "list_issues", "my_issues", "get_issue",
|
|
392
|
-
"update_issue", "delete_issue", "create_issue_note", "update_issue_note",
|
|
392
|
+
"update_issue", "update_issue_description_patch", "delete_issue", "list_todos", "mark_todo_done", "mark_all_todos_done", "create_issue_note", "update_issue_note",
|
|
393
393
|
"list_issue_links", "list_issue_discussions", "get_issue_link",
|
|
394
394
|
"create_issue_link", "delete_issue_link", "create_note",
|
|
395
395
|
"list_issue_emoji_reactions", "list_issue_note_emoji_reactions",
|
|
@@ -17,9 +17,9 @@ const MCP_PORT_BASE = 3200;
|
|
|
17
17
|
// Known tool counts per toolset (from TOOLSET_DEFINITIONS)
|
|
18
18
|
const TOOLSET_TOOL_COUNTS = {
|
|
19
19
|
merge_requests: 41,
|
|
20
|
-
issues:
|
|
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",
|
|
@@ -63,7 +65,7 @@ const TOOLSET_SAMPLE_TOOLS = {
|
|
|
63
65
|
merge_requests: ["merge_merge_request", "create_merge_request_thread", "list_draft_notes"],
|
|
64
66
|
issues: ["create_issue", "list_issues", "create_note", "list_todos"],
|
|
65
67
|
repositories: ["search_repositories", "get_file_contents", "push_files"],
|
|
66
|
-
branches: ["create_branch", "list_commits", "list_commit_statuses", "create_commit_status"],
|
|
68
|
+
branches: ["create_branch", "get_branch", "list_branches", "delete_branch", "list_commits", "list_commit_statuses", "create_commit_status"],
|
|
67
69
|
projects: ["get_project", "list_namespaces", "list_group_iterations"],
|
|
68
70
|
labels: ["list_labels", "create_label"],
|
|
69
71
|
ci: ["validate_ci_lint", "validate_project_ci_lint"],
|
|
@@ -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 = {}) {
|
|
@@ -308,6 +311,7 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
|
|
|
308
311
|
const writeIssueTools = [
|
|
309
312
|
"create_issue",
|
|
310
313
|
"update_issue",
|
|
314
|
+
"update_issue_description_patch",
|
|
311
315
|
"delete_issue",
|
|
312
316
|
"mark_todo_done",
|
|
313
317
|
"mark_all_todos_done",
|
|
@@ -13,6 +13,8 @@ export class MockGitLabServer {
|
|
|
13
13
|
// Root-level dynamic router (for OAuth paths not under /api/v4)
|
|
14
14
|
rootRouter;
|
|
15
15
|
rootHandlers = new Map();
|
|
16
|
+
// In-memory store for mutable resources (issues, etc.)
|
|
17
|
+
issueStore = new Map();
|
|
16
18
|
constructor(config) {
|
|
17
19
|
this.config = config;
|
|
18
20
|
this.app = express();
|
|
@@ -314,6 +316,12 @@ export class MockGitLabServer {
|
|
|
314
316
|
this.app.get("/api/v4/projects/:projectId/issues/:issue_iid", (req, res) => {
|
|
315
317
|
const issueIid = parseInt(req.params.issue_iid);
|
|
316
318
|
const projectId = req.params.projectId;
|
|
319
|
+
const storeKey = `${projectId}:${issueIid}`;
|
|
320
|
+
const stored = this.issueStore.get(storeKey);
|
|
321
|
+
if (stored) {
|
|
322
|
+
res.json(stored);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
317
325
|
res.json({
|
|
318
326
|
id: issueIid,
|
|
319
327
|
iid: issueIid,
|
|
@@ -337,6 +345,44 @@ export class MockGitLabServer {
|
|
|
337
345
|
milestone: null,
|
|
338
346
|
});
|
|
339
347
|
});
|
|
348
|
+
// PUT /api/v4/projects/:projectId/issues/:issue_iid - Update issue
|
|
349
|
+
this.app.put("/api/v4/projects/:projectId/issues/:issue_iid", (req, res) => {
|
|
350
|
+
const issueIid = parseInt(req.params.issue_iid);
|
|
351
|
+
const projectId = req.params.projectId;
|
|
352
|
+
const storeKey = `${projectId}:${issueIid}`;
|
|
353
|
+
// Build response from stored data (if previously updated) or defaults
|
|
354
|
+
const stored = this.issueStore.get(storeKey) || {};
|
|
355
|
+
const description = req.body?.description ?? stored.description ?? `Description for issue ${issueIid}`;
|
|
356
|
+
const title = req.body?.title ?? stored.title ?? `Test Issue ${issueIid}`;
|
|
357
|
+
const state = req.body?.state_event === "close" ? "closed" :
|
|
358
|
+
req.body?.state_event === "reopen" ? "opened" :
|
|
359
|
+
(stored.state ?? "opened");
|
|
360
|
+
const updatedIssue = {
|
|
361
|
+
id: issueIid,
|
|
362
|
+
iid: issueIid,
|
|
363
|
+
project_id: projectId,
|
|
364
|
+
title,
|
|
365
|
+
description,
|
|
366
|
+
state,
|
|
367
|
+
created_at: stored.created_at ?? "2024-01-01T00:00:00Z",
|
|
368
|
+
updated_at: new Date().toISOString(),
|
|
369
|
+
closed_at: state === "closed" ? new Date().toISOString() : null,
|
|
370
|
+
web_url: `https://gitlab.mock/project/${projectId}/issues/${issueIid}`,
|
|
371
|
+
author: {
|
|
372
|
+
id: 1,
|
|
373
|
+
username: "test-user",
|
|
374
|
+
name: "Test User",
|
|
375
|
+
avatar_url: null,
|
|
376
|
+
web_url: "https://gitlab.mock/test-user",
|
|
377
|
+
},
|
|
378
|
+
assignees: [],
|
|
379
|
+
labels: [],
|
|
380
|
+
milestone: null,
|
|
381
|
+
};
|
|
382
|
+
// Store for subsequent GET requests
|
|
383
|
+
this.issueStore.set(storeKey, updatedIssue);
|
|
384
|
+
res.json(updatedIssue);
|
|
385
|
+
});
|
|
340
386
|
// Mock blob search result
|
|
341
387
|
const mockBlobResults = [
|
|
342
388
|
{
|