@zereight/mcp-gitlab 2.1.70 → 2.2.1

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