@zereight/mcp-gitlab 2.1.29 → 2.1.38
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 +72 -20
- package/README.md +120 -82
- package/README.zh-CN.md +72 -20
- package/build/config.js +13 -0
- package/build/downloads/proxy.js +199 -0
- package/build/index.js +850 -490
- package/build/schemas.js +115 -28
- package/build/scripts/check-skill-sync.js +137 -0
- package/build/scripts/generate-tool-docs.js +8 -3
- package/build/server/metrics.js +84 -0
- package/build/server/transport-mode.js +25 -0
- package/build/test/callback-proxy-tests.js +1 -1
- package/build/test/client-pool-test.js +1 -1
- package/build/test/dynamic-api-url-allowlist.test.js +2 -2
- package/build/test/dynamic-api-url-test.js +4 -4
- package/build/test/dynamic-routing-tests.js +4 -4
- package/build/test/group-milestone-schema.test.js +45 -0
- package/build/test/mcp-oauth-tests.js +29 -4
- package/build/test/mcp-server-name.test.js +87 -0
- package/build/test/multi-server-test.js +3 -3
- package/build/test/no-proxy-integration-test.js +1 -1
- package/build/test/nullable-gitlab-response-fields.test.js +22 -0
- package/build/test/path-segment-encoding.test.js +11 -0
- package/build/test/remote-auth-simple-test.js +226 -199
- package/build/test/server/metrics.test.js +47 -0
- package/build/test/sse-auth-guard.test.js +2 -2
- package/build/test/stateless/session-id-integration.test.js +3 -3
- package/build/test/streamable-http-concurrent-session.test.js +64 -1
- package/build/test/streamable-http-dns-rebinding.test.js +185 -0
- package/build/test/streamable-http-static-token-auth.test.js +25 -22
- package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
- package/build/test/test-ci-catalog.js +1 -1
- package/build/test/test-ci-lint.js +1 -1
- package/build/test/test-ci-variables.js +8 -5
- package/build/test/test-dependency-proxy.js +11 -7
- package/build/test/test-deployment-tools.js +16 -2
- package/build/test/test-download-attachment.js +1 -1
- package/build/test/test-get-file-blame.js +1 -1
- package/build/test/test-geteffectiveprojectid.js +221 -8
- package/build/test/test-issue-description-patch.js +1 -1
- package/build/test/test-job-artifacts.js +1 -1
- package/build/test/test-list-issues.js +1 -1
- package/build/test/test-list-merge-requests.js +1 -1
- package/build/test/test-list-project-members.js +1 -1
- package/build/test/test-merge-request-approval-state-tools.js +1 -1
- package/build/test/test-merge-request-pipelines.js +1 -1
- package/build/test/test-mr-diffs-filter.js +1 -1
- package/build/test/test-mr-file-diffs.js +2 -2
- package/build/test/test-oauth-proxy-rate-limit.js +1 -1
- package/build/test/test-permission-mode.js +216 -0
- package/build/test/test-protected-branches.js +23 -4
- package/build/test/test-remote-downloads.js +2 -2
- package/build/test/test-search-code.js +1 -1
- package/build/test/test-tags.js +1 -1
- package/build/test/test-todos.js +1 -1
- package/build/test/test-token-optimizations.js +3 -3
- package/build/test/test-toolset-filtering.js +3 -3
- package/build/test/test-update-issue-slim.js +141 -0
- package/build/test/test-upload-markdown.js +1 -1
- package/build/test/utils/download-token.test.js +35 -0
- package/build/test/utils/forwarded-public-base-url.test.js +9 -1
- package/build/test/utils/graphql-query.test.js +64 -1
- package/build/test/utils/mock-gitlab-server.js +24 -31
- package/build/test/utils/server-launcher.js +1 -2
- package/build/test/utils/version-check.test.js +52 -0
- package/build/tools/registry.js +93 -5
- package/build/utils/download-token.js +71 -0
- package/build/utils/forwarded-public-base-url.js +22 -0
- package/build/utils/graphql-query.js +109 -12
- package/build/utils/version-check.js +40 -0
- package/package.json +5 -4
|
@@ -26,13 +26,134 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
26
26
|
let mockGitLab;
|
|
27
27
|
let servers = [];
|
|
28
28
|
let client;
|
|
29
|
+
let group123Requested = false;
|
|
30
|
+
let group789Requested = false;
|
|
31
|
+
let labelLookupRoots = [];
|
|
29
32
|
before(async () => {
|
|
30
33
|
// Start mock GitLab server
|
|
31
|
-
const mockPort = await findMockServerPort(
|
|
34
|
+
const mockPort = await findMockServerPort();
|
|
32
35
|
mockGitLab = new MockGitLabServer({
|
|
33
36
|
port: mockPort,
|
|
34
37
|
validTokens: [MOCK_TOKEN]
|
|
35
38
|
});
|
|
39
|
+
mockGitLab.addMockHandler('get', '/groups/123', (_req, res) => {
|
|
40
|
+
group123Requested = true;
|
|
41
|
+
res.json({
|
|
42
|
+
id: 123,
|
|
43
|
+
name: 'Test Group',
|
|
44
|
+
path: 'test-group',
|
|
45
|
+
full_path: 'test-group',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
mockGitLab.addMockHandler('get', '/projects/789', (_req, res) => {
|
|
49
|
+
res.status(404).json({ message: '404 Project Not Found' });
|
|
50
|
+
});
|
|
51
|
+
mockGitLab.addMockHandler('get', '/groups/789', (_req, res) => {
|
|
52
|
+
group789Requested = true;
|
|
53
|
+
res.json({
|
|
54
|
+
id: 789,
|
|
55
|
+
name: 'Numeric Group',
|
|
56
|
+
path: 'numeric-group',
|
|
57
|
+
full_path: 'numeric-group',
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
mockGitLab.addRootHandler('post', '/api/graphql', (req, res) => {
|
|
61
|
+
const { query, variables } = req.body;
|
|
62
|
+
if (query.includes('workItemTypes')) {
|
|
63
|
+
res.json({
|
|
64
|
+
data: {
|
|
65
|
+
namespace: {
|
|
66
|
+
workItemTypes: {
|
|
67
|
+
nodes: [{ id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' }],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (query.includes('workItems(')) {
|
|
75
|
+
assert.ok(query.includes('group(fullPath: $path)'), 'Should query the group GraphQL root');
|
|
76
|
+
assert.strictEqual(variables.path, 'test-group', 'Should use resolved group full_path');
|
|
77
|
+
res.json({
|
|
78
|
+
data: {
|
|
79
|
+
group: {
|
|
80
|
+
workItems: {
|
|
81
|
+
nodes: [
|
|
82
|
+
{
|
|
83
|
+
id: 'gid://gitlab/WorkItem/1',
|
|
84
|
+
iid: '1',
|
|
85
|
+
title: 'Group work item',
|
|
86
|
+
state: 'OPEN',
|
|
87
|
+
webUrl: 'https://gitlab.mock/groups/test-group/-/work_items/1',
|
|
88
|
+
workItemType: { name: 'Issue' },
|
|
89
|
+
widgets: [],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (query.includes('labels(title:')) {
|
|
100
|
+
assert.ok(query.includes('group(fullPath: $path)'), 'Should resolve labels from Group');
|
|
101
|
+
assert.ok(!query.includes('namespace(fullPath: $path)'), 'Should not query labels from Namespace');
|
|
102
|
+
labelLookupRoots.push('group');
|
|
103
|
+
res.json({
|
|
104
|
+
data: {
|
|
105
|
+
group: { l0: { nodes: [{ id: 'gid://gitlab/GroupLabel/1' }] } },
|
|
106
|
+
users: { nodes: [] },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (query.includes('workItem(iid: $iid)')) {
|
|
112
|
+
res.json({
|
|
113
|
+
data: { namespace: { workItem: { id: 'gid://gitlab/WorkItem/1' } } },
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (query.includes('workItemCreate')) {
|
|
118
|
+
assert.deepStrictEqual(variables.labelIds, ['gid://gitlab/GroupLabel/1']);
|
|
119
|
+
res.json({
|
|
120
|
+
data: {
|
|
121
|
+
workItemCreate: {
|
|
122
|
+
workItem: {
|
|
123
|
+
id: 'gid://gitlab/WorkItem/2',
|
|
124
|
+
iid: '2',
|
|
125
|
+
title: variables.title,
|
|
126
|
+
webUrl: 'https://gitlab.mock/groups/test-group/-/work_items/2',
|
|
127
|
+
workItemType: { name: 'Issue' },
|
|
128
|
+
},
|
|
129
|
+
errors: [],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (query.includes('workItemUpdate')) {
|
|
136
|
+
assert.deepStrictEqual(variables.addLabelIds, ['gid://gitlab/GroupLabel/1']);
|
|
137
|
+
res.json({
|
|
138
|
+
data: {
|
|
139
|
+
workItemUpdate: {
|
|
140
|
+
workItem: {
|
|
141
|
+
id: variables.id,
|
|
142
|
+
iid: '1',
|
|
143
|
+
title: 'Updated group work item',
|
|
144
|
+
state: 'OPEN',
|
|
145
|
+
webUrl: 'https://gitlab.mock/groups/test-group/-/work_items/1',
|
|
146
|
+
workItemType: { name: 'Issue' },
|
|
147
|
+
widgets: [],
|
|
148
|
+
},
|
|
149
|
+
errors: [],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
res.status(500).json({ message: `Unexpected GraphQL query: ${query}` });
|
|
156
|
+
});
|
|
36
157
|
await mockGitLab.start();
|
|
37
158
|
const mockGitLabUrl = mockGitLab.getUrl();
|
|
38
159
|
// Start MCP server WITHOUT GITLAB_ALLOWED_PROJECT_IDS
|
|
@@ -46,7 +167,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
46
167
|
REMOTE_AUTHORIZATION: 'true',
|
|
47
168
|
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
48
169
|
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
|
|
49
|
-
GITLAB_READ_ONLY_MODE: '
|
|
170
|
+
GITLAB_READ_ONLY_MODE: 'false',
|
|
50
171
|
}
|
|
51
172
|
});
|
|
52
173
|
servers.push(server);
|
|
@@ -94,6 +215,82 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
94
215
|
assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
|
|
95
216
|
console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
|
|
96
217
|
});
|
|
218
|
+
test('should resolve explicit numeric group IDs through the groups endpoint', async () => {
|
|
219
|
+
group123Requested = false;
|
|
220
|
+
const result = await client.callTool('list_work_items', {
|
|
221
|
+
project_id: 'group:123',
|
|
222
|
+
});
|
|
223
|
+
assert.ok(group123Requested, 'Should request /groups/123');
|
|
224
|
+
assert.ok(result.content, 'Should have content');
|
|
225
|
+
const content = result.content[0];
|
|
226
|
+
assert.ok('text' in content, 'Content should have text');
|
|
227
|
+
const parsed = JSON.parse(content.text);
|
|
228
|
+
assert.strictEqual(parsed.items.length, 1, 'Should return group work items');
|
|
229
|
+
assert.strictEqual(parsed.items[0].title, 'Group work item');
|
|
230
|
+
console.log(' ✓ Resolved group:123 via /groups/123 when no project allowlist is set');
|
|
231
|
+
});
|
|
232
|
+
test('should resolve labels from the group root when creating group work items', async () => {
|
|
233
|
+
labelLookupRoots = [];
|
|
234
|
+
const result = await client.callTool('create_work_item', {
|
|
235
|
+
project_id: 'group:123',
|
|
236
|
+
title: 'Created group work item',
|
|
237
|
+
labels: ['group-label'],
|
|
238
|
+
});
|
|
239
|
+
assert.deepStrictEqual(labelLookupRoots, ['group']);
|
|
240
|
+
assert.ok(result.content, 'Should have content');
|
|
241
|
+
const content = result.content[0];
|
|
242
|
+
assert.ok('text' in content, 'Content should have text');
|
|
243
|
+
assert.strictEqual(JSON.parse(content.text).title, 'Created group work item');
|
|
244
|
+
console.log(' ✓ Resolved create labels through group(fullPath:)');
|
|
245
|
+
});
|
|
246
|
+
test('should resolve labels from the group root when updating group work items', async () => {
|
|
247
|
+
labelLookupRoots = [];
|
|
248
|
+
const result = await client.callTool('update_work_item', {
|
|
249
|
+
project_id: 'group:123',
|
|
250
|
+
iid: 1,
|
|
251
|
+
add_labels: ['group-label'],
|
|
252
|
+
});
|
|
253
|
+
assert.deepStrictEqual(labelLookupRoots, ['group']);
|
|
254
|
+
assert.ok(result.content, 'Should have content');
|
|
255
|
+
const content = result.content[0];
|
|
256
|
+
assert.ok('text' in content, 'Content should have text');
|
|
257
|
+
assert.strictEqual(JSON.parse(content.text).title, 'Updated group work item');
|
|
258
|
+
console.log(' ✓ Resolved update labels through group(fullPath:)');
|
|
259
|
+
});
|
|
260
|
+
test('should not fall back from bare numeric project IDs to groups', async () => {
|
|
261
|
+
group789Requested = false;
|
|
262
|
+
let didThrow = false;
|
|
263
|
+
try {
|
|
264
|
+
await client.callTool('list_work_items', {
|
|
265
|
+
project_id: '789',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
didThrow = true;
|
|
270
|
+
assert.ok(error instanceof Error);
|
|
271
|
+
assert.ok(error.message.includes('404'), 'Should surface the project 404');
|
|
272
|
+
}
|
|
273
|
+
assert.ok(didThrow, 'Should have failed on the project 404');
|
|
274
|
+
assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
|
|
275
|
+
console.log(' ✓ Bare numeric ID 789 did not fall back to /groups/789');
|
|
276
|
+
});
|
|
277
|
+
test('should not fall back from explicit numeric project IDs to groups', async () => {
|
|
278
|
+
group789Requested = false;
|
|
279
|
+
let didThrow = false;
|
|
280
|
+
try {
|
|
281
|
+
await client.callTool('list_work_items', {
|
|
282
|
+
project_id: 'project:789',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
didThrow = true;
|
|
287
|
+
assert.ok(error instanceof Error);
|
|
288
|
+
assert.ok(error.message.includes('404'), 'Should surface the project 404');
|
|
289
|
+
}
|
|
290
|
+
assert.ok(didThrow, 'Should have failed on the project 404');
|
|
291
|
+
assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
|
|
292
|
+
console.log(' ✓ Explicit project:789 did not fall back to /groups/789');
|
|
293
|
+
});
|
|
97
294
|
});
|
|
98
295
|
describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () => {
|
|
99
296
|
let mcpUrl;
|
|
@@ -102,7 +299,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
102
299
|
let client;
|
|
103
300
|
before(async () => {
|
|
104
301
|
// Start mock GitLab server
|
|
105
|
-
const mockPort = await findMockServerPort(
|
|
302
|
+
const mockPort = await findMockServerPort();
|
|
106
303
|
mockGitLab = new MockGitLabServer({
|
|
107
304
|
port: mockPort,
|
|
108
305
|
validTokens: [MOCK_TOKEN]
|
|
@@ -166,6 +363,22 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
166
363
|
console.log(' ✓ Correctly rejected access to non-allowed project');
|
|
167
364
|
}
|
|
168
365
|
});
|
|
366
|
+
test('should reject explicit group IDs when GITLAB_ALLOWED_PROJECT_IDS is set', async () => {
|
|
367
|
+
let didThrow = false;
|
|
368
|
+
try {
|
|
369
|
+
await client.callTool('list_work_items', {
|
|
370
|
+
project_id: 'group:123',
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
didThrow = true;
|
|
375
|
+
assert.ok(error instanceof Error);
|
|
376
|
+
assert.ok(error.message.includes('GITLAB_ALLOWED_PROJECT_IDS') &&
|
|
377
|
+
error.message.includes('project allowlist does not cover groups'), 'Should explain that project allowlists do not authorize groups');
|
|
378
|
+
console.log(' ✓ Rejected group:123 when GITLAB_ALLOWED_PROJECT_IDS is set');
|
|
379
|
+
}
|
|
380
|
+
assert.ok(didThrow, 'Should have rejected explicit group access');
|
|
381
|
+
});
|
|
169
382
|
});
|
|
170
383
|
describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', () => {
|
|
171
384
|
let mcpUrl;
|
|
@@ -174,7 +387,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
174
387
|
let client;
|
|
175
388
|
before(async () => {
|
|
176
389
|
// Start mock GitLab server
|
|
177
|
-
const mockPort = await findMockServerPort(
|
|
390
|
+
const mockPort = await findMockServerPort();
|
|
178
391
|
mockGitLab = new MockGitLabServer({
|
|
179
392
|
port: mockPort,
|
|
180
393
|
validTokens: [MOCK_TOKEN]
|
|
@@ -255,7 +468,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
255
468
|
let servers = [];
|
|
256
469
|
let client;
|
|
257
470
|
before(async () => {
|
|
258
|
-
const mockPort = await findMockServerPort(
|
|
471
|
+
const mockPort = await findMockServerPort();
|
|
259
472
|
mockGitLab = new MockGitLabServer({
|
|
260
473
|
port: mockPort,
|
|
261
474
|
validTokens: [MOCK_TOKEN]
|
|
@@ -353,7 +566,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
353
566
|
let servers = [];
|
|
354
567
|
let client;
|
|
355
568
|
before(async () => {
|
|
356
|
-
const mockPort = await findMockServerPort(
|
|
569
|
+
const mockPort = await findMockServerPort();
|
|
357
570
|
mockGitLab = new MockGitLabServer({
|
|
358
571
|
port: mockPort,
|
|
359
572
|
validTokens: [MOCK_TOKEN]
|
|
@@ -451,7 +664,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
451
664
|
let servers = [];
|
|
452
665
|
let client;
|
|
453
666
|
before(async () => {
|
|
454
|
-
const mockPort = await findMockServerPort(
|
|
667
|
+
const mockPort = await findMockServerPort();
|
|
455
668
|
mockGitLab = new MockGitLabServer({
|
|
456
669
|
port: mockPort,
|
|
457
670
|
validTokens: [MOCK_TOKEN]
|
|
@@ -514,7 +727,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
|
|
|
514
727
|
let servers = [];
|
|
515
728
|
let client;
|
|
516
729
|
before(async () => {
|
|
517
|
-
const mockPort = await findMockServerPort(
|
|
730
|
+
const mockPort = await findMockServerPort();
|
|
518
731
|
mockGitLab = new MockGitLabServer({
|
|
519
732
|
port: mockPort,
|
|
520
733
|
validTokens: [MOCK_TOKEN],
|
|
@@ -137,7 +137,7 @@ describe("update_issue_description_patch MCP tool", () => {
|
|
|
137
137
|
return client;
|
|
138
138
|
}
|
|
139
139
|
before(async () => {
|
|
140
|
-
const mockPort = await findMockServerPort(
|
|
140
|
+
const mockPort = await findMockServerPort();
|
|
141
141
|
mockGitLab = new MockGitLabServer({
|
|
142
142
|
port: mockPort,
|
|
143
143
|
validTokens: [MOCK_TOKEN],
|
|
@@ -69,7 +69,7 @@ describe('job artifacts tools', () => {
|
|
|
69
69
|
let mockGitLabUrl;
|
|
70
70
|
let tmpDir;
|
|
71
71
|
before(async () => {
|
|
72
|
-
const mockPort = await findMockServerPort(
|
|
72
|
+
const mockPort = await findMockServerPort();
|
|
73
73
|
mockGitLab = new MockGitLabServer({
|
|
74
74
|
port: mockPort,
|
|
75
75
|
validTokens: [MOCK_TOKEN],
|
|
@@ -65,7 +65,7 @@ describe("list_issues", () => {
|
|
|
65
65
|
let mockGitLab;
|
|
66
66
|
let mockGitLabUrl;
|
|
67
67
|
before(async () => {
|
|
68
|
-
const mockPort = await findMockServerPort(
|
|
68
|
+
const mockPort = await findMockServerPort();
|
|
69
69
|
mockGitLab = new MockGitLabServer({
|
|
70
70
|
port: mockPort,
|
|
71
71
|
validTokens: [MOCK_TOKEN],
|
|
@@ -62,7 +62,7 @@ describe('list_merge_requests', () => {
|
|
|
62
62
|
let mockGitLab;
|
|
63
63
|
let mockGitLabUrl;
|
|
64
64
|
before(async () => {
|
|
65
|
-
const mockPort = await findMockServerPort(
|
|
65
|
+
const mockPort = await findMockServerPort();
|
|
66
66
|
mockGitLab = new MockGitLabServer({
|
|
67
67
|
port: mockPort,
|
|
68
68
|
validTokens: [MOCK_TOKEN]
|
|
@@ -84,7 +84,7 @@ describe('list_project_members', () => {
|
|
|
84
84
|
let directEndpointHit = false;
|
|
85
85
|
let inheritedEndpointHit = false;
|
|
86
86
|
before(async () => {
|
|
87
|
-
const mockPort = await findMockServerPort(
|
|
87
|
+
const mockPort = await findMockServerPort();
|
|
88
88
|
mockGitLab = new MockGitLabServer({
|
|
89
89
|
port: mockPort,
|
|
90
90
|
validTokens: [MOCK_TOKEN]
|
|
@@ -65,7 +65,7 @@ describe("merge request approval state tools", () => {
|
|
|
65
65
|
let mockGitLab;
|
|
66
66
|
let mockGitLabUrl;
|
|
67
67
|
before(async () => {
|
|
68
|
-
const mockPort = await findMockServerPort(
|
|
68
|
+
const mockPort = await findMockServerPort();
|
|
69
69
|
mockGitLab = new MockGitLabServer({
|
|
70
70
|
port: mockPort,
|
|
71
71
|
validTokens: [MOCK_TOKEN],
|
|
@@ -55,7 +55,7 @@ describe("list_merge_request_pipelines", () => {
|
|
|
55
55
|
let mockGitLabUrl;
|
|
56
56
|
let lastQuery = {};
|
|
57
57
|
before(async () => {
|
|
58
|
-
const mockPort = await findMockServerPort(
|
|
58
|
+
const mockPort = await findMockServerPort();
|
|
59
59
|
mockGitLab = new MockGitLabServer({
|
|
60
60
|
port: mockPort,
|
|
61
61
|
validTokens: [MOCK_TOKEN],
|
|
@@ -62,7 +62,7 @@ describe('get_merge_request_diffs with excluded_file_patterns', () => {
|
|
|
62
62
|
let mockGitLab;
|
|
63
63
|
let mockGitLabUrl;
|
|
64
64
|
before(async () => {
|
|
65
|
-
const mockPort = await findMockServerPort(
|
|
65
|
+
const mockPort = await findMockServerPort();
|
|
66
66
|
mockGitLab = new MockGitLabServer({
|
|
67
67
|
port: mockPort,
|
|
68
68
|
validTokens: [MOCK_TOKEN]
|
|
@@ -115,7 +115,7 @@ describe('list_merge_request_changed_files', () => {
|
|
|
115
115
|
let mockGitLab;
|
|
116
116
|
let mockGitLabUrl;
|
|
117
117
|
before(async () => {
|
|
118
|
-
const mockPort = await findMockServerPort(
|
|
118
|
+
const mockPort = await findMockServerPort();
|
|
119
119
|
mockGitLab = new MockGitLabServer({
|
|
120
120
|
port: mockPort,
|
|
121
121
|
validTokens: [MOCK_TOKEN]
|
|
@@ -177,7 +177,7 @@ describe('get_merge_request_file_diff', () => {
|
|
|
177
177
|
let mockGitLab;
|
|
178
178
|
let mockGitLabUrl;
|
|
179
179
|
before(async () => {
|
|
180
|
-
const mockPort = await findMockServerPort(
|
|
180
|
+
const mockPort = await findMockServerPort();
|
|
181
181
|
mockGitLab = new MockGitLabServer({
|
|
182
182
|
port: mockPort,
|
|
183
183
|
validTokens: [MOCK_TOKEN]
|
|
@@ -38,7 +38,7 @@ describe("When MCP OAuth runs behind a trusted proxy", () => {
|
|
|
38
38
|
let mockGitLab;
|
|
39
39
|
let servers = [];
|
|
40
40
|
before(async () => {
|
|
41
|
-
const mockPort = await findMockServerPort(
|
|
41
|
+
const mockPort = await findMockServerPort();
|
|
42
42
|
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [] });
|
|
43
43
|
await mockGitLab.start();
|
|
44
44
|
const mockGitLabUrl = mockGitLab.getUrl();
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Mode Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Tests GITLAB_PERMISSION_MODE (readonly / modify / full), its interaction with
|
|
5
|
+
* the legacy GITLAB_READ_ONLY_MODE flag, and enforcement at list_tools,
|
|
6
|
+
* call_tool, and execute_graphql levels.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, after, before } from "node:test";
|
|
9
|
+
import assert from "node:assert";
|
|
10
|
+
import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
|
|
11
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
12
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
13
|
+
const MOCK_TOKEN = "glpat-permission-mode-test";
|
|
14
|
+
const MOCK_PORT_BASE = 9700;
|
|
15
|
+
const MCP_PORT_BASE = 3700;
|
|
16
|
+
let mockGitLab;
|
|
17
|
+
let mockGitLabUrl;
|
|
18
|
+
let portCounter = 0;
|
|
19
|
+
async function nextMcpPort() {
|
|
20
|
+
return findAvailablePort(MCP_PORT_BASE + portCounter++ * 10);
|
|
21
|
+
}
|
|
22
|
+
async function launchMcpServer(extraEnv = {}) {
|
|
23
|
+
const port = await nextMcpPort();
|
|
24
|
+
return launchServer({
|
|
25
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
26
|
+
port,
|
|
27
|
+
timeout: 10000,
|
|
28
|
+
env: {
|
|
29
|
+
STREAMABLE_HTTP: "true",
|
|
30
|
+
REMOTE_AUTHORIZATION: "true",
|
|
31
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
32
|
+
GITLAB_TOOLSETS: "all",
|
|
33
|
+
...extraEnv,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function mcpUrl(server) {
|
|
38
|
+
return `http://${HOST}:${server.port}/mcp`;
|
|
39
|
+
}
|
|
40
|
+
async function connectClient(server) {
|
|
41
|
+
const client = new CustomHeaderClient({
|
|
42
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
43
|
+
});
|
|
44
|
+
await client.connect(mcpUrl(server));
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
async function getToolNames(server) {
|
|
48
|
+
const client = await connectClient(server);
|
|
49
|
+
const result = await client.listTools();
|
|
50
|
+
await client.disconnect();
|
|
51
|
+
return result.tools.map((t) => t.name);
|
|
52
|
+
}
|
|
53
|
+
const DELETE_SAMPLE_TOOLS = [
|
|
54
|
+
"delete_issue",
|
|
55
|
+
"delete_branch",
|
|
56
|
+
"delete_label",
|
|
57
|
+
"delete_wiki_page",
|
|
58
|
+
"delete_milestone",
|
|
59
|
+
"delete_group_milestone",
|
|
60
|
+
"delete_project_variable",
|
|
61
|
+
];
|
|
62
|
+
const MODIFY_SAMPLE_TOOLS = [
|
|
63
|
+
"create_issue",
|
|
64
|
+
"update_issue",
|
|
65
|
+
"create_merge_request",
|
|
66
|
+
"create_branch",
|
|
67
|
+
"push_files",
|
|
68
|
+
"merge_merge_request",
|
|
69
|
+
];
|
|
70
|
+
const READ_SAMPLE_TOOLS = ["list_issues", "get_project", "list_merge_requests"];
|
|
71
|
+
describe("Permission Mode", { concurrency: 1 }, () => {
|
|
72
|
+
before(async () => {
|
|
73
|
+
const mockPort = await findMockServerPort();
|
|
74
|
+
mockGitLab = new MockGitLabServer({
|
|
75
|
+
port: mockPort,
|
|
76
|
+
validTokens: [MOCK_TOKEN],
|
|
77
|
+
});
|
|
78
|
+
await mockGitLab.start();
|
|
79
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
80
|
+
});
|
|
81
|
+
after(async () => {
|
|
82
|
+
if (mockGitLab)
|
|
83
|
+
await mockGitLab.stop();
|
|
84
|
+
});
|
|
85
|
+
describe("full mode (default)", () => {
|
|
86
|
+
let server;
|
|
87
|
+
const servers = [];
|
|
88
|
+
before(async () => {
|
|
89
|
+
server = await launchMcpServer();
|
|
90
|
+
servers.push(server);
|
|
91
|
+
});
|
|
92
|
+
after(() => cleanupServers(servers));
|
|
93
|
+
test("exposes read, modify, and delete tools", async () => {
|
|
94
|
+
const names = await getToolNames(server);
|
|
95
|
+
for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
|
|
96
|
+
assert.ok(names.includes(name), `full mode: expected "${name}" to be present`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("modify mode", () => {
|
|
101
|
+
let server;
|
|
102
|
+
const servers = [];
|
|
103
|
+
before(async () => {
|
|
104
|
+
server = await launchMcpServer({ GITLAB_PERMISSION_MODE: "modify" });
|
|
105
|
+
servers.push(server);
|
|
106
|
+
});
|
|
107
|
+
after(() => cleanupServers(servers));
|
|
108
|
+
test("hides delete tools but keeps read and modify tools", async () => {
|
|
109
|
+
const names = await getToolNames(server);
|
|
110
|
+
for (const name of DELETE_SAMPLE_TOOLS) {
|
|
111
|
+
assert.ok(!names.includes(name), `modify mode: expected "${name}" to be absent`);
|
|
112
|
+
}
|
|
113
|
+
for (const name of [...READ_SAMPLE_TOOLS, ...MODIFY_SAMPLE_TOOLS]) {
|
|
114
|
+
assert.ok(names.includes(name), `modify mode: expected "${name}" to be present`);
|
|
115
|
+
}
|
|
116
|
+
assert.ok(!names.some(n => n.startsWith("delete_")), "no delete_* tool should be listed");
|
|
117
|
+
});
|
|
118
|
+
test("hides purge_dependency_proxy_cache", async () => {
|
|
119
|
+
const names = await getToolNames(server);
|
|
120
|
+
assert.ok(!names.includes("purge_dependency_proxy_cache"), "modify mode: purge_dependency_proxy_cache should be absent");
|
|
121
|
+
});
|
|
122
|
+
test("rejects direct delete tool calls", async () => {
|
|
123
|
+
const client = await connectClient(server);
|
|
124
|
+
try {
|
|
125
|
+
await assert.rejects(() => client.callTool("delete_issue", { project_id: "1", issue_iid: 1 }), (error) => error.message.includes("not allowed in modify mode"), "delete_issue should be rejected in modify mode");
|
|
126
|
+
await assert.rejects(() => client.callTool("purge_dependency_proxy_cache", { group_id: "my-group" }), (error) => error.message.includes("not allowed in modify mode"), "purge_dependency_proxy_cache should be rejected in modify mode");
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await client.disconnect();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
test("rejects delete mutations via execute_graphql", async () => {
|
|
133
|
+
const client = await connectClient(server);
|
|
134
|
+
try {
|
|
135
|
+
await assert.rejects(() => client.callTool("execute_graphql", {
|
|
136
|
+
query: 'mutation { issueDelete(input: { projectPath: "g/p", iid: "1" }) { errors } }',
|
|
137
|
+
}), (error) => error.message.includes("delete mutations in modify mode"), "delete mutation should be rejected in modify mode");
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await client.disconnect();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
test("does not apply the modify-mode guard to non-delete mutations", async () => {
|
|
144
|
+
const client = await connectClient(server);
|
|
145
|
+
try {
|
|
146
|
+
// The mock GitLab server has no GraphQL endpoint, so the call may fail
|
|
147
|
+
// downstream — but it must not fail with the modify-mode guard error.
|
|
148
|
+
await client.callTool("execute_graphql", {
|
|
149
|
+
query: "mutation { issueSetSeverity(input: { severity: HIGH }) { errors } }",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
assert.ok(error instanceof Error);
|
|
154
|
+
assert.ok(!error.message.includes("modify mode"), `non-delete mutation should pass the guard, got: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await client.disconnect();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
|
|
162
|
+
let server;
|
|
163
|
+
const servers = [];
|
|
164
|
+
before(async () => {
|
|
165
|
+
server = await launchMcpServer({ GITLAB_PERMISSION_MODE: "readonly" });
|
|
166
|
+
servers.push(server);
|
|
167
|
+
});
|
|
168
|
+
after(() => cleanupServers(servers));
|
|
169
|
+
test("exposes only read-only tools", async () => {
|
|
170
|
+
const names = await getToolNames(server);
|
|
171
|
+
for (const name of READ_SAMPLE_TOOLS) {
|
|
172
|
+
assert.ok(names.includes(name), `readonly mode: expected "${name}" to be present`);
|
|
173
|
+
}
|
|
174
|
+
for (const name of [...MODIFY_SAMPLE_TOOLS, ...DELETE_SAMPLE_TOOLS]) {
|
|
175
|
+
assert.ok(!names.includes(name), `readonly mode: expected "${name}" to be absent`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
test("rejects write tool calls", async () => {
|
|
179
|
+
const client = await connectClient(server);
|
|
180
|
+
try {
|
|
181
|
+
await assert.rejects(() => client.callTool("create_issue", { project_id: "1", title: "t" }), (error) => error.message.includes("not allowed in read-only mode"), "create_issue should be rejected in readonly mode");
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await client.disconnect();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe("legacy GITLAB_READ_ONLY_MODE interaction", () => {
|
|
189
|
+
const servers = [];
|
|
190
|
+
after(() => cleanupServers(servers));
|
|
191
|
+
test("GITLAB_READ_ONLY_MODE=true still behaves as readonly", async () => {
|
|
192
|
+
const server = await launchMcpServer({ GITLAB_READ_ONLY_MODE: "true" });
|
|
193
|
+
servers.push(server);
|
|
194
|
+
const names = await getToolNames(server);
|
|
195
|
+
assert.ok(names.includes("list_issues"), "read tool should be present");
|
|
196
|
+
assert.ok(!names.includes("create_issue"), "write tool should be absent");
|
|
197
|
+
assert.ok(!names.includes("delete_issue"), "delete tool should be absent");
|
|
198
|
+
});
|
|
199
|
+
test("GITLAB_READ_ONLY_MODE=true wins over GITLAB_PERMISSION_MODE=full", async () => {
|
|
200
|
+
const server = await launchMcpServer({
|
|
201
|
+
GITLAB_READ_ONLY_MODE: "true",
|
|
202
|
+
GITLAB_PERMISSION_MODE: "full",
|
|
203
|
+
});
|
|
204
|
+
servers.push(server);
|
|
205
|
+
const names = await getToolNames(server);
|
|
206
|
+
assert.ok(!names.includes("create_issue"), "legacy readonly should take precedence");
|
|
207
|
+
assert.ok(!names.includes("delete_issue"), "legacy readonly should take precedence");
|
|
208
|
+
});
|
|
209
|
+
test("rejects invalid GITLAB_PERMISSION_MODE even when GITLAB_READ_ONLY_MODE=true", async () => {
|
|
210
|
+
await assert.rejects(launchMcpServer({
|
|
211
|
+
GITLAB_READ_ONLY_MODE: "true",
|
|
212
|
+
GITLAB_PERMISSION_MODE: "write",
|
|
213
|
+
}), (error) => error.message.includes("Server process exited with code 1"), "invalid permission mode should fail startup before legacy override applies");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -10,10 +10,29 @@ function buildProtectedBranch(overrides = {}) {
|
|
|
10
10
|
id: 1,
|
|
11
11
|
name: TEST_BRANCH,
|
|
12
12
|
push_access_levels: [
|
|
13
|
-
{
|
|
13
|
+
{
|
|
14
|
+
access_level: 30,
|
|
15
|
+
access_level_description: "Developers + Maintainers",
|
|
16
|
+
user_id: null,
|
|
17
|
+
group_id: null,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
merge_access_levels: [
|
|
21
|
+
{
|
|
22
|
+
access_level: 40,
|
|
23
|
+
access_level_description: "Maintainers",
|
|
24
|
+
user_id: null,
|
|
25
|
+
group_id: null,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
unprotect_access_levels: [
|
|
29
|
+
{
|
|
30
|
+
access_level: 60,
|
|
31
|
+
access_level_description: "Administrators",
|
|
32
|
+
user_id: null,
|
|
33
|
+
group_id: null,
|
|
34
|
+
},
|
|
14
35
|
],
|
|
15
|
-
merge_access_levels: [{ access_level: 40, access_level_description: "Maintainers" }],
|
|
16
|
-
unprotect_access_levels: [{ access_level: 60, access_level_description: "Administrators" }],
|
|
17
36
|
allow_force_push: false,
|
|
18
37
|
code_owner_approval_required: false,
|
|
19
38
|
...overrides,
|
|
@@ -71,7 +90,7 @@ describe("protected branch tools", () => {
|
|
|
71
90
|
let mockGitLab;
|
|
72
91
|
let mockGitLabUrl;
|
|
73
92
|
before(async () => {
|
|
74
|
-
const mockPort = await findMockServerPort(
|
|
93
|
+
const mockPort = await findMockServerPort();
|
|
75
94
|
mockGitLab = new MockGitLabServer({
|
|
76
95
|
port: mockPort,
|
|
77
96
|
validTokens: [MOCK_TOKEN],
|