@zereight/mcp-gitlab 2.1.29 → 2.1.38

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.
Files changed (71) hide show
  1. package/README.ko.md +72 -20
  2. package/README.md +120 -82
  3. package/README.zh-CN.md +72 -20
  4. package/build/config.js +13 -0
  5. package/build/downloads/proxy.js +199 -0
  6. package/build/index.js +850 -490
  7. package/build/schemas.js +115 -28
  8. package/build/scripts/check-skill-sync.js +137 -0
  9. package/build/scripts/generate-tool-docs.js +8 -3
  10. package/build/server/metrics.js +84 -0
  11. package/build/server/transport-mode.js +25 -0
  12. package/build/test/callback-proxy-tests.js +1 -1
  13. package/build/test/client-pool-test.js +1 -1
  14. package/build/test/dynamic-api-url-allowlist.test.js +2 -2
  15. package/build/test/dynamic-api-url-test.js +4 -4
  16. package/build/test/dynamic-routing-tests.js +4 -4
  17. package/build/test/group-milestone-schema.test.js +45 -0
  18. package/build/test/mcp-oauth-tests.js +29 -4
  19. package/build/test/mcp-server-name.test.js +87 -0
  20. package/build/test/multi-server-test.js +3 -3
  21. package/build/test/no-proxy-integration-test.js +1 -1
  22. package/build/test/nullable-gitlab-response-fields.test.js +22 -0
  23. package/build/test/path-segment-encoding.test.js +11 -0
  24. package/build/test/remote-auth-simple-test.js +226 -199
  25. package/build/test/server/metrics.test.js +47 -0
  26. package/build/test/sse-auth-guard.test.js +2 -2
  27. package/build/test/stateless/session-id-integration.test.js +3 -3
  28. package/build/test/streamable-http-concurrent-session.test.js +64 -1
  29. package/build/test/streamable-http-dns-rebinding.test.js +185 -0
  30. package/build/test/streamable-http-static-token-auth.test.js +25 -22
  31. package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
  32. package/build/test/test-ci-catalog.js +1 -1
  33. package/build/test/test-ci-lint.js +1 -1
  34. package/build/test/test-ci-variables.js +8 -5
  35. package/build/test/test-dependency-proxy.js +11 -7
  36. package/build/test/test-deployment-tools.js +16 -2
  37. package/build/test/test-download-attachment.js +1 -1
  38. package/build/test/test-get-file-blame.js +1 -1
  39. package/build/test/test-geteffectiveprojectid.js +221 -8
  40. package/build/test/test-issue-description-patch.js +1 -1
  41. package/build/test/test-job-artifacts.js +1 -1
  42. package/build/test/test-list-issues.js +1 -1
  43. package/build/test/test-list-merge-requests.js +1 -1
  44. package/build/test/test-list-project-members.js +1 -1
  45. package/build/test/test-merge-request-approval-state-tools.js +1 -1
  46. package/build/test/test-merge-request-pipelines.js +1 -1
  47. package/build/test/test-mr-diffs-filter.js +1 -1
  48. package/build/test/test-mr-file-diffs.js +2 -2
  49. package/build/test/test-oauth-proxy-rate-limit.js +1 -1
  50. package/build/test/test-permission-mode.js +216 -0
  51. package/build/test/test-protected-branches.js +23 -4
  52. package/build/test/test-remote-downloads.js +2 -2
  53. package/build/test/test-search-code.js +1 -1
  54. package/build/test/test-tags.js +1 -1
  55. package/build/test/test-todos.js +1 -1
  56. package/build/test/test-token-optimizations.js +3 -3
  57. package/build/test/test-toolset-filtering.js +3 -3
  58. package/build/test/test-update-issue-slim.js +141 -0
  59. package/build/test/test-upload-markdown.js +1 -1
  60. package/build/test/utils/download-token.test.js +35 -0
  61. package/build/test/utils/forwarded-public-base-url.test.js +9 -1
  62. package/build/test/utils/graphql-query.test.js +64 -1
  63. package/build/test/utils/mock-gitlab-server.js +24 -31
  64. package/build/test/utils/server-launcher.js +1 -2
  65. package/build/test/utils/version-check.test.js +52 -0
  66. package/build/tools/registry.js +93 -5
  67. package/build/utils/download-token.js +71 -0
  68. package/build/utils/forwarded-public-base-url.js +22 -0
  69. package/build/utils/graphql-query.js +109 -12
  70. package/build/utils/version-check.js +40 -0
  71. package/package.json +5 -4
@@ -26,13 +26,134 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
26
26
  let mockGitLab;
27
27
  let servers = [];
28
28
  let client;
29
+ let group123Requested = false;
30
+ let group789Requested = false;
31
+ let labelLookupRoots = [];
29
32
  before(async () => {
30
33
  // Start mock GitLab server
31
- const mockPort = await findMockServerPort(9100);
34
+ const mockPort = await findMockServerPort();
32
35
  mockGitLab = new MockGitLabServer({
33
36
  port: mockPort,
34
37
  validTokens: [MOCK_TOKEN]
35
38
  });
39
+ mockGitLab.addMockHandler('get', '/groups/123', (_req, res) => {
40
+ group123Requested = true;
41
+ res.json({
42
+ id: 123,
43
+ name: 'Test Group',
44
+ path: 'test-group',
45
+ full_path: 'test-group',
46
+ });
47
+ });
48
+ mockGitLab.addMockHandler('get', '/projects/789', (_req, res) => {
49
+ res.status(404).json({ message: '404 Project Not Found' });
50
+ });
51
+ mockGitLab.addMockHandler('get', '/groups/789', (_req, res) => {
52
+ group789Requested = true;
53
+ res.json({
54
+ id: 789,
55
+ name: 'Numeric Group',
56
+ path: 'numeric-group',
57
+ full_path: 'numeric-group',
58
+ });
59
+ });
60
+ mockGitLab.addRootHandler('post', '/api/graphql', (req, res) => {
61
+ const { query, variables } = req.body;
62
+ if (query.includes('workItemTypes')) {
63
+ res.json({
64
+ data: {
65
+ namespace: {
66
+ workItemTypes: {
67
+ nodes: [{ id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' }],
68
+ },
69
+ },
70
+ },
71
+ });
72
+ return;
73
+ }
74
+ if (query.includes('workItems(')) {
75
+ assert.ok(query.includes('group(fullPath: $path)'), 'Should query the group GraphQL root');
76
+ assert.strictEqual(variables.path, 'test-group', 'Should use resolved group full_path');
77
+ res.json({
78
+ data: {
79
+ group: {
80
+ workItems: {
81
+ nodes: [
82
+ {
83
+ id: 'gid://gitlab/WorkItem/1',
84
+ iid: '1',
85
+ title: 'Group work item',
86
+ state: 'OPEN',
87
+ webUrl: 'https://gitlab.mock/groups/test-group/-/work_items/1',
88
+ workItemType: { name: 'Issue' },
89
+ widgets: [],
90
+ },
91
+ ],
92
+ pageInfo: { hasNextPage: false, endCursor: null },
93
+ },
94
+ },
95
+ },
96
+ });
97
+ return;
98
+ }
99
+ if (query.includes('labels(title:')) {
100
+ assert.ok(query.includes('group(fullPath: $path)'), 'Should resolve labels from Group');
101
+ assert.ok(!query.includes('namespace(fullPath: $path)'), 'Should not query labels from Namespace');
102
+ labelLookupRoots.push('group');
103
+ res.json({
104
+ data: {
105
+ group: { l0: { nodes: [{ id: 'gid://gitlab/GroupLabel/1' }] } },
106
+ users: { nodes: [] },
107
+ },
108
+ });
109
+ return;
110
+ }
111
+ if (query.includes('workItem(iid: $iid)')) {
112
+ res.json({
113
+ data: { namespace: { workItem: { id: 'gid://gitlab/WorkItem/1' } } },
114
+ });
115
+ return;
116
+ }
117
+ if (query.includes('workItemCreate')) {
118
+ assert.deepStrictEqual(variables.labelIds, ['gid://gitlab/GroupLabel/1']);
119
+ res.json({
120
+ data: {
121
+ workItemCreate: {
122
+ workItem: {
123
+ id: 'gid://gitlab/WorkItem/2',
124
+ iid: '2',
125
+ title: variables.title,
126
+ webUrl: 'https://gitlab.mock/groups/test-group/-/work_items/2',
127
+ workItemType: { name: 'Issue' },
128
+ },
129
+ errors: [],
130
+ },
131
+ },
132
+ });
133
+ return;
134
+ }
135
+ if (query.includes('workItemUpdate')) {
136
+ assert.deepStrictEqual(variables.addLabelIds, ['gid://gitlab/GroupLabel/1']);
137
+ res.json({
138
+ data: {
139
+ workItemUpdate: {
140
+ workItem: {
141
+ id: variables.id,
142
+ iid: '1',
143
+ title: 'Updated group work item',
144
+ state: 'OPEN',
145
+ webUrl: 'https://gitlab.mock/groups/test-group/-/work_items/1',
146
+ workItemType: { name: 'Issue' },
147
+ widgets: [],
148
+ },
149
+ errors: [],
150
+ },
151
+ },
152
+ });
153
+ return;
154
+ }
155
+ res.status(500).json({ message: `Unexpected GraphQL query: ${query}` });
156
+ });
36
157
  await mockGitLab.start();
37
158
  const mockGitLabUrl = mockGitLab.getUrl();
38
159
  // Start MCP server WITHOUT GITLAB_ALLOWED_PROJECT_IDS
@@ -46,7 +167,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
46
167
  REMOTE_AUTHORIZATION: 'true',
47
168
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
48
169
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
49
- GITLAB_READ_ONLY_MODE: 'true',
170
+ GITLAB_READ_ONLY_MODE: 'false',
50
171
  }
51
172
  });
52
173
  servers.push(server);
@@ -94,6 +215,82 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
94
215
  assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
95
216
  console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
96
217
  });
218
+ test('should resolve explicit numeric group IDs through the groups endpoint', async () => {
219
+ group123Requested = false;
220
+ const result = await client.callTool('list_work_items', {
221
+ project_id: 'group:123',
222
+ });
223
+ assert.ok(group123Requested, 'Should request /groups/123');
224
+ assert.ok(result.content, 'Should have content');
225
+ const content = result.content[0];
226
+ assert.ok('text' in content, 'Content should have text');
227
+ const parsed = JSON.parse(content.text);
228
+ assert.strictEqual(parsed.items.length, 1, 'Should return group work items');
229
+ assert.strictEqual(parsed.items[0].title, 'Group work item');
230
+ console.log(' ✓ Resolved group:123 via /groups/123 when no project allowlist is set');
231
+ });
232
+ test('should resolve labels from the group root when creating group work items', async () => {
233
+ labelLookupRoots = [];
234
+ const result = await client.callTool('create_work_item', {
235
+ project_id: 'group:123',
236
+ title: 'Created group work item',
237
+ labels: ['group-label'],
238
+ });
239
+ assert.deepStrictEqual(labelLookupRoots, ['group']);
240
+ assert.ok(result.content, 'Should have content');
241
+ const content = result.content[0];
242
+ assert.ok('text' in content, 'Content should have text');
243
+ assert.strictEqual(JSON.parse(content.text).title, 'Created group work item');
244
+ console.log(' ✓ Resolved create labels through group(fullPath:)');
245
+ });
246
+ test('should resolve labels from the group root when updating group work items', async () => {
247
+ labelLookupRoots = [];
248
+ const result = await client.callTool('update_work_item', {
249
+ project_id: 'group:123',
250
+ iid: 1,
251
+ add_labels: ['group-label'],
252
+ });
253
+ assert.deepStrictEqual(labelLookupRoots, ['group']);
254
+ assert.ok(result.content, 'Should have content');
255
+ const content = result.content[0];
256
+ assert.ok('text' in content, 'Content should have text');
257
+ assert.strictEqual(JSON.parse(content.text).title, 'Updated group work item');
258
+ console.log(' ✓ Resolved update labels through group(fullPath:)');
259
+ });
260
+ test('should not fall back from bare numeric project IDs to groups', async () => {
261
+ group789Requested = false;
262
+ let didThrow = false;
263
+ try {
264
+ await client.callTool('list_work_items', {
265
+ project_id: '789',
266
+ });
267
+ }
268
+ catch (error) {
269
+ didThrow = true;
270
+ assert.ok(error instanceof Error);
271
+ assert.ok(error.message.includes('404'), 'Should surface the project 404');
272
+ }
273
+ assert.ok(didThrow, 'Should have failed on the project 404');
274
+ assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
275
+ console.log(' ✓ Bare numeric ID 789 did not fall back to /groups/789');
276
+ });
277
+ test('should not fall back from explicit numeric project IDs to groups', async () => {
278
+ group789Requested = false;
279
+ let didThrow = false;
280
+ try {
281
+ await client.callTool('list_work_items', {
282
+ project_id: 'project:789',
283
+ });
284
+ }
285
+ catch (error) {
286
+ didThrow = true;
287
+ assert.ok(error instanceof Error);
288
+ assert.ok(error.message.includes('404'), 'Should surface the project 404');
289
+ }
290
+ assert.ok(didThrow, 'Should have failed on the project 404');
291
+ assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
292
+ console.log(' ✓ Explicit project:789 did not fall back to /groups/789');
293
+ });
97
294
  });
98
295
  describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () => {
99
296
  let mcpUrl;
@@ -102,7 +299,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
102
299
  let client;
103
300
  before(async () => {
104
301
  // Start mock GitLab server
105
- const mockPort = await findMockServerPort(9200);
302
+ const mockPort = await findMockServerPort();
106
303
  mockGitLab = new MockGitLabServer({
107
304
  port: mockPort,
108
305
  validTokens: [MOCK_TOKEN]
@@ -166,6 +363,22 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
166
363
  console.log(' ✓ Correctly rejected access to non-allowed project');
167
364
  }
168
365
  });
366
+ test('should reject explicit group IDs when GITLAB_ALLOWED_PROJECT_IDS is set', async () => {
367
+ let didThrow = false;
368
+ try {
369
+ await client.callTool('list_work_items', {
370
+ project_id: 'group:123',
371
+ });
372
+ }
373
+ catch (error) {
374
+ didThrow = true;
375
+ assert.ok(error instanceof Error);
376
+ assert.ok(error.message.includes('GITLAB_ALLOWED_PROJECT_IDS') &&
377
+ error.message.includes('project allowlist does not cover groups'), 'Should explain that project allowlists do not authorize groups');
378
+ console.log(' ✓ Rejected group:123 when GITLAB_ALLOWED_PROJECT_IDS is set');
379
+ }
380
+ assert.ok(didThrow, 'Should have rejected explicit group access');
381
+ });
169
382
  });
170
383
  describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', () => {
171
384
  let mcpUrl;
@@ -174,7 +387,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
174
387
  let client;
175
388
  before(async () => {
176
389
  // Start mock GitLab server
177
- const mockPort = await findMockServerPort(9300);
390
+ const mockPort = await findMockServerPort();
178
391
  mockGitLab = new MockGitLabServer({
179
392
  port: mockPort,
180
393
  validTokens: [MOCK_TOKEN]
@@ -255,7 +468,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
255
468
  let servers = [];
256
469
  let client;
257
470
  before(async () => {
258
- const mockPort = await findMockServerPort(9400);
471
+ const mockPort = await findMockServerPort();
259
472
  mockGitLab = new MockGitLabServer({
260
473
  port: mockPort,
261
474
  validTokens: [MOCK_TOKEN]
@@ -353,7 +566,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
353
566
  let servers = [];
354
567
  let client;
355
568
  before(async () => {
356
- const mockPort = await findMockServerPort(9600);
569
+ const mockPort = await findMockServerPort();
357
570
  mockGitLab = new MockGitLabServer({
358
571
  port: mockPort,
359
572
  validTokens: [MOCK_TOKEN]
@@ -451,7 +664,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
451
664
  let servers = [];
452
665
  let client;
453
666
  before(async () => {
454
- const mockPort = await findMockServerPort(9500);
667
+ const mockPort = await findMockServerPort();
455
668
  mockGitLab = new MockGitLabServer({
456
669
  port: mockPort,
457
670
  validTokens: [MOCK_TOKEN]
@@ -514,7 +727,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
514
727
  let servers = [];
515
728
  let client;
516
729
  before(async () => {
517
- const mockPort = await findMockServerPort(9500);
730
+ const mockPort = await findMockServerPort();
518
731
  mockGitLab = new MockGitLabServer({
519
732
  port: mockPort,
520
733
  validTokens: [MOCK_TOKEN],
@@ -137,7 +137,7 @@ describe("update_issue_description_patch MCP tool", () => {
137
137
  return client;
138
138
  }
139
139
  before(async () => {
140
- const mockPort = await findMockServerPort(MOCK_PORT_BASE);
140
+ const mockPort = await findMockServerPort();
141
141
  mockGitLab = new MockGitLabServer({
142
142
  port: mockPort,
143
143
  validTokens: [MOCK_TOKEN],
@@ -69,7 +69,7 @@ describe('job artifacts tools', () => {
69
69
  let mockGitLabUrl;
70
70
  let tmpDir;
71
71
  before(async () => {
72
- const mockPort = await findMockServerPort(9200);
72
+ const mockPort = await findMockServerPort();
73
73
  mockGitLab = new MockGitLabServer({
74
74
  port: mockPort,
75
75
  validTokens: [MOCK_TOKEN],
@@ -65,7 +65,7 @@ describe("list_issues", () => {
65
65
  let mockGitLab;
66
66
  let mockGitLabUrl;
67
67
  before(async () => {
68
- const mockPort = await findMockServerPort(9000);
68
+ const mockPort = await findMockServerPort();
69
69
  mockGitLab = new MockGitLabServer({
70
70
  port: mockPort,
71
71
  validTokens: [MOCK_TOKEN],
@@ -62,7 +62,7 @@ describe('list_merge_requests', () => {
62
62
  let mockGitLab;
63
63
  let mockGitLabUrl;
64
64
  before(async () => {
65
- const mockPort = await findMockServerPort(9000);
65
+ const mockPort = await findMockServerPort();
66
66
  mockGitLab = new MockGitLabServer({
67
67
  port: mockPort,
68
68
  validTokens: [MOCK_TOKEN]
@@ -84,7 +84,7 @@ describe('list_project_members', () => {
84
84
  let directEndpointHit = false;
85
85
  let inheritedEndpointHit = false;
86
86
  before(async () => {
87
- const mockPort = await findMockServerPort(9000);
87
+ const mockPort = await findMockServerPort();
88
88
  mockGitLab = new MockGitLabServer({
89
89
  port: mockPort,
90
90
  validTokens: [MOCK_TOKEN]
@@ -65,7 +65,7 @@ describe("merge request approval state tools", () => {
65
65
  let mockGitLab;
66
66
  let mockGitLabUrl;
67
67
  before(async () => {
68
- const mockPort = await findMockServerPort(9400);
68
+ const mockPort = await findMockServerPort();
69
69
  mockGitLab = new MockGitLabServer({
70
70
  port: mockPort,
71
71
  validTokens: [MOCK_TOKEN],
@@ -55,7 +55,7 @@ describe("list_merge_request_pipelines", () => {
55
55
  let mockGitLabUrl;
56
56
  let lastQuery = {};
57
57
  before(async () => {
58
- const mockPort = await findMockServerPort(9250);
58
+ const mockPort = await findMockServerPort();
59
59
  mockGitLab = new MockGitLabServer({
60
60
  port: mockPort,
61
61
  validTokens: [MOCK_TOKEN],
@@ -62,7 +62,7 @@ describe('get_merge_request_diffs with excluded_file_patterns', () => {
62
62
  let mockGitLab;
63
63
  let mockGitLabUrl;
64
64
  before(async () => {
65
- const mockPort = await findMockServerPort(9100);
65
+ const mockPort = await findMockServerPort();
66
66
  mockGitLab = new MockGitLabServer({
67
67
  port: mockPort,
68
68
  validTokens: [MOCK_TOKEN]
@@ -115,7 +115,7 @@ describe('list_merge_request_changed_files', () => {
115
115
  let mockGitLab;
116
116
  let mockGitLabUrl;
117
117
  before(async () => {
118
- const mockPort = await findMockServerPort(9150);
118
+ const mockPort = await findMockServerPort();
119
119
  mockGitLab = new MockGitLabServer({
120
120
  port: mockPort,
121
121
  validTokens: [MOCK_TOKEN]
@@ -177,7 +177,7 @@ describe('get_merge_request_file_diff', () => {
177
177
  let mockGitLab;
178
178
  let mockGitLabUrl;
179
179
  before(async () => {
180
- const mockPort = await findMockServerPort(9200);
180
+ const mockPort = await findMockServerPort();
181
181
  mockGitLab = new MockGitLabServer({
182
182
  port: mockPort,
183
183
  validTokens: [MOCK_TOKEN]
@@ -38,7 +38,7 @@ describe("When MCP OAuth runs behind a trusted proxy", () => {
38
38
  let mockGitLab;
39
39
  let servers = [];
40
40
  before(async () => {
41
- const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE);
41
+ const mockPort = await findMockServerPort();
42
42
  mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [] });
43
43
  await mockGitLab.start();
44
44
  const mockGitLabUrl = mockGitLab.getUrl();
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Permission Mode Test Suite
3
+ *
4
+ * Tests GITLAB_PERMISSION_MODE (readonly / modify / full), its interaction with
5
+ * the legacy GITLAB_READ_ONLY_MODE flag, and enforcement at list_tools,
6
+ * call_tool, and execute_graphql levels.
7
+ */
8
+ import { describe, test, after, before } from "node:test";
9
+ import assert from "node:assert";
10
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
11
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
12
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
13
+ const MOCK_TOKEN = "glpat-permission-mode-test";
14
+ const MOCK_PORT_BASE = 9700;
15
+ const MCP_PORT_BASE = 3700;
16
+ let mockGitLab;
17
+ let mockGitLabUrl;
18
+ let portCounter = 0;
19
+ async function nextMcpPort() {
20
+ return findAvailablePort(MCP_PORT_BASE + portCounter++ * 10);
21
+ }
22
+ async function launchMcpServer(extraEnv = {}) {
23
+ const port = await nextMcpPort();
24
+ return launchServer({
25
+ mode: TransportMode.STREAMABLE_HTTP,
26
+ port,
27
+ timeout: 10000,
28
+ env: {
29
+ STREAMABLE_HTTP: "true",
30
+ REMOTE_AUTHORIZATION: "true",
31
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
32
+ GITLAB_TOOLSETS: "all",
33
+ ...extraEnv,
34
+ },
35
+ });
36
+ }
37
+ function mcpUrl(server) {
38
+ return `http://${HOST}:${server.port}/mcp`;
39
+ }
40
+ async function connectClient(server) {
41
+ const client = new CustomHeaderClient({
42
+ authorization: `Bearer ${MOCK_TOKEN}`,
43
+ });
44
+ await client.connect(mcpUrl(server));
45
+ return client;
46
+ }
47
+ async function getToolNames(server) {
48
+ const client = await connectClient(server);
49
+ const result = await client.listTools();
50
+ await client.disconnect();
51
+ return result.tools.map((t) => t.name);
52
+ }
53
+ const DELETE_SAMPLE_TOOLS = [
54
+ "delete_issue",
55
+ "delete_branch",
56
+ "delete_label",
57
+ "delete_wiki_page",
58
+ "delete_milestone",
59
+ "delete_group_milestone",
60
+ "delete_project_variable",
61
+ ];
62
+ const MODIFY_SAMPLE_TOOLS = [
63
+ "create_issue",
64
+ "update_issue",
65
+ "create_merge_request",
66
+ "create_branch",
67
+ "push_files",
68
+ "merge_merge_request",
69
+ ];
70
+ const READ_SAMPLE_TOOLS = ["list_issues", "get_project", "list_merge_requests"];
71
+ describe("Permission Mode", { concurrency: 1 }, () => {
72
+ before(async () => {
73
+ const mockPort = await findMockServerPort();
74
+ mockGitLab = new MockGitLabServer({
75
+ port: mockPort,
76
+ validTokens: [MOCK_TOKEN],
77
+ });
78
+ await mockGitLab.start();
79
+ mockGitLabUrl = mockGitLab.getUrl();
80
+ });
81
+ after(async () => {
82
+ if (mockGitLab)
83
+ await mockGitLab.stop();
84
+ });
85
+ describe("full mode (default)", () => {
86
+ let server;
87
+ const servers = [];
88
+ before(async () => {
89
+ server = await launchMcpServer();
90
+ servers.push(server);
91
+ });
92
+ after(() => cleanupServers(servers));
93
+ test("exposes read, modify, and delete tools", async () => {
94
+ const names = await getToolNames(server);
95
+ for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
96
+ assert.ok(names.includes(name), `full mode: expected "${name}" to be present`);
97
+ }
98
+ });
99
+ });
100
+ describe("modify mode", () => {
101
+ let server;
102
+ const servers = [];
103
+ before(async () => {
104
+ server = await launchMcpServer({ GITLAB_PERMISSION_MODE: "modify" });
105
+ servers.push(server);
106
+ });
107
+ after(() => cleanupServers(servers));
108
+ test("hides delete tools but keeps read and modify tools", async () => {
109
+ const names = await getToolNames(server);
110
+ for (const name of DELETE_SAMPLE_TOOLS) {
111
+ assert.ok(!names.includes(name), `modify mode: expected "${name}" to be absent`);
112
+ }
113
+ for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS]) {
114
+ assert.ok(names.includes(name), `modify mode: expected "${name}" to be present`);
115
+ }
116
+ assert.ok(!names.some(n => n.startsWith("delete_")), "no delete_* tool should be listed");
117
+ });
118
+ test("hides purge_dependency_proxy_cache", async () => {
119
+ const names = await getToolNames(server);
120
+ assert.ok(!names.includes("purge_dependency_proxy_cache"), "modify mode: purge_dependency_proxy_cache should be absent");
121
+ });
122
+ test("rejects direct delete tool calls", async () => {
123
+ const client = await connectClient(server);
124
+ try {
125
+ await assert.rejects(() => client.callTool("delete_issue", { project_id: "1", issue_iid: 1 }), (error) => error.message.includes("not allowed in modify mode"), "delete_issue should be rejected in modify mode");
126
+ await assert.rejects(() => client.callTool("purge_dependency_proxy_cache", { group_id: "my-group" }), (error) => error.message.includes("not allowed in modify mode"), "purge_dependency_proxy_cache should be rejected in modify mode");
127
+ }
128
+ finally {
129
+ await client.disconnect();
130
+ }
131
+ });
132
+ test("rejects delete mutations via execute_graphql", async () => {
133
+ const client = await connectClient(server);
134
+ try {
135
+ await assert.rejects(() => client.callTool("execute_graphql", {
136
+ query: 'mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }',
137
+ }), (error) => error.message.includes("delete mutations in modify mode"), "delete mutation should be rejected in modify mode");
138
+ }
139
+ finally {
140
+ await client.disconnect();
141
+ }
142
+ });
143
+ test("does not apply the modify-mode guard to non-delete mutations", async () => {
144
+ const client = await connectClient(server);
145
+ try {
146
+ // The mock GitLab server has no GraphQL endpoint, so the call may fail
147
+ // downstream — but it must not fail with the modify-mode guard error.
148
+ await client.callTool("execute_graphql", {
149
+ query: "mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }",
150
+ });
151
+ }
152
+ catch (error) {
153
+ assert.ok(error instanceof Error);
154
+ assert.ok(!error.message.includes("modify mode"), `non-delete mutation should pass the guard, got: ${error.message}`);
155
+ }
156
+ finally {
157
+ await client.disconnect();
158
+ }
159
+ });
160
+ });
161
+ describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
162
+ let server;
163
+ const servers = [];
164
+ before(async () => {
165
+ server = await launchMcpServer({ GITLAB_PERMISSION_MODE: "readonly" });
166
+ servers.push(server);
167
+ });
168
+ after(() => cleanupServers(servers));
169
+ test("exposes only read-only tools", async () => {
170
+ const names = await getToolNames(server);
171
+ for (const name of READ_SAMPLE_TOOLS) {
172
+ assert.ok(names.includes(name), `readonly mode: expected "${name}" to be present`);
173
+ }
174
+ for (const name of [...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
175
+ assert.ok(!names.includes(name), `readonly mode: expected "${name}" to be absent`);
176
+ }
177
+ });
178
+ test("rejects write tool calls", async () => {
179
+ const client = await connectClient(server);
180
+ try {
181
+ await assert.rejects(() => client.callTool("create_issue", { project_id: "1", title: "t" }), (error) => error.message.includes("not allowed in read-only mode"), "create_issue should be rejected in readonly mode");
182
+ }
183
+ finally {
184
+ await client.disconnect();
185
+ }
186
+ });
187
+ });
188
+ describe("legacy GITLAB_READ_ONLY_MODE interaction", () => {
189
+ const servers = [];
190
+ after(() => cleanupServers(servers));
191
+ test("GITLAB_READ_ONLY_MODE=true still behaves as readonly", async () => {
192
+ const server = await launchMcpServer({ GITLAB_READ_ONLY_MODE: "true" });
193
+ servers.push(server);
194
+ const names = await getToolNames(server);
195
+ assert.ok(names.includes("list_issues"), "read tool should be present");
196
+ assert.ok(!names.includes("create_issue"), "write tool should be absent");
197
+ assert.ok(!names.includes("delete_issue"), "delete tool should be absent");
198
+ });
199
+ test("GITLAB_READ_ONLY_MODE=true wins over GITLAB_PERMISSION_MODE=full", async () => {
200
+ const server = await launchMcpServer({
201
+ GITLAB_READ_ONLY_MODE: "true",
202
+ GITLAB_PERMISSION_MODE: "full",
203
+ });
204
+ servers.push(server);
205
+ const names = await getToolNames(server);
206
+ assert.ok(!names.includes("create_issue"), "legacy readonly should take precedence");
207
+ assert.ok(!names.includes("delete_issue"), "legacy readonly should take precedence");
208
+ });
209
+ test("rejects invalid GITLAB_PERMISSION_MODE even when GITLAB_READ_ONLY_MODE=true", async () => {
210
+ await assert.rejects(launchMcpServer({
211
+ GITLAB_READ_ONLY_MODE: "true",
212
+ GITLAB_PERMISSION_MODE: "write",
213
+ }), (error) => error.message.includes("Server process exited with code 1"), "invalid permission mode should fail startup before legacy override applies");
214
+ });
215
+ });
216
+ });
@@ -10,10 +10,29 @@ function buildProtectedBranch(overrides = {}) {
10
10
  id: 1,
11
11
  name: TEST_BRANCH,
12
12
  push_access_levels: [
13
- { access_level: 30, access_level_description: "Developers + Maintainers" },
13
+ {
14
+ access_level: 30,
15
+ access_level_description: "Developers + Maintainers",
16
+ user_id: null,
17
+ group_id: null,
18
+ },
19
+ ],
20
+ merge_access_levels: [
21
+ {
22
+ access_level: 40,
23
+ access_level_description: "Maintainers",
24
+ user_id: null,
25
+ group_id: null,
26
+ },
27
+ ],
28
+ unprotect_access_levels: [
29
+ {
30
+ access_level: 60,
31
+ access_level_description: "Administrators",
32
+ user_id: null,
33
+ group_id: null,
34
+ },
14
35
  ],
15
- merge_access_levels: [{ access_level: 40, access_level_description: "Maintainers" }],
16
- unprotect_access_levels: [{ access_level: 60, access_level_description: "Administrators" }],
17
36
  allow_force_push: false,
18
37
  code_owner_approval_required: false,
19
38
  ...overrides,
@@ -71,7 +90,7 @@ describe("protected branch tools", () => {
71
90
  let mockGitLab;
72
91
  let mockGitLabUrl;
73
92
  before(async () => {
74
- const mockPort = await findMockServerPort(20500, 50);
93
+ const mockPort = await findMockServerPort();
75
94
  mockGitLab = new MockGitLabServer({
76
95
  port: mockPort,
77
96
  validTokens: [MOCK_TOKEN],