@zereight/mcp-gitlab 2.1.69 → 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 +10 -4
- package/README.md +238 -233
- package/README.pt-BR.md +4 -4
- package/README.zh-CN.md +6 -4
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +456 -237
- 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/build/utils/url.js +15 -0
- 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/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-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-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 -812
- 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 -482
- 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 -128
- 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,251 +0,0 @@
|
|
|
1
|
-
import { describe, test, before, after } from 'node:test';
|
|
2
|
-
import assert from 'node:assert';
|
|
3
|
-
import { spawn } from 'child_process';
|
|
4
|
-
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
|
|
5
|
-
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
6
|
-
const TEST_PROJECT_ID = '123';
|
|
7
|
-
const TEST_MR_IID = '1';
|
|
8
|
-
// Helper to call list_merge_request_changed_files
|
|
9
|
-
async function callListMergeRequestChangedFiles(args = {}, env) {
|
|
10
|
-
return new Promise((resolve, reject) => {
|
|
11
|
-
const proc = spawn('node', ['build/index.js'], {
|
|
12
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
13
|
-
env: {
|
|
14
|
-
...process.env,
|
|
15
|
-
...env,
|
|
16
|
-
GITLAB_READ_ONLY_MODE: 'true'
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
let output = '';
|
|
20
|
-
let errorOutput = '';
|
|
21
|
-
proc.stdout?.on('data', d => output += d);
|
|
22
|
-
proc.stderr?.on('data', d => errorOutput += d);
|
|
23
|
-
proc.on('close', (code) => {
|
|
24
|
-
if (code !== 0)
|
|
25
|
-
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
26
|
-
// Find the JSON line in stdout
|
|
27
|
-
const line = output.split('\n').find(l => l.startsWith('{'));
|
|
28
|
-
if (!line)
|
|
29
|
-
return reject(new Error('No JSON output found'));
|
|
30
|
-
try {
|
|
31
|
-
const response = JSON.parse(line);
|
|
32
|
-
if (response.error) {
|
|
33
|
-
reject(response.error);
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
// Parse the tool result content
|
|
37
|
-
const content = response.result?.content?.[0]?.text;
|
|
38
|
-
if (content) {
|
|
39
|
-
try {
|
|
40
|
-
resolve(JSON.parse(content));
|
|
41
|
-
}
|
|
42
|
-
catch (e) {
|
|
43
|
-
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
resolve(response.result);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
catch (e) {
|
|
52
|
-
reject(e);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
proc.stdin?.end(JSON.stringify({
|
|
56
|
-
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
57
|
-
params: { name: "list_merge_request_changed_files", arguments: args }
|
|
58
|
-
}) + '\n');
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
// Helper to call get_merge_request_file_diff
|
|
62
|
-
async function callGetMergeRequestFileDiff(args = {}, env) {
|
|
63
|
-
return new Promise((resolve, reject) => {
|
|
64
|
-
const proc = spawn('node', ['build/index.js'], {
|
|
65
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
66
|
-
env: {
|
|
67
|
-
...process.env,
|
|
68
|
-
...env,
|
|
69
|
-
GITLAB_READ_ONLY_MODE: 'true'
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
let output = '';
|
|
73
|
-
let errorOutput = '';
|
|
74
|
-
proc.stdout?.on('data', d => output += d);
|
|
75
|
-
proc.stderr?.on('data', d => errorOutput += d);
|
|
76
|
-
proc.on('close', (code) => {
|
|
77
|
-
if (code !== 0)
|
|
78
|
-
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
79
|
-
// Find the JSON line in stdout
|
|
80
|
-
const line = output.split('\n').find(l => l.startsWith('{'));
|
|
81
|
-
if (!line)
|
|
82
|
-
return reject(new Error('No JSON output found'));
|
|
83
|
-
try {
|
|
84
|
-
const response = JSON.parse(line);
|
|
85
|
-
if (response.error) {
|
|
86
|
-
reject(response.error);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
// Parse the tool result content
|
|
90
|
-
const content = response.result?.content?.[0]?.text;
|
|
91
|
-
if (content) {
|
|
92
|
-
try {
|
|
93
|
-
resolve(JSON.parse(content));
|
|
94
|
-
}
|
|
95
|
-
catch (e) {
|
|
96
|
-
reject(new Error(`Failed to parse tool output JSON: ${content}`));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
resolve(response.result);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
catch (e) {
|
|
105
|
-
reject(e);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
proc.stdin?.end(JSON.stringify({
|
|
109
|
-
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
110
|
-
params: { name: "get_merge_request_file_diff", arguments: args }
|
|
111
|
-
}) + '\n');
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
describe('list_merge_request_changed_files', () => {
|
|
115
|
-
let mockGitLab;
|
|
116
|
-
let mockGitLabUrl;
|
|
117
|
-
before(async () => {
|
|
118
|
-
const mockPort = await findMockServerPort();
|
|
119
|
-
mockGitLab = new MockGitLabServer({
|
|
120
|
-
port: mockPort,
|
|
121
|
-
validTokens: [MOCK_TOKEN]
|
|
122
|
-
});
|
|
123
|
-
await mockGitLab.start();
|
|
124
|
-
mockGitLabUrl = mockGitLab.getUrl();
|
|
125
|
-
});
|
|
126
|
-
after(async () => {
|
|
127
|
-
await mockGitLab.stop();
|
|
128
|
-
});
|
|
129
|
-
test('returns all changed files without filtering', async () => {
|
|
130
|
-
const files = await callListMergeRequestChangedFiles({ project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MR_IID }, {
|
|
131
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
132
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
133
|
-
});
|
|
134
|
-
assert.ok(Array.isArray(files), 'Response should be an array');
|
|
135
|
-
assert.strictEqual(files.length, 4, 'Should return 4 files');
|
|
136
|
-
// Check structure of returned files
|
|
137
|
-
for (const file of files) {
|
|
138
|
-
assert.ok(file.new_path !== undefined, 'Each file should have new_path');
|
|
139
|
-
assert.ok(file.old_path !== undefined, 'Each file should have old_path');
|
|
140
|
-
}
|
|
141
|
-
assert.strictEqual(files[0].new_path, 'src/index.ts');
|
|
142
|
-
assert.strictEqual(files[1].new_path, 'vendor/package/file.js');
|
|
143
|
-
assert.strictEqual(files[2].new_path, 'README.md');
|
|
144
|
-
assert.strictEqual(files[3].new_path, 'package-lock.json');
|
|
145
|
-
});
|
|
146
|
-
test('filters out vendor folder with ^vendor/ pattern', async () => {
|
|
147
|
-
const files = await callListMergeRequestChangedFiles({
|
|
148
|
-
project_id: TEST_PROJECT_ID,
|
|
149
|
-
merge_request_iid: TEST_MR_IID,
|
|
150
|
-
excluded_file_patterns: ['^vendor/']
|
|
151
|
-
}, {
|
|
152
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
153
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
154
|
-
});
|
|
155
|
-
assert.ok(Array.isArray(files), 'Response should be an array');
|
|
156
|
-
assert.strictEqual(files.length, 3, 'Should return 3 files (vendor filtered out)');
|
|
157
|
-
assert.strictEqual(files[0].new_path, 'src/index.ts');
|
|
158
|
-
assert.strictEqual(files[1].new_path, 'README.md');
|
|
159
|
-
assert.strictEqual(files[2].new_path, 'package-lock.json');
|
|
160
|
-
});
|
|
161
|
-
test('filters multiple patterns at once', async () => {
|
|
162
|
-
const files = await callListMergeRequestChangedFiles({
|
|
163
|
-
project_id: TEST_PROJECT_ID,
|
|
164
|
-
merge_request_iid: TEST_MR_IID,
|
|
165
|
-
excluded_file_patterns: ['^vendor/', 'package-lock\\.json']
|
|
166
|
-
}, {
|
|
167
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
168
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
169
|
-
});
|
|
170
|
-
assert.ok(Array.isArray(files), 'Response should be an array');
|
|
171
|
-
assert.strictEqual(files.length, 2, 'Should return 2 files (vendor and package-lock filtered out)');
|
|
172
|
-
assert.strictEqual(files[0].new_path, 'src/index.ts');
|
|
173
|
-
assert.strictEqual(files[1].new_path, 'README.md');
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
describe('get_merge_request_file_diff', () => {
|
|
177
|
-
let mockGitLab;
|
|
178
|
-
let mockGitLabUrl;
|
|
179
|
-
before(async () => {
|
|
180
|
-
const mockPort = await findMockServerPort();
|
|
181
|
-
mockGitLab = new MockGitLabServer({
|
|
182
|
-
port: mockPort,
|
|
183
|
-
validTokens: [MOCK_TOKEN]
|
|
184
|
-
});
|
|
185
|
-
await mockGitLab.start();
|
|
186
|
-
mockGitLabUrl = mockGitLab.getUrl();
|
|
187
|
-
});
|
|
188
|
-
after(async () => {
|
|
189
|
-
await mockGitLab.stop();
|
|
190
|
-
});
|
|
191
|
-
test('returns diffs for existing files in single page', async () => {
|
|
192
|
-
// Request only first few files that fit in one page (per_page=20)
|
|
193
|
-
const fileDiff = await callGetMergeRequestFileDiff({
|
|
194
|
-
project_id: TEST_PROJECT_ID,
|
|
195
|
-
merge_request_iid: TEST_MR_IID,
|
|
196
|
-
file_paths: ['src/index.ts', 'README.md']
|
|
197
|
-
}, {
|
|
198
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
199
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
200
|
-
});
|
|
201
|
-
assert.ok(Array.isArray(fileDiff), 'Response should be an array');
|
|
202
|
-
assert.strictEqual(fileDiff.length, 2, 'Should return 2 diff results');
|
|
203
|
-
// Check that we got the correct files
|
|
204
|
-
const paths = fileDiff.map((f) => f.new_path || f.old_path).sort();
|
|
205
|
-
assert.deepStrictEqual(paths, ['README.md', 'src/index.ts'].sort());
|
|
206
|
-
});
|
|
207
|
-
test('handles pagination when result spans multiple pages', async () => {
|
|
208
|
-
// Request more files than fit in one page (we have 15 total, per_page defaults to 20)
|
|
209
|
-
// but let's use a smaller per_page by testing with unidiff param
|
|
210
|
-
const fileDiff = await callGetMergeRequestFileDiff({
|
|
211
|
-
project_id: TEST_PROJECT_ID,
|
|
212
|
-
merge_request_iid: TEST_MR_IID,
|
|
213
|
-
file_paths: [
|
|
214
|
-
'src/index.ts',
|
|
215
|
-
'config/settings.yml',
|
|
216
|
-
'models/user.go'
|
|
217
|
-
]
|
|
218
|
-
}, {
|
|
219
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
220
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
221
|
-
});
|
|
222
|
-
assert.ok(Array.isArray(fileDiff), 'Response should be an array');
|
|
223
|
-
assert.strictEqual(fileDiff.length, 3, 'Should return 3 diff results');
|
|
224
|
-
});
|
|
225
|
-
test('returns error objects for not-found files', async () => {
|
|
226
|
-
// Request some existing + non-existing files
|
|
227
|
-
const fileDiff = await callGetMergeRequestFileDiff({
|
|
228
|
-
project_id: TEST_PROJECT_ID,
|
|
229
|
-
merge_request_iid: TEST_MR_IID,
|
|
230
|
-
file_paths: ['src/index.ts', 'nonexistent/file.txt', 'also_missing.py']
|
|
231
|
-
}, {
|
|
232
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
233
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
|
|
234
|
-
});
|
|
235
|
-
assert.ok(Array.isArray(fileDiff), 'Response should be an array');
|
|
236
|
-
// Should return 3 results: 1 success + 2 errors
|
|
237
|
-
assert.strictEqual(fileDiff.length, 3);
|
|
238
|
-
// Find the error entries
|
|
239
|
-
const errorEntries = fileDiff.filter((f) => f.error !== undefined);
|
|
240
|
-
const successEntries = fileDiff.filter((f) => f.error === undefined);
|
|
241
|
-
assert.strictEqual(errorEntries.length, 2, 'Should have 2 error entries');
|
|
242
|
-
assert.strictEqual(successEntries.length, 1, 'Should have 1 success entry');
|
|
243
|
-
// Verify error messages are helpful
|
|
244
|
-
const errorMsgs = errorEntries.map((e) => e.error);
|
|
245
|
-
assert.ok(errorMsgs.some(msg => msg.includes('nonexistent/file.txt')), 'Error message should mention nonexistent file');
|
|
246
|
-
assert.ok(errorMsgs.some(msg => msg.includes('also_missing.py')), 'Error message should mention other missing file');
|
|
247
|
-
// Check hint is present in at least one error
|
|
248
|
-
const hints = errorEntries.map((e) => e.hint).filter(Boolean);
|
|
249
|
-
assert.ok(hints.length > 0, 'Errors should include hints to check list_merge_request_changed_files');
|
|
250
|
-
});
|
|
251
|
-
});
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression tests for OAuth endpoint rate limiting behind trusted proxies.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, test, after, before } from "node:test";
|
|
5
|
-
import assert from "node:assert";
|
|
6
|
-
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
7
|
-
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
8
|
-
const MOCK_GITLAB_PORT_BASE = 9210;
|
|
9
|
-
const MCP_SERVER_PORT_BASE = 3210;
|
|
10
|
-
const MOCK_CLIENT_ID = "mock-app-uid-from-dcr";
|
|
11
|
-
function addOAuthEndpoints(mockGitLab, baseUrl) {
|
|
12
|
-
mockGitLab.addRootHandler("post", "/oauth/register", (req, res) => {
|
|
13
|
-
res.status(201).json({
|
|
14
|
-
client_id: MOCK_CLIENT_ID,
|
|
15
|
-
client_name: req.body?.client_name ?? "test",
|
|
16
|
-
redirect_uris: req.body?.redirect_uris ?? [],
|
|
17
|
-
token_endpoint_auth_method: "none",
|
|
18
|
-
require_pkce: true,
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
mockGitLab.addRootHandler("get", "/.well-known/oauth-authorization-server", (_req, res) => {
|
|
22
|
-
res.json({
|
|
23
|
-
issuer: baseUrl,
|
|
24
|
-
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
25
|
-
token_endpoint: `${baseUrl}/oauth/token`,
|
|
26
|
-
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
27
|
-
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
|
28
|
-
scopes_supported: ["api", "read_api", "read_user"],
|
|
29
|
-
response_types_supported: ["code"],
|
|
30
|
-
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
31
|
-
code_challenge_methods_supported: ["S256"],
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
describe("When MCP OAuth runs behind a trusted proxy", () => {
|
|
36
|
-
describe("with X-Forwarded-For containing client ports", () => {
|
|
37
|
-
let mcpBaseUrl;
|
|
38
|
-
let mockGitLab;
|
|
39
|
-
let servers = [];
|
|
40
|
-
before(async () => {
|
|
41
|
-
const mockPort = await findMockServerPort();
|
|
42
|
-
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [] });
|
|
43
|
-
await mockGitLab.start();
|
|
44
|
-
const mockGitLabUrl = mockGitLab.getUrl();
|
|
45
|
-
addOAuthEndpoints(mockGitLab, mockGitLabUrl);
|
|
46
|
-
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
47
|
-
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
48
|
-
const server = await launchServer({
|
|
49
|
-
mode: TransportMode.STREAMABLE_HTTP,
|
|
50
|
-
port: mcpPort,
|
|
51
|
-
timeout: 5000,
|
|
52
|
-
env: {
|
|
53
|
-
STREAMABLE_HTTP: "true",
|
|
54
|
-
GITLAB_MCP_OAUTH: "true",
|
|
55
|
-
MCP_TRUST_PROXY: "true",
|
|
56
|
-
GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
|
|
57
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
58
|
-
MCP_SERVER_URL: mcpBaseUrl,
|
|
59
|
-
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
servers.push(server);
|
|
63
|
-
});
|
|
64
|
-
after(async () => {
|
|
65
|
-
cleanupServers(servers);
|
|
66
|
-
if (mockGitLab) {
|
|
67
|
-
await mockGitLab.stop();
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
test("should not return 500 from /register", async () => {
|
|
71
|
-
const res = await fetch(`${mcpBaseUrl}/register`, {
|
|
72
|
-
method: "POST",
|
|
73
|
-
headers: {
|
|
74
|
-
"Content-Type": "application/json",
|
|
75
|
-
"X-Forwarded-For": "160.79.106.36:38914",
|
|
76
|
-
},
|
|
77
|
-
body: JSON.stringify({
|
|
78
|
-
redirect_uris: ["https://client.example/callback"],
|
|
79
|
-
client_name: "proxy-rate-limit-test",
|
|
80
|
-
}),
|
|
81
|
-
});
|
|
82
|
-
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /register");
|
|
83
|
-
assert.strictEqual(res.status, 201, "DCR should succeed behind forwarded IPv4:port");
|
|
84
|
-
});
|
|
85
|
-
test("should not return 500 from /authorize", async () => {
|
|
86
|
-
const params = new URLSearchParams({
|
|
87
|
-
response_type: "code",
|
|
88
|
-
client_id: MOCK_CLIENT_ID,
|
|
89
|
-
redirect_uri: "https://client.example/callback",
|
|
90
|
-
code_challenge: "challenge",
|
|
91
|
-
code_challenge_method: "S256",
|
|
92
|
-
state: "proxy-rate-limit-state",
|
|
93
|
-
scope: "api",
|
|
94
|
-
});
|
|
95
|
-
const res = await fetch(`${mcpBaseUrl}/authorize?${params}`, {
|
|
96
|
-
redirect: "manual",
|
|
97
|
-
headers: {
|
|
98
|
-
"X-Forwarded-For": "[2001:db8::1]:5678",
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /authorize");
|
|
102
|
-
});
|
|
103
|
-
test("should not return 500 from /token", async () => {
|
|
104
|
-
const res = await fetch(`${mcpBaseUrl}/token`, {
|
|
105
|
-
method: "POST",
|
|
106
|
-
headers: {
|
|
107
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
108
|
-
"X-Forwarded-For": "160.79.106.36:38914",
|
|
109
|
-
},
|
|
110
|
-
body: new URLSearchParams({
|
|
111
|
-
grant_type: "authorization_code",
|
|
112
|
-
client_id: MOCK_CLIENT_ID,
|
|
113
|
-
code: "invalid-code",
|
|
114
|
-
redirect_uri: "https://client.example/callback",
|
|
115
|
-
}),
|
|
116
|
-
});
|
|
117
|
-
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /token");
|
|
118
|
-
});
|
|
119
|
-
test("should not return 500 from /revoke", async () => {
|
|
120
|
-
const res = await fetch(`${mcpBaseUrl}/revoke`, {
|
|
121
|
-
method: "POST",
|
|
122
|
-
headers: {
|
|
123
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
124
|
-
"X-Forwarded-For": "[2001:db8::1]",
|
|
125
|
-
},
|
|
126
|
-
body: new URLSearchParams({
|
|
127
|
-
token: "invalid-token",
|
|
128
|
-
}),
|
|
129
|
-
});
|
|
130
|
-
assert.notEqual(res.status, 500, "rate-limit key generation should not crash /revoke");
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
});
|
package/build/test/test-orbit.js
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { describe, test, before, after } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import { spawn } from "child_process";
|
|
4
|
-
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
-
const MOCK_TOKEN = "glpat-mock-token-12345";
|
|
6
|
-
const mockSchema = {
|
|
7
|
-
nodes: [{ name: "Project", properties: ["id", "name"] }],
|
|
8
|
-
edges: [{ name: "HAS_MERGE_REQUEST", from: "Project", to: "MergeRequest" }],
|
|
9
|
-
};
|
|
10
|
-
const mockStatus = {
|
|
11
|
-
status: "ready",
|
|
12
|
-
last_indexed_at: "2026-09-05T10:00:00Z",
|
|
13
|
-
indexed_groups: 3,
|
|
14
|
-
};
|
|
15
|
-
const mockTools = {
|
|
16
|
-
tools: [{ name: "orbit_query", description: "Execute a graph query" }],
|
|
17
|
-
};
|
|
18
|
-
const mockQueryRaw = {
|
|
19
|
-
results: [{ project: "group/a", failures: 7 }],
|
|
20
|
-
};
|
|
21
|
-
const mockQueryLlm = "Top failing project: group/a (7 failures).";
|
|
22
|
-
function baseEnv(mockGitLabUrl) {
|
|
23
|
-
return {
|
|
24
|
-
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
25
|
-
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
26
|
-
GITLAB_TOOLSETS: "orbit",
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
async function callToolAsync(env, name, args) {
|
|
30
|
-
return new Promise((resolve, reject) => {
|
|
31
|
-
const proc = spawn("node", ["build/index.js"], {
|
|
32
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
-
env: { ...process.env, ...env },
|
|
34
|
-
});
|
|
35
|
-
let output = "";
|
|
36
|
-
let errorOutput = "";
|
|
37
|
-
proc.stdout?.on("data", (chunk) => (output += chunk));
|
|
38
|
-
proc.stderr?.on("data", (chunk) => (errorOutput += chunk));
|
|
39
|
-
proc.on("close", code => {
|
|
40
|
-
if (code !== 0) {
|
|
41
|
-
return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
42
|
-
}
|
|
43
|
-
const line = output.split("\n").find(entry => entry.startsWith("{"));
|
|
44
|
-
if (!line) {
|
|
45
|
-
return reject(new Error("No JSON output found"));
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
const response = JSON.parse(line);
|
|
49
|
-
if (response.error) {
|
|
50
|
-
reject(new Error(JSON.stringify(response.error)));
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
const content = response.result?.content?.[0]?.text;
|
|
54
|
-
if (content === undefined) {
|
|
55
|
-
reject(new Error("No tool result content"));
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
resolve(content);
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
reject(error);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
proc.stdin?.end(JSON.stringify({
|
|
65
|
-
jsonrpc: "2.0",
|
|
66
|
-
id: 1,
|
|
67
|
-
method: "tools/call",
|
|
68
|
-
params: { name, arguments: args },
|
|
69
|
-
}) + "\n");
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
describe("orbit tools", () => {
|
|
73
|
-
let mockGitLab;
|
|
74
|
-
let mockGitLabUrl;
|
|
75
|
-
let lastQueryBody = null;
|
|
76
|
-
before(async () => {
|
|
77
|
-
const mockPort = await findMockServerPort();
|
|
78
|
-
mockGitLab = new MockGitLabServer({
|
|
79
|
-
port: mockPort,
|
|
80
|
-
validTokens: [MOCK_TOKEN],
|
|
81
|
-
});
|
|
82
|
-
mockGitLab.addMockHandler("get", "/orbit/schema", (_req, res) => {
|
|
83
|
-
res.json(mockSchema);
|
|
84
|
-
});
|
|
85
|
-
mockGitLab.addMockHandler("get", "/orbit/status", (_req, res) => {
|
|
86
|
-
res.json(mockStatus);
|
|
87
|
-
});
|
|
88
|
-
mockGitLab.addMockHandler("get", "/orbit/tools", (_req, res) => {
|
|
89
|
-
res.json(mockTools);
|
|
90
|
-
});
|
|
91
|
-
mockGitLab.addMockHandler("post", "/orbit/query", (req, res) => {
|
|
92
|
-
lastQueryBody = req.body;
|
|
93
|
-
if (req.body?.format === "raw") {
|
|
94
|
-
res.json(mockQueryRaw);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
res.type("text/plain").send(mockQueryLlm);
|
|
98
|
-
});
|
|
99
|
-
await mockGitLab.start();
|
|
100
|
-
mockGitLabUrl = mockGitLab.getUrl();
|
|
101
|
-
});
|
|
102
|
-
after(async () => {
|
|
103
|
-
await mockGitLab.stop();
|
|
104
|
-
});
|
|
105
|
-
test("orbit_get_schema returns the graph schema", async () => {
|
|
106
|
-
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_get_schema", {});
|
|
107
|
-
assert.deepEqual(JSON.parse(text), mockSchema);
|
|
108
|
-
});
|
|
109
|
-
test("orbit_get_status returns indexing status", async () => {
|
|
110
|
-
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_get_status", {});
|
|
111
|
-
assert.deepEqual(JSON.parse(text), mockStatus);
|
|
112
|
-
});
|
|
113
|
-
test("orbit_list_tools returns tool definitions", async () => {
|
|
114
|
-
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_list_tools", {});
|
|
115
|
-
assert.deepEqual(JSON.parse(text), mockTools);
|
|
116
|
-
});
|
|
117
|
-
test("orbit_query with format=raw returns structured JSON", async () => {
|
|
118
|
-
const query = { node: "Project", limit: 5 };
|
|
119
|
-
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_query", {
|
|
120
|
-
query,
|
|
121
|
-
format: "raw",
|
|
122
|
-
});
|
|
123
|
-
assert.deepEqual(JSON.parse(text), mockQueryRaw);
|
|
124
|
-
assert.deepEqual(lastQueryBody, { query, format: "raw" });
|
|
125
|
-
});
|
|
126
|
-
test("orbit_query defaults to llm format and passes text through", async () => {
|
|
127
|
-
const query = { node: "Project", limit: 5 };
|
|
128
|
-
const text = await callToolAsync(baseEnv(mockGitLabUrl), "orbit_query", { query });
|
|
129
|
-
assert.equal(text, mockQueryLlm);
|
|
130
|
-
assert.deepEqual(lastQueryBody, { query, format: "llm" });
|
|
131
|
-
});
|
|
132
|
-
for (const toolName of [
|
|
133
|
-
"orbit_query",
|
|
134
|
-
"orbit_get_schema",
|
|
135
|
-
"orbit_get_status",
|
|
136
|
-
"orbit_list_tools",
|
|
137
|
-
]) {
|
|
138
|
-
test(`${toolName} is refused under strict project scope`, async () => {
|
|
139
|
-
const env = {
|
|
140
|
-
...baseEnv(mockGitLabUrl),
|
|
141
|
-
ENABLE_STRICT_PROJECT_SCOPE: "true",
|
|
142
|
-
GITLAB_ALLOWED_PROJECT_IDS: "1,2",
|
|
143
|
-
};
|
|
144
|
-
const args = toolName === "orbit_query" ? { query: {} } : {};
|
|
145
|
-
let message = "";
|
|
146
|
-
try {
|
|
147
|
-
message = await callToolAsync(env, toolName, args);
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
message = error.message;
|
|
151
|
-
}
|
|
152
|
-
assert.match(message, /not allowed while a project allowlist is in effect/);
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
});
|