@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.
- package/README.ko.md +3 -1
- package/README.md +6 -2
- package/README.pt-BR.md +1 -1
- package/README.zh-CN.md +3 -1
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +369 -184
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +226 -57
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/credentials.test.js +0 -153
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-api-tool-errors.test.js +0 -112
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-download-url.test.js +0 -157
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -804
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -484
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -141
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- 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
|
-
});
|