@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,510 +0,0 @@
1
- /**
2
- * Remote Mode Download Proxy & Upload Tests
3
- *
4
- * Tests the /downloads/:type proxy endpoint and verifies that download/upload
5
- * tools behave correctly in remote (StreamableHTTP) mode:
6
- * - download_job_artifacts returns a download_url
7
- * - download_attachment for non-image returns a download_url
8
- * - download_attachment for image returns inline base64
9
- * - upload_markdown with content+filename works
10
- * - upload_markdown with file_path is rejected
11
- */
12
- import { describe, test, before, after } from 'node:test';
13
- import assert from 'node:assert';
14
- import { launchServer, TransportMode, HOST } from './utils/server-launcher.js';
15
- import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
16
- const MOCK_TOKEN = 'glpat-mock-token-12345';
17
- const TEST_PROJECT_ID = '123';
18
- const TEST_JOB_ID = '456';
19
- const TEST_SECRET = 'testsecret';
20
- const FORWARDED_BASE_URL = 'https://gitlab.mcp.example.test/gitlab-mcp';
21
- const FORWARDED_HEADERS = {
22
- 'X-Forwarded-Proto': 'https',
23
- 'X-Forwarded-Host': 'gitlab.mcp.example.test',
24
- 'X-Forwarded-Prefix': '/gitlab-mcp',
25
- };
26
- const RFC_FORWARDED_HEADERS = {
27
- 'Forwarded': 'for=192.0.2.43;proto=http;host=attacker.example.test, for="[2001:db8:cafe::17]";proto="https";host="gitlab.mcp.example.test"',
28
- 'X-Forwarded-Prefix': '/gitlab-mcp',
29
- };
30
- // Minimal 1x1 transparent PNG
31
- const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
32
- const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
33
- const ATTACHMENT_TOKEN = 'glpat-attachment-test-token';
34
- const FAKE_ZIP = Buffer.from('PK\x03\x04fake-zip-content-for-testing');
35
- const MOCK_UPLOAD_RESPONSE = {
36
- id: 99,
37
- alt: 'test-file.txt',
38
- url: '/uploads/abc123secret/test-file.txt',
39
- full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
40
- markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
41
- };
42
- function parseSSE(text) {
43
- const lines = text.split('\n');
44
- const dataLines = lines.filter(l => l.startsWith('data: '));
45
- return dataLines.map(l => JSON.parse(l.slice(6)));
46
- }
47
- // --- Test suites ---
48
- describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () => {
49
- let mockGitLab;
50
- let server;
51
- let serverPort;
52
- before(async () => {
53
- const mockPort = await findMockServerPort();
54
- mockGitLab = new MockGitLabServer({
55
- port: mockPort,
56
- validTokens: [MOCK_TOKEN, LARGE_FILE_TOKEN, ATTACHMENT_TOKEN],
57
- });
58
- // Mock artifact download
59
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
60
- res.set('Content-Type', 'application/zip');
61
- res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
62
- res.send(FAKE_ZIP);
63
- });
64
- // Mock image attachment
65
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => {
66
- res.set('Content-Type', 'image/png');
67
- res.send(MINIMAL_PNG);
68
- });
69
- // Mock text attachment
70
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => {
71
- res.set('Content-Type', 'text/plain');
72
- res.send('hello document content');
73
- });
74
- // Mock attachment whose name contains a slash. The uploads route takes one filename
75
- // segment, so the proxy has to request it as `nested%2Fdocument.txt`; a handler on
76
- // `nested/document.txt` would match the wrong route shape and must not be hit.
77
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/nested%2Fdocument.txt`, (_req, res) => {
78
- res.set('Content-Type', 'text/plain');
79
- res.send('nested document content');
80
- });
81
- // Mock large artifact (2MB) to verify streaming works for big files
82
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts`, (_req, res) => {
83
- res.set('Content-Type', 'application/zip');
84
- res.set('Content-Disposition', 'attachment; filename="large_artifacts.zip"');
85
- // Send 2MB of data in chunks
86
- const chunk = Buffer.alloc(64 * 1024, 0x42); // 64KB of 'B'
87
- res.writeHead(200);
88
- let sent = 0;
89
- const total = 2 * 1024 * 1024; // 2MB
90
- const sendChunk = () => {
91
- while (sent < total) {
92
- const size = Math.min(chunk.length, total - sent);
93
- const ok = res.write(chunk.subarray(0, size));
94
- sent += size;
95
- if (!ok) {
96
- res.once('drain', sendChunk);
97
- return;
98
- }
99
- }
100
- res.end();
101
- };
102
- sendChunk();
103
- });
104
- await mockGitLab.start();
105
- serverPort = 3500;
106
- server = await launchServer({
107
- mode: TransportMode.STREAMABLE_HTTP,
108
- port: serverPort,
109
- timeout: 10_000,
110
- env: {
111
- STREAMABLE_HTTP: 'true',
112
- REMOTE_AUTHORIZATION: 'true',
113
- MCP_TRUST_PROXY: 'false',
114
- MCP_SERVER_URL: '',
115
- GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
116
- USE_PIPELINE: 'true',
117
- MAX_REQUESTS_PER_MINUTE: '2',
118
- },
119
- });
120
- });
121
- after(async () => {
122
- if (server)
123
- server.kill();
124
- if (mockGitLab)
125
- await mockGitLab.stop();
126
- });
127
- test('returns 401 without auth headers', async () => {
128
- const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=${TEST_JOB_ID}`);
129
- assert.strictEqual(res.status, 401);
130
- const body = await res.json();
131
- assert.ok(body.error.toLowerCase().includes('auth'));
132
- });
133
- test('returns 400 for missing parameters', async () => {
134
- const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}`, { headers: { 'Private-Token': MOCK_TOKEN } });
135
- assert.strictEqual(res.status, 400);
136
- const body = await res.json();
137
- assert.ok(body.error.includes('required'));
138
- });
139
- test('returns 400 for unknown download types', async () => {
140
- const res = await fetch(`http://${HOST}:${serverPort}/downloads/unknown-type?project_id=${TEST_PROJECT_ID}`, { headers: { 'Private-Token': MOCK_TOKEN } });
141
- assert.strictEqual(res.status, 400);
142
- const body = await res.json();
143
- assert.ok(body.error.toLowerCase().includes('unknown'));
144
- });
145
- test('streams large file (2MB) without buffering issues', async () => {
146
- // Use a dedicated token to avoid rate limit interference from other tests
147
- const largeFileToken = 'glpat-largefile-test-token';
148
- const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=999`, { headers: { 'Private-Token': largeFileToken } });
149
- assert.strictEqual(res.status, 200, 'Should stream large file successfully');
150
- const buf = Buffer.from(await res.arrayBuffer());
151
- const expectedSize = 2 * 1024 * 1024;
152
- assert.strictEqual(buf.length, expectedSize, `Should receive full 2MB (got ${buf.length} bytes)`);
153
- });
154
- test('keeps a slash-bearing attachment filename inside one path segment', async () => {
155
- const res = await fetch(`http://${HOST}:${serverPort}/downloads/attachment?project_id=${TEST_PROJECT_ID}&secret=${TEST_SECRET}&filename=${encodeURIComponent('nested/document.txt')}`, { headers: { 'Private-Token': ATTACHMENT_TOKEN } });
156
- assert.strictEqual(res.status, 200, 'attachment proxy should route to one encoded segment');
157
- assert.strictEqual(await res.text(), 'nested document content');
158
- });
159
- test('returns 429 after exceeding rate limit', async () => {
160
- // Use a different token to get a fresh rate limit counter
161
- const rateLimitToken = 'glpat-ratelimit-test-token';
162
- let got429 = false;
163
- for (let i = 0; i < 10; i++) {
164
- const res = await fetch(`http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=${TEST_JOB_ID}`, { headers: { 'Private-Token': rateLimitToken } });
165
- if (res.status === 429) {
166
- got429 = true;
167
- const body = await res.json();
168
- assert.ok(body.error.toLowerCase().includes('rate limit'));
169
- break;
170
- }
171
- // consume body (might be 401/403 from GitLab mock, but rate limit still increments)
172
- await res.arrayBuffer();
173
- }
174
- assert.ok(got429, 'Should have received 429 within 10 requests (rate limit is 2/min)');
175
- });
176
- test('ignores forwarded headers unless MCP_TRUST_PROXY is enabled', async () => {
177
- const initRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
178
- method: 'POST',
179
- headers: {
180
- 'Content-Type': 'application/json',
181
- 'Accept': 'application/json, text/event-stream',
182
- 'Private-Token': MOCK_TOKEN,
183
- },
184
- body: JSON.stringify({
185
- jsonrpc: '2.0',
186
- id: 20,
187
- method: 'initialize',
188
- params: {
189
- protocolVersion: '2025-03-26',
190
- capabilities: {},
191
- clientInfo: { name: 'test-remote-downloads-no-trust-proxy', version: '1.0' },
192
- },
193
- }),
194
- });
195
- assert.strictEqual(initRes.status, 200, 'Initialize should succeed');
196
- const sessionId = initRes.headers.get('mcp-session-id');
197
- assert.ok(sessionId, 'Should receive a session ID');
198
- const toolRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
199
- method: 'POST',
200
- headers: {
201
- 'Content-Type': 'application/json',
202
- 'Accept': 'application/json, text/event-stream',
203
- ...FORWARDED_HEADERS,
204
- 'Private-Token': MOCK_TOKEN,
205
- 'mcp-session-id': sessionId,
206
- },
207
- body: JSON.stringify({
208
- jsonrpc: '2.0',
209
- id: 21,
210
- method: 'tools/call',
211
- params: {
212
- name: 'download_job_artifacts',
213
- arguments: {
214
- project_id: TEST_PROJECT_ID,
215
- job_id: TEST_JOB_ID,
216
- },
217
- },
218
- }),
219
- });
220
- assert.strictEqual(toolRes.status, 200, 'Tool call should return 200');
221
- const responses = parseSSE(await toolRes.text());
222
- const result = responses.find(r => r.id === 21);
223
- assert.ok(result?.result, 'Should have a result');
224
- const textBlock = result.result.content.find(c => c.type === 'text');
225
- assert.ok(textBlock?.text, 'Should have text content');
226
- const parsed = JSON.parse(textBlock.text);
227
- assert.ok(parsed.download_url, 'Should contain download_url');
228
- assert.ok(parsed.download_url.startsWith(`http://${HOST}:${serverPort}/downloads/job-artifacts?`), `URL should fall back to local server address when MCP_TRUST_PROXY is disabled, got ${parsed.download_url}`);
229
- assert.ok(!parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), 'URL should not use forwarded public base URL when MCP_TRUST_PROXY is disabled');
230
- });
231
- });
232
- describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000 }, () => {
233
- let mockGitLab;
234
- let server;
235
- let serverPort;
236
- let sessionId;
237
- before(async () => {
238
- const mockPort = await findMockServerPort();
239
- mockGitLab = new MockGitLabServer({
240
- port: mockPort,
241
- validTokens: [MOCK_TOKEN],
242
- });
243
- // Mock artifact download
244
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts`, (_req, res) => {
245
- res.set('Content-Type', 'application/zip');
246
- res.set('Content-Disposition', `attachment; filename="artifacts_job_${TEST_JOB_ID}.zip"`);
247
- res.send(FAKE_ZIP);
248
- });
249
- // Mock image attachment
250
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => {
251
- res.set('Content-Type', 'image/png');
252
- res.send(MINIMAL_PNG);
253
- });
254
- // Mock text attachment
255
- mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => {
256
- res.set('Content-Type', 'text/plain');
257
- res.send('hello document content');
258
- });
259
- // Mock upload endpoint
260
- mockGitLab.addMockHandler('post', `/projects/${TEST_PROJECT_ID}/uploads`, (req, res) => {
261
- res.status(201).json(MOCK_UPLOAD_RESPONSE);
262
- });
263
- await mockGitLab.start();
264
- serverPort = 3510;
265
- server = await launchServer({
266
- mode: TransportMode.STREAMABLE_HTTP,
267
- port: serverPort,
268
- timeout: 10_000,
269
- env: {
270
- STREAMABLE_HTTP: 'true',
271
- REMOTE_AUTHORIZATION: 'true',
272
- MCP_TRUST_PROXY: 'true',
273
- MCP_SERVER_URL: '',
274
- GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
275
- USE_PIPELINE: 'true',
276
- },
277
- });
278
- // Initialize MCP session
279
- const initRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
280
- method: 'POST',
281
- headers: {
282
- 'Content-Type': 'application/json',
283
- 'Accept': 'application/json, text/event-stream',
284
- 'Private-Token': MOCK_TOKEN,
285
- },
286
- body: JSON.stringify({
287
- jsonrpc: '2.0',
288
- id: 1,
289
- method: 'initialize',
290
- params: {
291
- protocolVersion: '2025-03-26',
292
- capabilities: {},
293
- clientInfo: { name: 'test-remote-downloads', version: '1.0' },
294
- },
295
- }),
296
- });
297
- assert.strictEqual(initRes.status, 200, 'Initialize should succeed');
298
- sessionId = initRes.headers.get('mcp-session-id');
299
- assert.ok(sessionId, 'Should receive a session ID');
300
- });
301
- after(async () => {
302
- if (server)
303
- server.kill();
304
- if (mockGitLab)
305
- await mockGitLab.stop();
306
- });
307
- async function callTool(id, name, args, extraHeaders = {}) {
308
- const res = await fetch(`http://${HOST}:${serverPort}/mcp`, {
309
- method: 'POST',
310
- headers: {
311
- 'Content-Type': 'application/json',
312
- 'Accept': 'application/json, text/event-stream',
313
- ...extraHeaders,
314
- 'Private-Token': MOCK_TOKEN,
315
- 'mcp-session-id': sessionId,
316
- },
317
- body: JSON.stringify({
318
- jsonrpc: '2.0',
319
- id,
320
- method: 'tools/call',
321
- params: { name, arguments: args },
322
- }),
323
- });
324
- assert.strictEqual(res.status, 200, `Tool call ${name} should return 200`);
325
- const text = await res.text();
326
- const responses = parseSSE(text);
327
- const result = responses.find(r => r.id === id);
328
- assert.ok(result, `Should find response with id=${id} in SSE stream`);
329
- return result;
330
- }
331
- test('download_job_artifacts returns download_url with embedded auth token', async () => {
332
- const result = await callTool(10, 'download_job_artifacts', {
333
- project_id: TEST_PROJECT_ID,
334
- job_id: TEST_JOB_ID,
335
- });
336
- assert.ok(result.result, 'Should have a result');
337
- const content = result.result.content;
338
- assert.ok(content && content.length > 0, 'Should have content');
339
- const textBlock = content.find(c => c.type === 'text');
340
- assert.ok(textBlock?.text, 'Should have text content');
341
- const parsed = JSON.parse(textBlock.text);
342
- assert.ok(parsed.download_url, 'Should contain download_url');
343
- assert.ok(parsed.download_url.includes('/downloads/job-artifacts'), 'URL should point to proxy endpoint');
344
- assert.ok(parsed.download_url.includes(`project_id=${TEST_PROJECT_ID}`), 'URL should include project_id');
345
- assert.ok(parsed.download_url.includes(`job_id=${TEST_JOB_ID}`), 'URL should include job_id');
346
- assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
347
- assert.ok(parsed.filename.includes('.zip'), 'Should have zip filename');
348
- // The URL should work WITHOUT auth headers (token is embedded)
349
- const downloadRes = await fetch(parsed.download_url);
350
- assert.strictEqual(downloadRes.status, 200, 'Download URL should work without auth headers');
351
- const buf = Buffer.from(await downloadRes.arrayBuffer());
352
- assert.ok(buf.length > 0, 'Downloaded content should not be empty');
353
- assert.ok(buf.includes(Buffer.from('PK')), 'Should contain zip magic bytes');
354
- });
355
- test('download_job_artifacts ignores forwarded hosts containing URL components', async () => {
356
- const result = await callTool(14, 'download_job_artifacts', {
357
- project_id: TEST_PROJECT_ID,
358
- job_id: TEST_JOB_ID,
359
- }, {
360
- ...FORWARDED_HEADERS,
361
- 'X-Forwarded-Host': 'gitlab.mcp.example.test@attacker.example.test',
362
- });
363
- assert.ok(result.result, 'Should have a result');
364
- const content = result.result.content;
365
- assert.ok(content && content.length > 0, 'Should have content');
366
- const textBlock = content.find(c => c.type === 'text');
367
- assert.ok(textBlock?.text, 'Should have text content');
368
- const parsed = JSON.parse(textBlock.text);
369
- assert.ok(parsed.download_url, 'Should contain download_url');
370
- assert.ok(parsed.download_url.startsWith(`http://${HOST}:${serverPort}/downloads/job-artifacts?`), `URL should fall back to local server address for unsafe forwarded host, got ${parsed.download_url}`);
371
- assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not contain unsafe forwarded host, got ${parsed.download_url}`);
372
- assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
373
- });
374
- test('download_job_artifacts ignores authority-style forwarded prefixes', async () => {
375
- const result = await callTool(15, 'download_job_artifacts', {
376
- project_id: TEST_PROJECT_ID,
377
- job_id: TEST_JOB_ID,
378
- }, {
379
- ...FORWARDED_HEADERS,
380
- 'X-Forwarded-Prefix': '//attacker.example.test',
381
- });
382
- assert.ok(result.result, 'Should have a result');
383
- const content = result.result.content;
384
- assert.ok(content && content.length > 0, 'Should have content');
385
- const textBlock = content.find(c => c.type === 'text');
386
- assert.ok(textBlock?.text, 'Should have text content');
387
- const parsed = JSON.parse(textBlock.text);
388
- assert.ok(parsed.download_url, 'Should contain download_url');
389
- assert.ok(parsed.download_url.startsWith('https://gitlab.mcp.example.test/downloads/job-artifacts?'), `URL should keep forwarded proto/host while ignoring unsafe prefix, got ${parsed.download_url}`);
390
- assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not contain authority-style prefix, got ${parsed.download_url}`);
391
- assert.ok(!parsed.download_url.startsWith(`http://${HOST}:${serverPort}`), 'URL should not fall back to local server address');
392
- assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
393
- });
394
- test('download_job_artifacts uses rightmost forwarded header values', async () => {
395
- const result = await callTool(16, 'download_job_artifacts', {
396
- project_id: TEST_PROJECT_ID,
397
- job_id: TEST_JOB_ID,
398
- }, {
399
- 'X-Forwarded-Proto': 'http, https',
400
- 'X-Forwarded-Host': 'attacker.example.test, gitlab.mcp.example.test',
401
- 'X-Forwarded-Prefix': '/attacker, /gitlab-mcp',
402
- });
403
- assert.ok(result.result, 'Should have a result');
404
- const content = result.result.content;
405
- assert.ok(content && content.length > 0, 'Should have content');
406
- const textBlock = content.find(c => c.type === 'text');
407
- assert.ok(textBlock?.text, 'Should have text content');
408
- const parsed = JSON.parse(textBlock.text);
409
- assert.ok(parsed.download_url, 'Should contain download_url');
410
- assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use the rightmost forwarded header values, got ${parsed.download_url}`);
411
- assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not use the leftmost untrusted forwarded header value, got ${parsed.download_url}`);
412
- assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
413
- });
414
- test('download_job_artifacts derives download_url from RFC 7239 Forwarded header', async () => {
415
- const result = await callTool(18, 'download_job_artifacts', {
416
- project_id: TEST_PROJECT_ID,
417
- job_id: TEST_JOB_ID,
418
- }, RFC_FORWARDED_HEADERS);
419
- assert.ok(result.result, 'Should have a result');
420
- const content = result.result.content;
421
- assert.ok(content && content.length > 0, 'Should have content');
422
- const textBlock = content.find(c => c.type === 'text');
423
- assert.ok(textBlock?.text, 'Should have text content');
424
- const parsed = JSON.parse(textBlock.text);
425
- assert.ok(parsed.download_url, 'Should contain download_url');
426
- assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use RFC 7239 Forwarded header values, got ${parsed.download_url}`);
427
- assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not use the leftmost untrusted Forwarded value, got ${parsed.download_url}`);
428
- assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
429
- });
430
- test('download_job_artifacts derives download_url from forwarded headers when MCP_SERVER_URL is unset', async () => {
431
- const result = await callTool(17, 'download_job_artifacts', {
432
- project_id: TEST_PROJECT_ID,
433
- job_id: TEST_JOB_ID,
434
- }, FORWARDED_HEADERS);
435
- assert.ok(result.result, 'Should have a result');
436
- const content = result.result.content;
437
- assert.ok(content && content.length > 0, 'Should have content');
438
- const textBlock = content.find(c => c.type === 'text');
439
- assert.ok(textBlock?.text, 'Should have text content');
440
- const parsed = JSON.parse(textBlock.text);
441
- assert.ok(parsed.download_url, 'Should contain download_url');
442
- assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use forwarded public base URL, got ${parsed.download_url}`);
443
- assert.ok(!parsed.download_url.startsWith(`http://${HOST}:${serverPort}`), 'URL should not fall back to local server address');
444
- assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
445
- });
446
- test('download_attachment for non-image returns download_url', async () => {
447
- const result = await callTool(11, 'download_attachment', {
448
- project_id: TEST_PROJECT_ID,
449
- secret: TEST_SECRET,
450
- filename: 'document.txt',
451
- });
452
- assert.ok(result.result, 'Should have a result');
453
- const content = result.result.content;
454
- assert.ok(content && content.length > 0, 'Should have content');
455
- const textBlock = content.find(c => c.type === 'text');
456
- assert.ok(textBlock?.text, 'Should have text content');
457
- const parsed = JSON.parse(textBlock.text);
458
- assert.ok(parsed.download_url, 'Should contain download_url');
459
- assert.ok(parsed.download_url.includes('/downloads/attachment'), 'URL should point to attachment proxy');
460
- assert.ok(parsed.download_url.includes(`project_id=${TEST_PROJECT_ID}`), 'URL should include project_id');
461
- assert.ok(parsed.download_url.includes(`secret=${TEST_SECRET}`), 'URL should include secret');
462
- assert.ok(parsed.download_url.includes('filename=document.txt'), 'URL should include filename');
463
- assert.strictEqual(parsed.filename, 'document.txt', 'Should echo the filename');
464
- });
465
- test('download_attachment for image returns base64 inline', async () => {
466
- const result = await callTool(12, 'download_attachment', {
467
- project_id: TEST_PROJECT_ID,
468
- secret: TEST_SECRET,
469
- filename: 'image.png',
470
- });
471
- assert.ok(result.result, 'Should have a result');
472
- const content = result.result.content;
473
- assert.ok(content && content.length > 0, 'Should have content');
474
- const imageBlock = content.find(c => c.type === 'image');
475
- assert.ok(imageBlock, 'Should contain an image content block');
476
- assert.strictEqual(imageBlock.mimeType, 'image/png', 'Should have image/png mime type');
477
- assert.ok(imageBlock.data && imageBlock.data.length > 0, 'Should have non-empty base64 data');
478
- });
479
- test('upload_markdown with content+filename works', async () => {
480
- const fileContent = Buffer.from('hello upload test').toString('base64');
481
- const result = await callTool(13, 'upload_markdown', {
482
- project_id: TEST_PROJECT_ID,
483
- content: fileContent,
484
- filename: 'test-file.txt',
485
- });
486
- assert.ok(result.result, 'Should have a result');
487
- assert.ok(!result.error, `Should not have error: ${result.error?.message}`);
488
- const content = result.result.content;
489
- assert.ok(content && content.length > 0, 'Should have content');
490
- const textBlock = content.find(c => c.type === 'text');
491
- assert.ok(textBlock?.text, 'Should have text content');
492
- const parsed = JSON.parse(textBlock.text);
493
- assert.ok(parsed.markdown, 'Should have markdown field');
494
- assert.ok(parsed.url, 'Should have url field');
495
- });
496
- test('upload_markdown with file_path is rejected in remote mode', async () => {
497
- const result = await callTool(14, 'upload_markdown', {
498
- project_id: TEST_PROJECT_ID,
499
- file_path: '/tmp/some-file.txt',
500
- });
501
- // In remote mode the server uses MarkdownUploadRemoteSchema which
502
- // requires content+filename and does not accept file_path. This should
503
- // result in a validation error.
504
- const hasError = !!result.error ||
505
- (result.result?.content?.some(c => c.type === 'text' && c.text && (c.text.toLowerCase().includes('error') ||
506
- c.text.toLowerCase().includes('required') ||
507
- c.text.toLowerCase().includes('invalid'))));
508
- assert.ok(hasError, 'Should reject file_path in remote mode (needs content+filename)');
509
- });
510
- });