@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,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
- });