@zereight/mcp-gitlab 2.1.70 → 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 (157) hide show
  1. package/README.ko.md +3 -1
  2. package/README.md +6 -2
  3. package/README.pt-BR.md +1 -1
  4. package/README.zh-CN.md +3 -1
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +369 -184
  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/package.json +3 -3
  20. package/build/scripts/check-skill-sync.js +0 -143
  21. package/build/scripts/generate-server-card.js +0 -39
  22. package/build/scripts/generate-tool-coverage.js +0 -58
  23. package/build/scripts/generate-tool-docs.js +0 -430
  24. package/build/scripts/tool-coverage/coverage.js +0 -341
  25. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  26. package/build/test/callback-proxy-tests.js +0 -321
  27. package/build/test/cli/args.test.js +0 -175
  28. package/build/test/cli/boolean-flags.test.js +0 -106
  29. package/build/test/cli/compact-mcp-result.test.js +0 -93
  30. package/build/test/cli/credentials.test.js +0 -153
  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-api-tool-errors.test.js +0 -112
  50. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  51. package/build/test/gitlab-diff-schema.test.js +0 -36
  52. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  53. package/build/test/gitlab-tag-schema.test.js +0 -51
  54. package/build/test/group-milestone-schema.test.js +0 -45
  55. package/build/test/mcp-oauth-tests.js +0 -905
  56. package/build/test/mcp-server-name.test.js +0 -87
  57. package/build/test/merge-merge-request-schema.test.js +0 -75
  58. package/build/test/multi-server-test.js +0 -190
  59. package/build/test/no-proxy-integration-test.js +0 -183
  60. package/build/test/no-proxy-test.js +0 -149
  61. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  62. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  63. package/build/test/oauth-device-flow-tests.js +0 -450
  64. package/build/test/oauth-download-url.test.js +0 -157
  65. package/build/test/oauth-startup.test.js +0 -204
  66. package/build/test/oauth-tests.js +0 -428
  67. package/build/test/path-segment-encoding.test.js +0 -98
  68. package/build/test/remote-auth-simple-test.js +0 -277
  69. package/build/test/remote-auth-tests.js +0 -314
  70. package/build/test/response-masking.test.js +0 -445
  71. package/build/test/schema-tests.js +0 -1679
  72. package/build/test/server/metrics.test.js +0 -47
  73. package/build/test/server/request-helpers.test.js +0 -48
  74. package/build/test/server/version.test.js +0 -13
  75. package/build/test/sse-auth-guard.test.js +0 -96
  76. package/build/test/sse-session-limits.test.js +0 -273
  77. package/build/test/stateless/callback-proxy.test.js +0 -510
  78. package/build/test/stateless/client-id.test.js +0 -177
  79. package/build/test/stateless/codec.test.js +0 -328
  80. package/build/test/stateless/config-ttl.test.js +0 -149
  81. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  82. package/build/test/stateless/session-id-integration.test.js +0 -675
  83. package/build/test/stateless/session-id.test.js +0 -157
  84. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  85. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  86. package/build/test/streamable-http-sse-stream.test.js +0 -350
  87. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  88. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  89. package/build/test/test-all-transport-server.js +0 -255
  90. package/build/test/test-auth-retry.js +0 -195
  91. package/build/test/test-blank-filters.js +0 -156
  92. package/build/test/test-ci-catalog.js +0 -177
  93. package/build/test/test-ci-lint.js +0 -191
  94. package/build/test/test-ci-variables.js +0 -309
  95. package/build/test/test-create-repository.js +0 -120
  96. package/build/test/test-dependency-proxy.js +0 -195
  97. package/build/test/test-deployment-tools.js +0 -463
  98. package/build/test/test-download-attachment.js +0 -190
  99. package/build/test/test-dynamic-project-scope.js +0 -524
  100. package/build/test/test-empty-toolsets.js +0 -535
  101. package/build/test/test-get-file-blame.js +0 -145
  102. package/build/test/test-get-file-contents.js +0 -128
  103. package/build/test/test-geteffectiveprojectid.js +0 -804
  104. package/build/test/test-health-check.js +0 -115
  105. package/build/test/test-issue-description-patch.js +0 -256
  106. package/build/test/test-job-artifacts.js +0 -275
  107. package/build/test/test-json-schema.js +0 -166
  108. package/build/test/test-list-group-members.js +0 -150
  109. package/build/test/test-list-group-merge-requests.js +0 -165
  110. package/build/test/test-list-issues.js +0 -170
  111. package/build/test/test-list-merge-requests.js +0 -201
  112. package/build/test/test-list-milestones.js +0 -145
  113. package/build/test/test-list-project-members.js +0 -132
  114. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  115. package/build/test/test-merge-request-approvals.js +0 -187
  116. package/build/test/test-merge-request-pipelines.js +0 -106
  117. package/build/test/test-mr-diffs-filter.js +0 -132
  118. package/build/test/test-mr-file-diffs.js +0 -251
  119. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  120. package/build/test/test-orbit.js +0 -155
  121. package/build/test/test-permission-mode.js +0 -424
  122. package/build/test/test-pipeline-schedules.js +0 -484
  123. package/build/test/test-protected-branches.js +0 -174
  124. package/build/test/test-remote-downloads.js +0 -510
  125. package/build/test/test-search-code.js +0 -275
  126. package/build/test/test-snippets.js +0 -735
  127. package/build/test/test-tags.js +0 -220
  128. package/build/test/test-todos.js +0 -195
  129. package/build/test/test-token-optimizations.js +0 -691
  130. package/build/test/test-toolset-filtering.js +0 -499
  131. package/build/test/test-update-issue-slim.js +0 -141
  132. package/build/test/test-update-project.js +0 -112
  133. package/build/test/test-upload-markdown.js +0 -206
  134. package/build/test/test-vulnerabilities.js +0 -399
  135. package/build/test/test-webhooks.js +0 -218
  136. package/build/test/tool-description-quality.test.js +0 -50
  137. package/build/test/utils/bulk-publish-options.test.js +0 -32
  138. package/build/test/utils/download-token.test.js +0 -35
  139. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  140. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  141. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  142. package/build/test/utils/graphql-query.test.js +0 -168
  143. package/build/test/utils/helpers-path.test.js +0 -104
  144. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  145. package/build/test/utils/masking.test.js +0 -384
  146. package/build/test/utils/merge-request-position.test.js +0 -57
  147. package/build/test/utils/mock-gitlab-server.js +0 -614
  148. package/build/test/utils/proxy-client-ip.test.js +0 -28
  149. package/build/test/utils/redact-sensitive.test.js +0 -32
  150. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  151. package/build/test/utils/server-launcher.js +0 -231
  152. package/build/test/utils/tool-args.test.js +0 -210
  153. package/build/test/utils/url.test.js +0 -141
  154. package/build/test/utils/version-check.test.js +0 -239
  155. package/build/test/utils/wiki-title.test.js +0 -21
  156. package/build/test-note.js +0 -54
  157. package/build/test-resolve-issue-note.js +0 -127
@@ -1,495 +0,0 @@
1
- import { describe, test, after, before } from 'node:test';
2
- import assert from 'node:assert';
3
- import { launchServer, findAvailablePort, TransportMode, HOST } from './utils/server-launcher.js';
4
- import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
5
- import { CustomHeaderClient } from './clients/custom-header-client.js';
6
- const MOCK_TOKEN_DEFAULT = 'glpat-mock-token-default';
7
- const MOCK_TOKEN_HEADER = 'glpat-mock-token-header';
8
- describe('Dynamic Routing and Authentication Scenarios', () => {
9
- const originalToken = process.env.GITLAB_TOKEN_TEST;
10
- before(() => {
11
- process.env.GITLAB_TOKEN_TEST = 'mock-token-for-launcher';
12
- });
13
- after(() => {
14
- if (originalToken) {
15
- process.env.GITLAB_TOKEN_TEST = originalToken;
16
- }
17
- else {
18
- delete process.env.GITLAB_TOKEN_TEST;
19
- }
20
- });
21
- // Scenario 1: remote=off, dynamic=off
22
- describe('Scenario 1: Remote Auth OFF, Dynamic URL OFF', () => {
23
- let mcpServer;
24
- let mcpUrl;
25
- let mockServer;
26
- const originalProjectId = process.env.TEST_PROJECT_ID;
27
- before(async () => {
28
- // Ensure GITLAB_TOKEN_TEST matches what we expect for this scenario
29
- // to avoid launchServer overwriting GITLAB_PERSONAL_ACCESS_TOKEN with a different value
30
- process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN_DEFAULT;
31
- process.env.TEST_PROJECT_ID = '1';
32
- const mockPort = await findMockServerPort();
33
- mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_DEFAULT] });
34
- await mockServer.start();
35
- const mcpPort = await findAvailablePort(3021);
36
- mcpServer = await launchServer({
37
- mode: TransportMode.STREAMABLE_HTTP,
38
- port: mcpPort,
39
- env: {
40
- GITLAB_API_URL: `${mockServer.getUrl()}/api/v4`,
41
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN_DEFAULT,
42
- REMOTE_AUTHORIZATION: "false",
43
- ENABLE_DYNAMIC_API_URL: "false",
44
- GITLAB_TOKEN_TEST: MOCK_TOKEN_DEFAULT,
45
- },
46
- });
47
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
48
- });
49
- after(async () => {
50
- if (originalProjectId) {
51
- process.env.TEST_PROJECT_ID = originalProjectId;
52
- }
53
- else {
54
- delete process.env.TEST_PROJECT_ID;
55
- }
56
- if (mcpServer)
57
- mcpServer.kill();
58
- if (mockServer)
59
- await mockServer.stop();
60
- });
61
- test('should ignore headers and use startup config', async () => {
62
- mockServer.addMockHandler('get', '/projects/1', (req, res) => {
63
- // index.ts uses Authorization header by default unless GITLAB_IS_OLD is set
64
- assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_DEFAULT}`);
65
- res.json({ id: 1, default_branch: 'main' });
66
- });
67
- const client = new CustomHeaderClient({
68
- headers: {
69
- 'authorization': `Bearer ${MOCK_TOKEN_HEADER}`, // This should be ignored
70
- 'X-GitLab-API-URL': 'http://localhost:9999/api/v4', // This should be ignored
71
- }
72
- });
73
- await client.connect(mcpUrl);
74
- const result = await client.callTool('get_project', { project_id: "1" });
75
- assert.ok(result, 'Should get a result from the tool call');
76
- await client.disconnect();
77
- });
78
- });
79
- // Scenario 2: remote=on, dynamic=off
80
- describe('Scenario 2: Remote Auth ON, Dynamic URL OFF', () => {
81
- let mcpServer;
82
- let mcpUrl;
83
- let mockServer;
84
- before(async () => {
85
- const mockPort = await findMockServerPort();
86
- mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_HEADER] });
87
- await mockServer.start();
88
- const mcpPort = await findAvailablePort(3022);
89
- mcpServer = await launchServer({
90
- mode: TransportMode.STREAMABLE_HTTP,
91
- port: mcpPort,
92
- env: {
93
- GITLAB_API_URL: `${mockServer.getUrl()}/api/v4`,
94
- REMOTE_AUTHORIZATION: "true",
95
- ENABLE_DYNAMIC_API_URL: "false",
96
- },
97
- });
98
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
99
- });
100
- after(async () => {
101
- if (mcpServer)
102
- mcpServer.kill();
103
- if (mockServer)
104
- await mockServer.stop();
105
- });
106
- test('should use token from header and ignore dynamic URL', async () => {
107
- mockServer.addMockHandler('get', '/projects/1', (req, res) => {
108
- assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
109
- res.json({ id: 1, default_branch: 'main' });
110
- });
111
- const client = new CustomHeaderClient({
112
- headers: {
113
- 'authorization': `Bearer ${MOCK_TOKEN_HEADER}`,
114
- 'X-GitLab-API-URL': 'http://localhost:9999/api/v4', // This should be ignored
115
- }
116
- });
117
- await client.connect(mcpUrl);
118
- const result = await client.callTool('get_project', { project_id: "1" });
119
- assert.ok(result, 'Should get a result from the tool call');
120
- await client.disconnect();
121
- });
122
- });
123
- // Scenario 3: remote=off, dynamic=on - should be an error
124
- describe('Scenario 3: Remote Auth OFF, Dynamic URL ON (Error Case)', () => {
125
- test('should fail to start with an error', async () => {
126
- await assert.rejects(launchServer({
127
- mode: TransportMode.STREAMABLE_HTTP,
128
- port: await findAvailablePort(3023),
129
- env: {
130
- REMOTE_AUTHORIZATION: "false",
131
- ENABLE_DYNAMIC_API_URL: "true",
132
- GITLAB_TOKEN_TEST: "mock-token", // Required to bypass launcher check
133
- },
134
- }), (err) => {
135
- // The server process exits with code 1, which launchServer catches and throws as a generic error
136
- // We can't easily see the stderr output here without modifying launchServer,
137
- // so we accept the exit code 1 error as success for this negative test.
138
- return err.message.includes('Server process exited with code 1');
139
- });
140
- });
141
- });
142
- // Scenario 4: remote=on, dynamic=on
143
- describe('Scenario 4: Remote Auth ON, Dynamic URL ON', () => {
144
- let mcpServer;
145
- let mcpUrl;
146
- let defaultMockServer;
147
- let headerMockServer;
148
- before(async () => {
149
- const defaultPort = await findMockServerPort();
150
- defaultMockServer = new MockGitLabServer({ port: defaultPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
151
- await defaultMockServer.start();
152
- const headerPort = await findMockServerPort();
153
- headerMockServer = new MockGitLabServer({ port: headerPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
154
- await headerMockServer.start();
155
- const mcpPort = await findAvailablePort(3024);
156
- mcpServer = await launchServer({
157
- mode: TransportMode.STREAMABLE_HTTP,
158
- port: mcpPort,
159
- env: {
160
- GITLAB_API_URL: `${defaultMockServer.getUrl()}/api/v4`,
161
- GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN_DEFAULT,
162
- REMOTE_AUTHORIZATION: "true",
163
- ENABLE_DYNAMIC_API_URL: "true",
164
- },
165
- });
166
- mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
167
- });
168
- after(async () => {
169
- if (mcpServer)
170
- mcpServer.kill();
171
- if (defaultMockServer)
172
- await defaultMockServer.stop();
173
- if (headerMockServer)
174
- await headerMockServer.stop();
175
- });
176
- test('should use default URL and token when no headers are provided', async () => {
177
- defaultMockServer.addMockHandler('get', '/projects/1', (req, res) => {
178
- assert.strictEqual(req.headers['private-token'], MOCK_TOKEN_DEFAULT);
179
- res.json(createMockProject(1, 'default-server'));
180
- });
181
- const client = new CustomHeaderClient({ headers: { 'private-token': MOCK_TOKEN_DEFAULT } });
182
- await client.connect(mcpUrl);
183
- const result = await client.callTool('get_project', { project_id: "1" });
184
- const resultContent = JSON.parse(result.content[0].text);
185
- assert.strictEqual(resultContent.description, 'default-server');
186
- await client.disconnect();
187
- });
188
- test('should use custom URL from header and default token', async () => {
189
- headerMockServer.addMockHandler('get', '/projects/2', (req, res) => {
190
- assert.strictEqual(req.headers['private-token'], MOCK_TOKEN_DEFAULT);
191
- res.json(createMockProject(2, 'header-server'));
192
- });
193
- const client = new CustomHeaderClient({
194
- headers: {
195
- 'private-token': MOCK_TOKEN_DEFAULT,
196
- 'X-GitLab-API-URL': `${headerMockServer.getUrl()}/api/v4`,
197
- }
198
- });
199
- await client.connect(mcpUrl);
200
- const result = await client.callTool('get_project', { project_id: "2" });
201
- const resultContent = JSON.parse(result.content[0].text);
202
- assert.strictEqual(resultContent.description, 'header-server');
203
- await client.disconnect();
204
- });
205
- test('should use custom URL and token from headers', async () => {
206
- headerMockServer.addMockHandler('get', '/projects/3', (req, res) => {
207
- assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
208
- res.json(createMockProject(3, 'header-server-with-header-token'));
209
- });
210
- const client = new CustomHeaderClient({
211
- headers: {
212
- 'authorization': `Bearer ${MOCK_TOKEN_HEADER}`,
213
- 'X-GitLab-API-URL': `${headerMockServer.getUrl()}/api/v4`,
214
- }
215
- });
216
- await client.connect(mcpUrl);
217
- const result = await client.callTool('get_project', { project_id: "3" });
218
- const resultContent = JSON.parse(result.content[0].text);
219
- assert.strictEqual(resultContent.description, 'header-server-with-header-token');
220
- await client.disconnect();
221
- });
222
- test('should work with multiple tool calls', async () => {
223
- const client = new CustomHeaderClient({
224
- headers: {
225
- 'authorization': `Bearer ${MOCK_TOKEN_HEADER}`,
226
- 'X-GitLab-API-URL': `${headerMockServer.getUrl()}/api/v4`,
227
- }
228
- });
229
- await client.connect(mcpUrl);
230
- await validateToolCalls(client, headerMockServer, MOCK_TOKEN_HEADER);
231
- await client.disconnect();
232
- });
233
- test('should preserve legacy tree array response and return keyset metadata when requested', async () => {
234
- const client = new CustomHeaderClient({
235
- headers: {
236
- 'authorization': `Bearer ${MOCK_TOKEN_HEADER}`,
237
- 'X-GitLab-API-URL': `${headerMockServer.getUrl()}/api/v4`,
238
- }
239
- });
240
- await client.connect(mcpUrl);
241
- headerMockServer.clearCustomHandlers();
242
- headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
243
- assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
244
- assert.strictEqual(req.query.pagination, undefined);
245
- res.json([createMockTreeItem('legacy-blob')]);
246
- });
247
- const legacyResult = await client.callTool('get_repository_tree', { project_id: '4' });
248
- const legacyContent = JSON.parse(legacyResult.content[0].text);
249
- assert.ok(Array.isArray(legacyContent));
250
- assert.strictEqual(legacyContent[0].id, 'legacy-blob');
251
- headerMockServer.clearCustomHandlers();
252
- headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
253
- assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
254
- assert.strictEqual(req.query.pagination, 'keyset');
255
- res.set('x-next-page-token', 'token-blob');
256
- res.json([createMockTreeItem('keyset-blob')]);
257
- });
258
- const keysetResult = await client.callTool('get_repository_tree', {
259
- project_id: '4',
260
- pagination: 'keyset',
261
- });
262
- const keysetContent = JSON.parse(keysetResult.content[0].text);
263
- assert.ok(!Array.isArray(keysetContent));
264
- assert.strictEqual(keysetContent.items[0].id, 'keyset-blob');
265
- assert.strictEqual(keysetContent.next_page_token, 'token-blob');
266
- headerMockServer.clearCustomHandlers();
267
- headerMockServer.addMockHandler('get', '/projects/4/repository/tree', (req, res) => {
268
- assert.strictEqual(req.headers['authorization'], `Bearer ${MOCK_TOKEN_HEADER}`);
269
- assert.strictEqual(req.query.pagination, 'keyset');
270
- res.set('x-next-page', 'fallback-token');
271
- res.json([createMockTreeItem('fallback-blob')]);
272
- });
273
- const fallbackResult = await client.callTool('get_repository_tree', {
274
- project_id: '4',
275
- pagination: 'keyset',
276
- });
277
- const fallbackContent = JSON.parse(fallbackResult.content[0].text);
278
- assert.strictEqual(fallbackContent.items[0].id, 'fallback-blob');
279
- assert.strictEqual(fallbackContent.next_page_token, 'fallback-token');
280
- await client.disconnect();
281
- });
282
- });
283
- });
284
- // Helper functions to create schema-compliant mock objects
285
- function createMockUser() {
286
- return {
287
- id: 1,
288
- username: 'mock_user',
289
- name: 'Mock User',
290
- state: 'active',
291
- avatar_url: 'https://example.com/avatar.png',
292
- web_url: 'https://example.com/mock_user'
293
- };
294
- }
295
- function createMockProject(id, description = 'Mock Project') {
296
- return {
297
- id,
298
- name: `Project ${id}`,
299
- path_with_namespace: `group/project-${id}`,
300
- description,
301
- visibility: 'private',
302
- web_url: `https://gitlab.example.com/group/project-${id}`,
303
- created_at: '2024-01-01T00:00:00Z',
304
- last_activity_at: '2024-01-01T00:00:00Z',
305
- default_branch: 'main',
306
- namespace: {
307
- id: 1,
308
- name: 'Group',
309
- path: 'group',
310
- kind: 'group',
311
- full_path: 'group',
312
- web_url: 'https://gitlab.example.com/group'
313
- }
314
- };
315
- }
316
- function createMockIssue(id, projectId) {
317
- return {
318
- id,
319
- iid: id,
320
- project_id: projectId,
321
- title: `Issue ${id}`,
322
- description: 'Description',
323
- state: 'opened',
324
- created_at: '2024-01-01T00:00:00Z',
325
- updated_at: '2024-01-01T00:00:00Z',
326
- closed_at: null,
327
- web_url: `https://gitlab.example.com/group/project-${projectId}/issues/${id}`,
328
- author: createMockUser(),
329
- assignees: [],
330
- labels: [],
331
- milestone: null,
332
- user_notes_count: 0,
333
- upvotes: 0,
334
- downvotes: 0,
335
- confidential: false
336
- };
337
- }
338
- function createMockMergeRequest(id, projectId) {
339
- return {
340
- id,
341
- iid: id,
342
- project_id: projectId,
343
- title: `MR ${id}`,
344
- description: 'Description',
345
- state: 'opened',
346
- created_at: '2024-01-01T00:00:00Z',
347
- updated_at: '2024-01-01T00:00:00Z',
348
- merged_at: null,
349
- closed_at: null,
350
- merge_commit_sha: null,
351
- web_url: `https://gitlab.example.com/group/project-${projectId}/merge_requests/${id}`,
352
- author: createMockUser(),
353
- source_branch: 'feature',
354
- target_branch: 'main',
355
- draft: false,
356
- work_in_progress: false,
357
- merge_status: 'can_be_merged'
358
- };
359
- }
360
- function createMockPipeline(id, projectId) {
361
- return {
362
- id,
363
- project_id: projectId,
364
- sha: 'sha123',
365
- ref: 'main',
366
- status: 'success',
367
- created_at: '2024-01-01T00:00:00Z',
368
- updated_at: '2024-01-01T00:00:00Z',
369
- web_url: `https://gitlab.example.com/group/project-${projectId}/pipelines/${id}`,
370
- user: createMockUser()
371
- };
372
- }
373
- function createMockCommit(id) {
374
- return {
375
- id,
376
- short_id: id.substring(0, 8),
377
- title: 'Commit message',
378
- author_name: 'Mock User',
379
- author_email: 'mock@example.com',
380
- authored_date: '2024-01-01T00:00:00Z',
381
- committer_name: 'Mock User',
382
- committer_email: 'mock@example.com',
383
- committed_date: '2024-01-01T00:00:00Z',
384
- message: 'Commit message',
385
- parent_ids: [],
386
- web_url: `https://gitlab.example.com/commit/${id}`
387
- };
388
- }
389
- function createMockLabel(id) {
390
- return {
391
- id,
392
- name: `Label ${id}`,
393
- color: '#FF0000',
394
- text_color: '#FFFFFF',
395
- description: 'Label description',
396
- open_issues_count: 0,
397
- closed_issues_count: 0,
398
- open_merge_requests_count: 0,
399
- subscribed: false,
400
- priority: null,
401
- is_project_label: true
402
- };
403
- }
404
- function createMockTreeItem(id) {
405
- return {
406
- id,
407
- name: 'file.txt',
408
- type: 'blob',
409
- path: 'file.txt',
410
- mode: '100644'
411
- };
412
- }
413
- async function validateToolCalls(client, mockServer, expectedToken) {
414
- const toolsToTest = [
415
- { name: 'get_project', params: { project_id: '1' } },
416
- { name: 'list_issues', params: { project_id: '1' } },
417
- { name: 'get_merge_request', params: { project_id: '1', merge_request_iid: '1' } },
418
- { name: 'list_merge_requests', params: { project_id: '1' } },
419
- { name: 'get_repository_tree', params: { project_id: '1' } },
420
- { name: 'list_labels', params: { project_id: '1', page: 2, per_page: 50 } },
421
- { name: 'list_pipelines', params: { project_id: '1' } },
422
- { name: 'list_commits', params: { project_id: '1' } },
423
- ];
424
- for (const tool of toolsToTest) {
425
- mockServer.clearCustomHandlers();
426
- let mockPath = '';
427
- let mockResponse;
428
- switch (tool.name) {
429
- case 'get_project':
430
- mockPath = '/projects/1';
431
- mockResponse = createMockProject(1, 'mock-response');
432
- break;
433
- case 'list_issues':
434
- mockPath = '/projects/1/issues';
435
- mockResponse = [createMockIssue(1, 1)];
436
- break;
437
- case 'get_merge_request':
438
- mockPath = '/projects/1/merge_requests/1';
439
- mockResponse = createMockMergeRequest(1, 1);
440
- break;
441
- case 'list_merge_requests':
442
- mockPath = '/projects/1/merge_requests';
443
- mockResponse = [createMockMergeRequest(1, 1)];
444
- break;
445
- case 'get_repository_tree':
446
- mockPath = '/projects/1/repository/tree';
447
- mockResponse = [createMockTreeItem('blob1')];
448
- break;
449
- case 'list_labels':
450
- mockPath = '/projects/1/labels';
451
- mockResponse = [createMockLabel(1)];
452
- break;
453
- case 'list_pipelines':
454
- mockPath = '/projects/1/pipelines';
455
- mockResponse = [createMockPipeline(1, 1)];
456
- break;
457
- case 'list_commits':
458
- mockPath = '/projects/1/repository/commits';
459
- mockResponse = [createMockCommit('sha1')];
460
- break;
461
- default:
462
- throw new Error(`Unknown tool: ${tool.name}`);
463
- }
464
- mockServer.addMockHandler('get', mockPath, (req, res) => {
465
- if (req.headers['authorization']) {
466
- assert.strictEqual(req.headers['authorization'], `Bearer ${expectedToken}`);
467
- }
468
- else {
469
- assert.strictEqual(req.headers['private-token'], expectedToken);
470
- }
471
- if (tool.name === 'list_labels') {
472
- assert.strictEqual(req.query.page, '2');
473
- assert.strictEqual(req.query.per_page, '50');
474
- }
475
- res.json(mockResponse);
476
- });
477
- const result = await client.callTool(tool.name, tool.params);
478
- const resultContent = JSON.parse(result.content[0].text);
479
- // Basic validation that we got the expected object back
480
- if (Array.isArray(mockResponse)) {
481
- assert.ok(Array.isArray(resultContent));
482
- assert.strictEqual(resultContent.length, mockResponse.length);
483
- // Check ID of first item
484
- if (resultContent[0].id) {
485
- assert.strictEqual(String(resultContent[0].id), String(mockResponse[0].id));
486
- }
487
- }
488
- else {
489
- assert.strictEqual(String(resultContent.id), String(mockResponse.id));
490
- if (tool.name === 'get_project') {
491
- assert.strictEqual(resultContent.description, 'mock-response');
492
- }
493
- }
494
- }
495
- }
@@ -1,30 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { test } from "node:test";
3
- import { GetMergeRequestDiscussionSchema, GitLabDiscussionSchema } from "../schemas.js";
4
- test("get discussion input coerces numeric ids and keeps discussion id", () => {
5
- const parsed = GetMergeRequestDiscussionSchema.parse({
6
- project_id: 42,
7
- merge_request_iid: 7,
8
- discussion_id: "abc123",
9
- });
10
- assert.equal(parsed.project_id, "42");
11
- assert.equal(parsed.merge_request_iid, "7");
12
- assert.equal(parsed.discussion_id, "abc123");
13
- });
14
- test("get discussion input rejects a missing discussion id", () => {
15
- assert.throws(() => GetMergeRequestDiscussionSchema.parse({
16
- project_id: "42",
17
- merge_request_iid: "7",
18
- }));
19
- });
20
- test("single discussion response parses the GitLab discussion item shape", () => {
21
- const parsed = GitLabDiscussionSchema.parse({
22
- id: "abc123",
23
- individual_note: false,
24
- notes: [{ id: 1, body: "please fix", resolvable: true, resolved: false }],
25
- });
26
- assert.equal(parsed.id, "abc123");
27
- assert.equal(parsed.individual_note, false);
28
- assert.equal(parsed.notes.length, 1);
29
- assert.equal(parsed.notes[0].body, "please fix");
30
- });
@@ -1,112 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import http from "node:http";
3
- import { describe, test } from "node:test";
4
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
- import { getDefaultEnvironment, StdioClientTransport, } from "@modelcontextprotocol/sdk/client/stdio.js";
6
- const MASKED_IP = "10.20.30.40";
7
- async function startServer(t) {
8
- const api = http.createServer((req, res) => {
9
- res.setHeader("Content-Type", "application/json");
10
- if (req.url === "/api/v4/namespaces/missing") {
11
- res.statusCode = 404;
12
- res.end(JSON.stringify({ message: "Namespace not found", host: MASKED_IP }));
13
- return;
14
- }
15
- if (req.url === "/api/v4/groups/10/members") {
16
- res.statusCode = 403;
17
- res.end(JSON.stringify({ message: "Forbidden" }));
18
- return;
19
- }
20
- if (req.method === "POST" && req.url === "/api/v4/projects/123/merge_requests") {
21
- res.statusCode = 400;
22
- res.end(JSON.stringify({ message: "Invalid merge request" }));
23
- return;
24
- }
25
- if (req.url === "/api/v4/namespaces/core/exists") {
26
- res.end(JSON.stringify({ exists: true, suggests: [] }));
27
- return;
28
- }
29
- res.statusCode = 404;
30
- res.end(JSON.stringify({ message: "Not found" }));
31
- });
32
- await new Promise((resolve, reject) => {
33
- api.once("error", reject);
34
- api.listen(0, "127.0.0.1", resolve);
35
- });
36
- const apiUrl = `http://127.0.0.1:${api.address().port}/api/v4`;
37
- const client = new Client({ name: "gitlab-api-tool-error-test", version: "1.0.0" });
38
- const transport = new StdioClientTransport({
39
- command: process.execPath,
40
- args: ["build/index.js"],
41
- stderr: "pipe",
42
- env: {
43
- ...getDefaultEnvironment(),
44
- GITLAB_PERSONAL_ACCESS_TOKEN: "test-token",
45
- GITLAB_API_URL: apiUrl,
46
- GITLAB_PERMISSION_MODE: "modify",
47
- GITLAB_TOOLSETS: "projects,merge_requests",
48
- GITLAB_MASKING_ENABLED: "true",
49
- GITLAB_DISABLE_VERSION_CHECK: "true",
50
- LOG_LEVEL: "error",
51
- },
52
- });
53
- transport.stderr?.on("data", () => undefined);
54
- t.after(async () => {
55
- try {
56
- await client.close();
57
- }
58
- finally {
59
- api.closeAllConnections();
60
- await new Promise(resolve => api.close(() => resolve()));
61
- }
62
- });
63
- await client.connect(transport);
64
- return client;
65
- }
66
- function resultText(result) {
67
- assert.ok("content" in result && Array.isArray(result.content), "expected tool content");
68
- const block = result.content.find((item) => typeof item === "object" &&
69
- item !== null &&
70
- "type" in item &&
71
- item.type === "text" &&
72
- "text" in item &&
73
- typeof item.text === "string");
74
- assert.ok(block, "expected a text tool result");
75
- return block.text;
76
- }
77
- describe("GitLab API tool errors", () => {
78
- test("returns HTTP failures as tool errors and keeps the MCP connection usable", async (t) => {
79
- const client = await startServer(t);
80
- const notFound = await client.callTool({
81
- name: "get_namespace",
82
- arguments: { namespace_id: "missing" },
83
- });
84
- assert.equal(notFound.isError, true);
85
- assert.match(resultText(notFound), /GitLab API error: 404 Not Found/);
86
- assert.doesNotMatch(resultText(notFound), new RegExp(MASKED_IP.replaceAll(".", "\\.")));
87
- assert.match(resultText(notFound), /\[IP address\]/);
88
- const forbidden = await client.callTool({
89
- name: "list_group_members",
90
- arguments: { group_id: "10" },
91
- });
92
- assert.equal(forbidden.isError, true);
93
- assert.match(resultText(forbidden), /GitLab API error: 403 Forbidden/);
94
- const invalidMergeRequest = await client.callTool({
95
- name: "create_merge_request",
96
- arguments: {
97
- project_id: "123",
98
- title: "Test MR",
99
- source_branch: "feature",
100
- target_branch: "main",
101
- },
102
- });
103
- assert.equal(invalidMergeRequest.isError, true);
104
- assert.match(resultText(invalidMergeRequest), /Invalid request/);
105
- const recovery = await client.callTool({
106
- name: "verify_namespace",
107
- arguments: { path: "core" },
108
- });
109
- assert.notEqual(recovery.isError, true);
110
- assert.deepEqual(JSON.parse(resultText(recovery)), { exists: true, suggests: [] });
111
- });
112
- });
@@ -1,44 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert";
3
- import { GitLabArtifactEntrySchema } from "../schemas.js";
4
- function validEntry(overrides = {}) {
5
- return {
6
- name: "report.xml",
7
- path: "report.xml",
8
- type: "file",
9
- ...overrides,
10
- };
11
- }
12
- describe("When parsing an artifact tree entry", () => {
13
- describe("with a string mode", () => {
14
- it("should keep the mode", () => {
15
- const parsed = GitLabArtifactEntrySchema.parse(validEntry({ mode: "100644", size: 1024 }));
16
- assert.strictEqual(parsed.mode, "100644");
17
- assert.strictEqual(parsed.size, 1024);
18
- });
19
- });
20
- describe("with mode set to null", () => {
21
- it("should accept null", () => {
22
- const parsed = GitLabArtifactEntrySchema.parse(validEntry({ mode: null }));
23
- assert.strictEqual(parsed.mode, null);
24
- });
25
- });
26
- describe("with size set to null", () => {
27
- it("should accept null", () => {
28
- const parsed = GitLabArtifactEntrySchema.parse(validEntry({ size: null }));
29
- assert.strictEqual(parsed.size, null);
30
- });
31
- });
32
- describe("with mode and size omitted", () => {
33
- it("should leave them undefined", () => {
34
- const parsed = GitLabArtifactEntrySchema.parse(validEntry());
35
- assert.strictEqual(parsed.mode, undefined);
36
- assert.strictEqual(parsed.size, undefined);
37
- });
38
- });
39
- describe("with an unknown type", () => {
40
- it("should reject the entry", () => {
41
- assert.throws(() => GitLabArtifactEntrySchema.parse(validEntry({ type: "symlink" })));
42
- });
43
- });
44
- });