@zereight/mcp-gitlab 2.1.69 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/build/utils/url.js +15 -0
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. package/build/test-resolve-issue-note.js +0 -127
@@ -1,812 +0,0 @@
1
- /**
2
- * Test suite for getEffectiveProjectId function
3
- * Tests the behavior of project ID resolution with different environment configurations
4
- */
5
- import { describe, test, before, after } from 'node:test';
6
- import assert from 'node:assert';
7
- import { launchServer, findAvailablePort, 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
- // Use the same token that will be passed via GITLAB_TOKEN_TEST environment variable
11
- const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || 'glpat-mock-token-12345';
12
- const DEFAULT_PROJECT_ID = '123';
13
- const OTHER_PROJECT_ID = '456';
14
- // Ensure GITLAB_TOKEN_TEST is set for launchServer() validation
15
- if (!process.env.GITLAB_TOKEN_TEST && !process.env.GITLAB_TOKEN) {
16
- process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN;
17
- }
18
- if (!process.env.TEST_PROJECT_ID) {
19
- process.env.TEST_PROJECT_ID = DEFAULT_PROJECT_ID;
20
- }
21
- console.log('🔍 Testing getEffectiveProjectId functionality');
22
- console.log('');
23
- describe('getEffectiveProjectId', { concurrency: 1 }, () => {
24
- describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
25
- let mcpUrl;
26
- let mockGitLab;
27
- let servers = [];
28
- let client;
29
- let group123Requested = false;
30
- let group789Requested = false;
31
- let labelLookupRoots = [];
32
- before(async () => {
33
- // Start mock GitLab server
34
- const mockPort = await findMockServerPort();
35
- mockGitLab = new MockGitLabServer({
36
- port: mockPort,
37
- validTokens: [MOCK_TOKEN]
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
- });
157
- await mockGitLab.start();
158
- const mockGitLabUrl = mockGitLab.getUrl();
159
- // Start MCP server WITHOUT GITLAB_ALLOWED_PROJECT_IDS
160
- const mcpPort = await findAvailablePort(3100);
161
- const server = await launchServer({
162
- mode: TransportMode.STREAMABLE_HTTP,
163
- port: mcpPort,
164
- timeout: 5000,
165
- env: {
166
- STREAMABLE_HTTP: 'true',
167
- REMOTE_AUTHORIZATION: 'true',
168
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
169
- GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
170
- GITLAB_READ_ONLY_MODE: 'false',
171
- GITLAB_TOOLSETS: 'all',
172
- }
173
- });
174
- servers.push(server);
175
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
176
- client = new CustomHeaderClient({
177
- authorization: `Bearer ${MOCK_TOKEN}`,
178
- });
179
- await client.connect(mcpUrl);
180
- console.log(`Mock GitLab: ${mockGitLabUrl}`);
181
- console.log(`MCP Server: ${mcpUrl}`);
182
- console.log(`Default Project: ${DEFAULT_PROJECT_ID}`);
183
- });
184
- after(async () => {
185
- if (client) {
186
- await client.disconnect();
187
- }
188
- cleanupServers(servers);
189
- if (mockGitLab) {
190
- await mockGitLab.stop();
191
- }
192
- });
193
- test('should use GITLAB_PROJECT_ID when no project_id is provided', async () => {
194
- // Call get_project without specifying project_id
195
- const result = await client.callTool('get_project', {
196
- project_id: ''
197
- });
198
- assert.ok(result.content, 'Should have content');
199
- const content = result.content[0];
200
- assert.ok('text' in content, 'Content should have text');
201
- const project = JSON.parse(content.text);
202
- // The mock server should receive a request for the default project
203
- assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use GITLAB_PROJECT_ID as default');
204
- console.log(` ✓ Used default project ${DEFAULT_PROJECT_ID} when no project_id provided`);
205
- });
206
- test('should prioritize passed project_id over GITLAB_PROJECT_ID', async () => {
207
- // Call get_project with a different project_id
208
- const result = await client.callTool('get_project', {
209
- project_id: OTHER_PROJECT_ID
210
- });
211
- assert.ok(result.content, 'Should have content');
212
- const content = result.content[0];
213
- assert.ok('text' in content, 'Content should have text');
214
- const project = JSON.parse(content.text);
215
- // Should use the passed project_id, not GITLAB_PROJECT_ID
216
- assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
217
- console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
218
- });
219
- test('should resolve explicit numeric group IDs through the groups endpoint', async () => {
220
- group123Requested = false;
221
- const result = await client.callTool('list_work_items', {
222
- project_id: 'group:123',
223
- });
224
- assert.ok(group123Requested, 'Should request /groups/123');
225
- assert.ok(result.content, 'Should have content');
226
- const content = result.content[0];
227
- assert.ok('text' in content, 'Content should have text');
228
- const parsed = JSON.parse(content.text);
229
- assert.strictEqual(parsed.items.length, 1, 'Should return group work items');
230
- assert.strictEqual(parsed.items[0].title, 'Group work item');
231
- console.log(' ✓ Resolved group:123 via /groups/123 when no project allowlist is set');
232
- });
233
- test('should resolve labels from the group root when creating group work items', async () => {
234
- labelLookupRoots = [];
235
- const result = await client.callTool('create_work_item', {
236
- project_id: 'group:123',
237
- title: 'Created group work item',
238
- labels: ['group-label'],
239
- });
240
- assert.deepStrictEqual(labelLookupRoots, ['group']);
241
- assert.ok(result.content, 'Should have content');
242
- const content = result.content[0];
243
- assert.ok('text' in content, 'Content should have text');
244
- assert.strictEqual(JSON.parse(content.text).title, 'Created group work item');
245
- console.log(' ✓ Resolved create labels through group(fullPath:)');
246
- });
247
- test('should resolve labels from the group root when updating group work items', async () => {
248
- labelLookupRoots = [];
249
- const result = await client.callTool('update_work_item', {
250
- project_id: 'group:123',
251
- iid: 1,
252
- add_labels: ['group-label'],
253
- });
254
- assert.deepStrictEqual(labelLookupRoots, ['group']);
255
- assert.ok(result.content, 'Should have content');
256
- const content = result.content[0];
257
- assert.ok('text' in content, 'Content should have text');
258
- assert.strictEqual(JSON.parse(content.text).title, 'Updated group work item');
259
- console.log(' ✓ Resolved update labels through group(fullPath:)');
260
- });
261
- test('should not fall back from bare numeric project IDs to groups', async () => {
262
- group789Requested = false;
263
- let didThrow = false;
264
- try {
265
- await client.callTool('list_work_items', {
266
- project_id: '789',
267
- });
268
- }
269
- catch (error) {
270
- didThrow = true;
271
- assert.ok(error instanceof Error);
272
- assert.ok(error.message.includes('404'), 'Should surface the project 404');
273
- }
274
- assert.ok(didThrow, 'Should have failed on the project 404');
275
- assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
276
- console.log(' ✓ Bare numeric ID 789 did not fall back to /groups/789');
277
- });
278
- test('should not fall back from explicit numeric project IDs to groups', async () => {
279
- group789Requested = false;
280
- let didThrow = false;
281
- try {
282
- await client.callTool('list_work_items', {
283
- project_id: 'project:789',
284
- });
285
- }
286
- catch (error) {
287
- didThrow = true;
288
- assert.ok(error instanceof Error);
289
- assert.ok(error.message.includes('404'), 'Should surface the project 404');
290
- }
291
- assert.ok(didThrow, 'Should have failed on the project 404');
292
- assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
293
- console.log(' ✓ Explicit project:789 did not fall back to /groups/789');
294
- });
295
- });
296
- describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () => {
297
- let mcpUrl;
298
- let mockGitLab;
299
- let servers = [];
300
- let client;
301
- before(async () => {
302
- // Start mock GitLab server
303
- const mockPort = await findMockServerPort();
304
- mockGitLab = new MockGitLabServer({
305
- port: mockPort,
306
- validTokens: [MOCK_TOKEN]
307
- });
308
- await mockGitLab.start();
309
- const mockGitLabUrl = mockGitLab.getUrl();
310
- // Start MCP server WITH single GITLAB_ALLOWED_PROJECT_IDS
311
- const mcpPort = await findAvailablePort(3200);
312
- const server = await launchServer({
313
- mode: TransportMode.STREAMABLE_HTTP,
314
- port: mcpPort,
315
- timeout: 5000,
316
- env: {
317
- REMOTE_AUTHORIZATION: 'true',
318
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
319
- GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
320
- GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
321
- GITLAB_READ_ONLY_MODE: 'true',
322
- GITLAB_TOOLSETS: 'all',
323
- }
324
- });
325
- servers.push(server);
326
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
327
- client = new CustomHeaderClient({
328
- authorization: `Bearer ${MOCK_TOKEN}`,
329
- });
330
- await client.connect(mcpUrl);
331
- console.log(`Mock GitLab: ${mockGitLabUrl}`);
332
- console.log(`MCP Server: ${mcpUrl}`);
333
- console.log(`Allowed Project: ${DEFAULT_PROJECT_ID}`);
334
- });
335
- after(async () => {
336
- if (client) {
337
- await client.disconnect();
338
- }
339
- cleanupServers(servers);
340
- if (mockGitLab) {
341
- await mockGitLab.stop();
342
- }
343
- });
344
- test('should use single allowed project as default', async () => {
345
- const result = await client.callTool('get_project', {
346
- project_id: ''
347
- });
348
- assert.ok(result.content, 'Should have content');
349
- const content = result.content[0];
350
- assert.ok('text' in content, 'Content should have text');
351
- const project = JSON.parse(content.text);
352
- assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use allowed project as default');
353
- console.log(` ✓ Used allowed project ${DEFAULT_PROJECT_ID} as default`);
354
- });
355
- test('should reject access to non-allowed project', async () => {
356
- try {
357
- await client.callTool('get_project', {
358
- project_id: OTHER_PROJECT_ID
359
- });
360
- assert.fail('Should have rejected access to non-allowed project');
361
- }
362
- catch (error) {
363
- assert.ok(error instanceof Error);
364
- assert.ok(error.message.includes('Access denied'), 'Should indicate access denied');
365
- console.log(' ✓ Correctly rejected access to non-allowed project');
366
- }
367
- });
368
- test('should reject explicit group IDs when GITLAB_ALLOWED_PROJECT_IDS is set', async () => {
369
- let didThrow = false;
370
- try {
371
- await client.callTool('list_work_items', {
372
- project_id: 'group:123',
373
- });
374
- }
375
- catch (error) {
376
- didThrow = true;
377
- assert.ok(error instanceof Error);
378
- assert.ok(error.message.includes('GITLAB_ALLOWED_PROJECT_IDS') &&
379
- error.message.includes('project allowlist does not cover groups'), 'Should explain that project allowlists do not authorize groups');
380
- console.log(' ✓ Rejected group:123 when GITLAB_ALLOWED_PROJECT_IDS is set');
381
- }
382
- assert.ok(didThrow, 'Should have rejected explicit group access');
383
- });
384
- });
385
- describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', () => {
386
- let mcpUrl;
387
- let mockGitLab;
388
- let servers = [];
389
- let client;
390
- before(async () => {
391
- // Start mock GitLab server
392
- const mockPort = await findMockServerPort();
393
- mockGitLab = new MockGitLabServer({
394
- port: mockPort,
395
- validTokens: [MOCK_TOKEN]
396
- });
397
- await mockGitLab.start();
398
- const mockGitLabUrl = mockGitLab.getUrl();
399
- // Start MCP server WITH multiple GITLAB_ALLOWED_PROJECT_IDS
400
- const mcpPort = await findAvailablePort(3300);
401
- const server = await launchServer({
402
- mode: TransportMode.STREAMABLE_HTTP,
403
- port: mcpPort,
404
- timeout: 5000,
405
- env: {
406
- REMOTE_AUTHORIZATION: 'true',
407
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
408
- GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
409
- GITLAB_READ_ONLY_MODE: 'true',
410
- }
411
- });
412
- servers.push(server);
413
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
414
- client = new CustomHeaderClient({
415
- authorization: `Bearer ${MOCK_TOKEN}`,
416
- });
417
- await client.connect(mcpUrl);
418
- console.log(`Mock GitLab: ${mockGitLabUrl}`);
419
- console.log(`MCP Server: ${mcpUrl}`);
420
- console.log(`Allowed Projects: ${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`);
421
- });
422
- after(async () => {
423
- if (client) {
424
- await client.disconnect();
425
- }
426
- cleanupServers(servers);
427
- if (mockGitLab) {
428
- await mockGitLab.stop();
429
- }
430
- });
431
- test('should require explicit project_id when multiple projects allowed', async () => {
432
- try {
433
- await client.callTool('get_project', {
434
- project_id: ''
435
- });
436
- assert.fail('Should have required explicit project_id');
437
- }
438
- catch (error) {
439
- assert.ok(error instanceof Error);
440
- assert.ok(error.message.includes('Please specify a project ID'), 'Should require project ID');
441
- console.log(' ✓ Correctly required explicit project_id');
442
- }
443
- });
444
- test('should allow access to first allowed project', async () => {
445
- const result = await client.callTool('get_project', {
446
- project_id: DEFAULT_PROJECT_ID
447
- });
448
- assert.ok(result.content, 'Should have content');
449
- const content = result.content[0];
450
- assert.ok('text' in content, 'Content should have text');
451
- const project = JSON.parse(content.text);
452
- assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should allow first project');
453
- console.log(` ✓ Allowed access to first project ${DEFAULT_PROJECT_ID}`);
454
- });
455
- test('should allow access to second allowed project', async () => {
456
- const result = await client.callTool('get_project', {
457
- project_id: OTHER_PROJECT_ID
458
- });
459
- assert.ok(result.content, 'Should have content');
460
- const content = result.content[0];
461
- assert.ok('text' in content, 'Content should have text');
462
- const project = JSON.parse(content.text);
463
- assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should allow second project');
464
- console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
465
- });
466
- });
467
- describe('GITLAB_PROJECT_ID guards repository and group mutators', () => {
468
- let mcpUrl;
469
- let mockGitLab;
470
- let servers = [];
471
- let client;
472
- before(async () => {
473
- const mockPort = await findMockServerPort();
474
- mockGitLab = new MockGitLabServer({
475
- port: mockPort,
476
- validTokens: [MOCK_TOKEN]
477
- });
478
- await mockGitLab.start();
479
- const mockGitLabUrl = mockGitLab.getUrl();
480
- const mcpPort = await findAvailablePort(3400);
481
- const server = await launchServer({
482
- mode: TransportMode.STREAMABLE_HTTP,
483
- port: mcpPort,
484
- timeout: 5000,
485
- env: {
486
- REMOTE_AUTHORIZATION: 'true',
487
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
488
- GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
489
- GITLAB_TOOLSETS: 'variables,repositories,groups,projects',
490
- }
491
- });
492
- servers.push(server);
493
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
494
- client = new CustomHeaderClient({
495
- authorization: `Bearer ${MOCK_TOKEN}`,
496
- });
497
- await client.connect(mcpUrl);
498
- });
499
- after(async () => {
500
- if (client)
501
- await client.disconnect();
502
- cleanupServers(servers);
503
- if (mockGitLab)
504
- await mockGitLab.stop();
505
- });
506
- test('should reject create_repository when GITLAB_PROJECT_ID is set', async () => {
507
- try {
508
- await client.callTool('create_repository', { name: 'test-repo' });
509
- assert.fail('Should have rejected create_repository');
510
- }
511
- catch (error) {
512
- assert.ok(error instanceof Error);
513
- assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
514
- }
515
- });
516
- test('should reject fork_repository when GITLAB_PROJECT_ID is set', async () => {
517
- try {
518
- await client.callTool('fork_repository', { project_id: '999' });
519
- assert.fail('Should have rejected fork_repository');
520
- }
521
- catch (error) {
522
- assert.ok(error instanceof Error);
523
- assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
524
- }
525
- });
526
- test('should reject create_group when GITLAB_PROJECT_ID is set', async () => {
527
- try {
528
- await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
529
- assert.fail('Should have rejected create_group');
530
- }
531
- catch (error) {
532
- assert.ok(error instanceof Error);
533
- assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
534
- }
535
- });
536
- test('should reject list_group_variables when GITLAB_PROJECT_ID is set', async () => {
537
- try {
538
- await client.callTool('list_group_variables', { group_id: 'my-group' });
539
- assert.fail('Should have rejected list_group_variables');
540
- }
541
- catch (error) {
542
- assert.ok(error instanceof Error);
543
- assert.ok(error.message.includes('list_group_variables is not allowed'), 'Should mention list_group_variables');
544
- }
545
- });
546
- test('should reject get_group_variable when GITLAB_PROJECT_ID is set', async () => {
547
- try {
548
- await client.callTool('get_group_variable', { group_id: 'my-group', key: 'SHARED_SECRET' });
549
- assert.fail('Should have rejected get_group_variable');
550
- }
551
- catch (error) {
552
- assert.ok(error instanceof Error);
553
- assert.ok(error.message.includes('get_group_variable is not allowed'), 'Should mention get_group_variable');
554
- }
555
- });
556
- test('should allow get_project (non-mutator) when GITLAB_PROJECT_ID is set', async () => {
557
- const result = await client.callTool('get_project', { project_id: '' });
558
- assert.ok(result.content, 'Should have content');
559
- const content = result.content[0];
560
- assert.ok('text' in content, 'Content should have text');
561
- const project = JSON.parse(content.text);
562
- assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
563
- });
564
- });
565
- describe('GITLAB_ALLOWED_PROJECT_IDS guards repository and group mutators (allowlist-only, no GITLAB_PROJECT_ID)', () => {
566
- let mcpUrl;
567
- let mockGitLab;
568
- let servers = [];
569
- let client;
570
- before(async () => {
571
- const mockPort = await findMockServerPort();
572
- mockGitLab = new MockGitLabServer({
573
- port: mockPort,
574
- validTokens: [MOCK_TOKEN]
575
- });
576
- await mockGitLab.start();
577
- const mockGitLabUrl = mockGitLab.getUrl();
578
- const mcpPort = await findAvailablePort(3600);
579
- const server = await launchServer({
580
- mode: TransportMode.STREAMABLE_HTTP,
581
- port: mcpPort,
582
- timeout: 5000,
583
- env: {
584
- REMOTE_AUTHORIZATION: 'true',
585
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
586
- GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
587
- GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines,repositories,groups,projects',
588
- }
589
- });
590
- servers.push(server);
591
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
592
- client = new CustomHeaderClient({
593
- authorization: `Bearer ${MOCK_TOKEN}`,
594
- });
595
- await client.connect(mcpUrl);
596
- });
597
- after(async () => {
598
- if (client)
599
- await client.disconnect();
600
- cleanupServers(servers);
601
- if (mockGitLab)
602
- await mockGitLab.stop();
603
- });
604
- test('should reject create_repository with GITLAB_ALLOWED_PROJECT_IDS', async () => {
605
- try {
606
- await client.callTool('create_repository', { name: 'test-repo' });
607
- assert.fail('Should have rejected create_repository');
608
- }
609
- catch (error) {
610
- assert.ok(error instanceof Error);
611
- assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
612
- }
613
- });
614
- test('should reject fork_repository with GITLAB_ALLOWED_PROJECT_IDS', async () => {
615
- try {
616
- await client.callTool('fork_repository', { project_id: '999' });
617
- assert.fail('Should have rejected fork_repository');
618
- }
619
- catch (error) {
620
- assert.ok(error instanceof Error);
621
- assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
622
- }
623
- });
624
- test('should reject create_group with GITLAB_ALLOWED_PROJECT_IDS', async () => {
625
- try {
626
- await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
627
- assert.fail('Should have rejected create_group');
628
- }
629
- catch (error) {
630
- assert.ok(error instanceof Error);
631
- assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
632
- }
633
- });
634
- test('should reject list_group_variables with GITLAB_ALLOWED_PROJECT_IDS', async () => {
635
- try {
636
- await client.callTool('list_group_variables', { group_id: 'my-group' });
637
- assert.fail('Should have rejected list_group_variables');
638
- }
639
- catch (error) {
640
- assert.ok(error instanceof Error);
641
- assert.ok(error.message.includes('list_group_variables is not allowed'), 'Should mention list_group_variables');
642
- }
643
- });
644
- test('should reject get_group_variable with GITLAB_ALLOWED_PROJECT_IDS', async () => {
645
- try {
646
- await client.callTool('get_group_variable', { group_id: 'my-group', key: 'SHARED_SECRET' });
647
- assert.fail('Should have rejected get_group_variable');
648
- }
649
- catch (error) {
650
- assert.ok(error instanceof Error);
651
- assert.ok(error.message.includes('get_group_variable is not allowed'), 'Should mention get_group_variable');
652
- }
653
- });
654
- test('should allow get_project (non-mutator) with GITLAB_ALLOWED_PROJECT_IDS', async () => {
655
- const result = await client.callTool('get_project', { project_id: '' });
656
- assert.ok(result.content, 'Should have content');
657
- const content = result.content[0];
658
- assert.ok('text' in content, 'Content should have text');
659
- const project = JSON.parse(content.text);
660
- assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
661
- });
662
- test('should enforce the project allowlist for get_draft_note', async () => {
663
- await assert.rejects(client.callTool('get_draft_note', {
664
- project_id: OTHER_PROJECT_ID,
665
- merge_request_iid: '1',
666
- draft_note_id: '1',
667
- }), /Access denied/);
668
- });
669
- test('should reject dot path segments before making GitLab requests', async () => {
670
- const calls = [
671
- {
672
- name: 'get_draft_note',
673
- args: { project_id: DEFAULT_PROJECT_ID, merge_request_iid: '..', draft_note_id: '1' },
674
- },
675
- {
676
- name: 'get_job_artifact_file',
677
- args: { project_id: DEFAULT_PROJECT_ID, job_id: '1', artifact_path: '../file.txt' },
678
- },
679
- {
680
- name: 'list_issue_emoji_reactions',
681
- args: { project_id: DEFAULT_PROJECT_ID, issue_iid: '..' },
682
- },
683
- ];
684
- for (const { name, args } of calls) {
685
- await assert.rejects(client.callTool(name, args), /Cannot use value as a GitLab URL path segment/);
686
- }
687
- });
688
- });
689
- describe('GITLAB_READ_ONLY_MODE enforces read-only for all write tools', () => {
690
- let mcpUrl;
691
- let mockGitLab;
692
- let servers = [];
693
- let client;
694
- before(async () => {
695
- const mockPort = await findMockServerPort();
696
- mockGitLab = new MockGitLabServer({
697
- port: mockPort,
698
- validTokens: [MOCK_TOKEN]
699
- });
700
- await mockGitLab.start();
701
- const mockGitLabUrl = mockGitLab.getUrl();
702
- const mcpPort = await findAvailablePort(3500);
703
- const server = await launchServer({
704
- mode: TransportMode.STREAMABLE_HTTP,
705
- port: mcpPort,
706
- timeout: 5000,
707
- env: {
708
- REMOTE_AUTHORIZATION: 'true',
709
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
710
- GITLAB_READ_ONLY_MODE: 'true',
711
- }
712
- });
713
- servers.push(server);
714
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
715
- client = new CustomHeaderClient({
716
- authorization: `Bearer ${MOCK_TOKEN}`,
717
- });
718
- await client.connect(mcpUrl);
719
- });
720
- after(async () => {
721
- if (client)
722
- await client.disconnect();
723
- cleanupServers(servers);
724
- if (mockGitLab)
725
- await mockGitLab.stop();
726
- });
727
- test('should reject create_group in read-only mode (no project ID)', async () => {
728
- try {
729
- await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
730
- assert.fail('Should have rejected create_group in read-only mode');
731
- }
732
- catch (error) {
733
- assert.ok(error instanceof Error);
734
- assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
735
- }
736
- });
737
- test('should reject create_repository in read-only mode', async () => {
738
- try {
739
- await client.callTool('create_repository', { name: 'test-repo' });
740
- assert.fail('Should have rejected create_repository in read-only mode');
741
- }
742
- catch (error) {
743
- assert.ok(error instanceof Error);
744
- assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
745
- }
746
- });
747
- test('should allow get_project (read-only) in read-only mode', async () => {
748
- const result = await client.callTool('get_project', { project_id: DEFAULT_PROJECT_ID });
749
- assert.ok(result.content, 'Should have content');
750
- });
751
- });
752
- describe('GITLAB_PROJECT_ID guards dependency proxy tools', () => {
753
- let mcpUrl;
754
- let mockGitLab;
755
- let servers = [];
756
- let client;
757
- before(async () => {
758
- const mockPort = await findMockServerPort();
759
- mockGitLab = new MockGitLabServer({
760
- port: mockPort,
761
- validTokens: [MOCK_TOKEN],
762
- });
763
- await mockGitLab.start();
764
- const mockGitLabUrl = mockGitLab.getUrl();
765
- const mcpPort = await findAvailablePort(3500);
766
- const server = await launchServer({
767
- mode: TransportMode.STREAMABLE_HTTP,
768
- port: mcpPort,
769
- timeout: 5000,
770
- env: {
771
- REMOTE_AUTHORIZATION: 'true',
772
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
773
- GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
774
- GITLAB_TOOLSETS: 'dependency_proxy',
775
- },
776
- });
777
- servers.push(server);
778
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
779
- client = new CustomHeaderClient({
780
- authorization: `Bearer ${MOCK_TOKEN}`,
781
- });
782
- await client.connect(mcpUrl);
783
- });
784
- after(async () => {
785
- if (client)
786
- await client.disconnect();
787
- cleanupServers(servers);
788
- if (mockGitLab)
789
- await mockGitLab.stop();
790
- });
791
- test('should reject get_dependency_proxy_settings when GITLAB_PROJECT_ID is set', async () => {
792
- try {
793
- await client.callTool('get_dependency_proxy_settings', { group_id: 'my-group' });
794
- assert.fail('Should have rejected get_dependency_proxy_settings');
795
- }
796
- catch (error) {
797
- assert.ok(error instanceof Error);
798
- assert.ok(error.message.includes('get_dependency_proxy_settings is not allowed'), 'Should mention get_dependency_proxy_settings');
799
- }
800
- });
801
- test('should reject purge_dependency_proxy_cache when GITLAB_PROJECT_ID is set', async () => {
802
- try {
803
- await client.callTool('purge_dependency_proxy_cache', { group_id: 'my-group' });
804
- assert.fail('Should have rejected purge_dependency_proxy_cache');
805
- }
806
- catch (error) {
807
- assert.ok(error instanceof Error);
808
- assert.ok(error.message.includes('purge_dependency_proxy_cache is not allowed'), 'Should mention purge_dependency_proxy_cache');
809
- }
810
- });
811
- });
812
- }); // end wrapper describe