@zereight/mcp-gitlab 2.1.0 → 2.1.2
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/build/auth-retry.js +81 -0
- package/build/index.js +239 -10
- package/build/oauth.js +4 -4
- package/build/schemas.js +104 -10
- package/build/test/mcp-oauth-tests.js +49 -0
- package/build/test/schema-tests.js +261 -3
- package/build/test/test-auth-retry.js +188 -0
- package/build/test/test-search-code.js +7 -4
- package/build/test/test-token-optimizations.js +3 -0
- package/build/test/test-toolset-filtering.js +9 -3
- package/build/tools/registry.js +124 -1
- package/package.json +4 -4
|
@@ -139,6 +139,55 @@ describe("MCP OAuth — Discovery Endpoints", () => {
|
|
|
139
139
|
assert.ok(body.resource, "Should have resource field");
|
|
140
140
|
console.log(" ✓ Protected resource metadata returned");
|
|
141
141
|
});
|
|
142
|
+
test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
|
|
143
|
+
const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 25);
|
|
144
|
+
const prefixedMockGitLab = new MockGitLabServer({
|
|
145
|
+
port: mockPort,
|
|
146
|
+
validTokens: [MOCK_OAUTH_TOKEN],
|
|
147
|
+
});
|
|
148
|
+
await prefixedMockGitLab.start();
|
|
149
|
+
const scopedServers = [];
|
|
150
|
+
try {
|
|
151
|
+
const mockGitLabUrl = prefixedMockGitLab.getUrl();
|
|
152
|
+
addOAuthEndpoints(prefixedMockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
|
|
153
|
+
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 25);
|
|
154
|
+
const mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
155
|
+
const issuerPath = "/gitlab-mcp";
|
|
156
|
+
const prefixedServerUrl = `${mcpBaseUrl}${issuerPath}`;
|
|
157
|
+
const server = await launchServer({
|
|
158
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
159
|
+
port: mcpPort,
|
|
160
|
+
timeout: 5000,
|
|
161
|
+
env: {
|
|
162
|
+
STREAMABLE_HTTP: "true",
|
|
163
|
+
GITLAB_MCP_OAUTH: "true",
|
|
164
|
+
GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
|
|
165
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
166
|
+
MCP_SERVER_URL: prefixedServerUrl,
|
|
167
|
+
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
scopedServers.push(server);
|
|
171
|
+
const authMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server${issuerPath}`);
|
|
172
|
+
assert.strictEqual(authMetadataRes.status, 200, "Should return 200");
|
|
173
|
+
const authMetadata = (await authMetadataRes.json());
|
|
174
|
+
assert.strictEqual(authMetadata.issuer, prefixedServerUrl);
|
|
175
|
+
assert.strictEqual(authMetadata.authorization_endpoint, `${prefixedServerUrl}/authorize`);
|
|
176
|
+
assert.strictEqual(authMetadata.token_endpoint, `${prefixedServerUrl}/token`);
|
|
177
|
+
assert.strictEqual(authMetadata.registration_endpoint, `${prefixedServerUrl}/register`);
|
|
178
|
+
assert.strictEqual(authMetadata.revocation_endpoint, `${prefixedServerUrl}/revoke`);
|
|
179
|
+
const resourceMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource${issuerPath}/mcp`);
|
|
180
|
+
assert.strictEqual(resourceMetadataRes.status, 200, "Should return 200");
|
|
181
|
+
const resourceMetadata = (await resourceMetadataRes.json());
|
|
182
|
+
assert.strictEqual(resourceMetadata.resource, prefixedServerUrl);
|
|
183
|
+
assert.deepStrictEqual(resourceMetadata.authorization_servers, [prefixedServerUrl]);
|
|
184
|
+
console.log(" ✓ Path-prefixed discovery metadata returned at RFC well-known URLs");
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
cleanupServers(scopedServers);
|
|
188
|
+
await prefixedMockGitLab.stop();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
142
191
|
});
|
|
143
192
|
// ---------------------------------------------------------------------------
|
|
144
193
|
// Test suite: /mcp auth enforcement
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env ts-node
|
|
2
|
-
import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema } from '../schemas.js';
|
|
2
|
+
import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, GitLabTreeItemSchema } from '../schemas.js';
|
|
3
3
|
function runGetFileContentsSchemaTests() {
|
|
4
4
|
console.log('🧪 Testing GetFileContentsSchema...');
|
|
5
5
|
const cases = [
|
|
@@ -371,13 +371,271 @@ function runCreateIssueNoteSchemaTests() {
|
|
|
371
371
|
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
372
372
|
return { passed, failed };
|
|
373
373
|
}
|
|
374
|
+
function runEmojiReactionSchemaTests() {
|
|
375
|
+
console.log('\n🧪 Testing Emoji Reaction Schemas...');
|
|
376
|
+
const cases = [
|
|
377
|
+
{ name: 'schema:create_mr_emoji:valid', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42', name: 'thumbsup' }, expected: { project_id: 'my/project', merge_request_iid: '42', name: 'thumbsup' } },
|
|
378
|
+
{ name: 'schema:create_mr_emoji:numeric-iid-coerced', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: 42, name: 'rocket' }, expected: { merge_request_iid: '42', name: 'rocket' } },
|
|
379
|
+
{ name: 'schema:create_mr_emoji:reject-missing-name', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42' }, shouldFail: true },
|
|
380
|
+
{ name: 'schema:delete_mr_emoji:valid', schema: DeleteMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42', award_id: '123' }, expected: { merge_request_iid: '42', award_id: '123' } },
|
|
381
|
+
{ name: 'schema:create_issue_emoji:valid', schema: CreateIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10', name: 'thumbsdown' }, expected: { issue_iid: '10', name: 'thumbsdown' } },
|
|
382
|
+
{ name: 'schema:create_issue_emoji:reject-missing-name', schema: CreateIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10' }, shouldFail: true },
|
|
383
|
+
{ name: 'schema:delete_issue_emoji:valid', schema: DeleteIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10', award_id: '99' }, expected: { issue_iid: '10', award_id: '99' } },
|
|
384
|
+
{ name: 'schema:create_work_item_emoji:valid', schema: CreateWorkItemEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, name: 'rocket' }, expected: { iid: 5, name: 'rocket' } },
|
|
385
|
+
{ name: 'schema:create_work_item_emoji:reject-missing-name', schema: CreateWorkItemEmojiReactionSchema, input: { project_id: 'my/project', iid: 5 }, shouldFail: true },
|
|
386
|
+
{ name: 'schema:create_work_item_note_emoji:valid', schema: CreateWorkItemNoteEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, note_id: 'gid://gitlab/Note/123', name: 'thumbsup' }, expected: { iid: 5, note_id: 'gid://gitlab/Note/123', name: 'thumbsup' } },
|
|
387
|
+
{ name: 'schema:create_work_item_note_emoji:reject-missing-note-id', schema: CreateWorkItemNoteEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, name: 'thumbsup' }, shouldFail: true },
|
|
388
|
+
];
|
|
389
|
+
let passed = 0;
|
|
390
|
+
let failed = 0;
|
|
391
|
+
cases.forEach(testCase => {
|
|
392
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
393
|
+
const parsed = testCase.schema.safeParse(testCase.input);
|
|
394
|
+
if (testCase.shouldFail) {
|
|
395
|
+
if (parsed.success) {
|
|
396
|
+
result.error = 'Expected schema validation to fail';
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
result.status = 'passed';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else if (parsed.success) {
|
|
403
|
+
const expected = testCase.expected || {};
|
|
404
|
+
const matches = Object.entries(expected).every(([key, value]) => {
|
|
405
|
+
return parsed.data[key] === value;
|
|
406
|
+
});
|
|
407
|
+
if (matches) {
|
|
408
|
+
result.status = 'passed';
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
416
|
+
}
|
|
417
|
+
if (result.status === 'passed') {
|
|
418
|
+
passed++;
|
|
419
|
+
console.log(`✅ ${result.name}`);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
failed++;
|
|
423
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
427
|
+
return { passed, failed };
|
|
428
|
+
}
|
|
429
|
+
function runGitLabRepositorySchemaTests() {
|
|
430
|
+
console.log('🧪 Testing GitLabRepositorySchema...');
|
|
431
|
+
const baseProject = {
|
|
432
|
+
id: '42',
|
|
433
|
+
name: 'my-project',
|
|
434
|
+
path_with_namespace: 'group/my-project',
|
|
435
|
+
description: null,
|
|
436
|
+
};
|
|
437
|
+
const cases = [
|
|
438
|
+
{
|
|
439
|
+
name: 'schema:repository:default_branch-null',
|
|
440
|
+
input: { ...baseProject, default_branch: null },
|
|
441
|
+
shouldFail: false,
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'schema:repository:default_branch-string',
|
|
445
|
+
input: { ...baseProject, default_branch: 'main' },
|
|
446
|
+
shouldFail: false,
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: 'schema:repository:default_branch-omitted',
|
|
450
|
+
input: { ...baseProject },
|
|
451
|
+
shouldFail: false,
|
|
452
|
+
},
|
|
453
|
+
];
|
|
454
|
+
let passed = 0;
|
|
455
|
+
let failed = 0;
|
|
456
|
+
cases.forEach(testCase => {
|
|
457
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
458
|
+
const parsed = GitLabRepositorySchema.safeParse(testCase.input);
|
|
459
|
+
if (testCase.shouldFail) {
|
|
460
|
+
if (parsed.success) {
|
|
461
|
+
result.error = 'Expected schema validation to fail';
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
result.status = 'passed';
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else if (parsed.success) {
|
|
468
|
+
result.status = 'passed';
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
472
|
+
}
|
|
473
|
+
if (result.status === 'passed') {
|
|
474
|
+
passed++;
|
|
475
|
+
console.log(`✅ ${result.name}`);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
failed++;
|
|
479
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
483
|
+
return { passed, failed };
|
|
484
|
+
}
|
|
485
|
+
function runLabelsCoercionSchemaTests() {
|
|
486
|
+
console.log('\n=== Labels Coercion Schema Tests ===');
|
|
487
|
+
const cases = [
|
|
488
|
+
{
|
|
489
|
+
name: 'schema:create_issue:labels-native-array',
|
|
490
|
+
schema: CreateIssueSchema,
|
|
491
|
+
input: { project_id: 'my/project', title: 'Test', labels: ['bug', 'enhancement'] },
|
|
492
|
+
expectedLabels: ['bug', 'enhancement'],
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: 'schema:create_issue:labels-stringified-array',
|
|
496
|
+
schema: CreateIssueSchema,
|
|
497
|
+
input: { project_id: 'my/project', title: 'Test', labels: '["bug","enhancement"]' },
|
|
498
|
+
expectedLabels: ['bug', 'enhancement'],
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
name: 'schema:create_issue:labels-omitted',
|
|
502
|
+
schema: CreateIssueSchema,
|
|
503
|
+
input: { project_id: 'my/project', title: 'Test' },
|
|
504
|
+
expectedLabels: undefined,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: 'schema:list_issues:labels-native-array',
|
|
508
|
+
schema: ListIssuesSchema,
|
|
509
|
+
input: { project_id: 'my/project', labels: ['bug'] },
|
|
510
|
+
expectedLabels: ['bug'],
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: 'schema:list_issues:labels-stringified-array',
|
|
514
|
+
schema: ListIssuesSchema,
|
|
515
|
+
input: { project_id: 'my/project', labels: '["bug","enhancement"]' },
|
|
516
|
+
expectedLabels: ['bug', 'enhancement'],
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: 'schema:list_merge_requests:labels-native-array',
|
|
520
|
+
schema: ListMergeRequestsSchema,
|
|
521
|
+
input: { labels: ['feature'] },
|
|
522
|
+
expectedLabels: ['feature'],
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
name: 'schema:list_merge_requests:labels-stringified-array',
|
|
526
|
+
schema: ListMergeRequestsSchema,
|
|
527
|
+
input: { labels: '["feature","bugfix"]' },
|
|
528
|
+
expectedLabels: ['feature', 'bugfix'],
|
|
529
|
+
},
|
|
530
|
+
];
|
|
531
|
+
let passed = 0;
|
|
532
|
+
let failed = 0;
|
|
533
|
+
function checkLabelResult(testCase, parsed) {
|
|
534
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
535
|
+
if (!parsed.success) {
|
|
536
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
537
|
+
return result;
|
|
538
|
+
}
|
|
539
|
+
const actualLabels = parsed.data['labels'];
|
|
540
|
+
if (testCase.expectedLabels === undefined) {
|
|
541
|
+
result.status = actualLabels === undefined ? 'passed' : 'failed';
|
|
542
|
+
if (actualLabels !== undefined) {
|
|
543
|
+
result.error = `Expected labels to be undefined, got ${JSON.stringify(actualLabels)}`;
|
|
544
|
+
}
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
const match = Array.isArray(actualLabels) &&
|
|
548
|
+
actualLabels.length === testCase.expectedLabels.length &&
|
|
549
|
+
testCase.expectedLabels.every((v, i) => actualLabels[i] === v);
|
|
550
|
+
result.status = match ? 'passed' : 'failed';
|
|
551
|
+
if (!match) {
|
|
552
|
+
result.error = `Expected ${JSON.stringify(testCase.expectedLabels)}, got ${JSON.stringify(actualLabels)}`;
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
cases.forEach(testCase => {
|
|
557
|
+
const parsed = testCase.schema.safeParse(testCase.input);
|
|
558
|
+
let result;
|
|
559
|
+
if (testCase.shouldFail) {
|
|
560
|
+
result = { name: testCase.name, status: parsed.success ? 'failed' : 'passed' };
|
|
561
|
+
if (parsed.success)
|
|
562
|
+
result.error = 'Expected schema validation to fail';
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
result = checkLabelResult(testCase, parsed);
|
|
566
|
+
}
|
|
567
|
+
if (result.status === 'passed') {
|
|
568
|
+
passed++;
|
|
569
|
+
console.log(`✅ ${result.name}`);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
failed++;
|
|
573
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
577
|
+
return { passed, failed };
|
|
578
|
+
}
|
|
579
|
+
function runGitLabTreeItemSchemaTests() {
|
|
580
|
+
console.log('\n=== GitLabTreeItem Schema Tests ===');
|
|
581
|
+
const cases = [
|
|
582
|
+
{
|
|
583
|
+
name: 'schema:tree_item:type-blob',
|
|
584
|
+
input: { id: 'abc123', name: 'README.md', type: 'blob', path: 'README.md', mode: '100644' },
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
name: 'schema:tree_item:type-tree',
|
|
588
|
+
input: { id: 'def456', name: 'src', type: 'tree', path: 'src', mode: '040000' },
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: 'schema:tree_item:type-commit-submodule',
|
|
592
|
+
input: { id: 'ghi789', name: 'vendor', type: 'commit', path: 'vendor', mode: '160000' },
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
name: 'schema:tree_item:reject-unknown-type',
|
|
596
|
+
input: { id: 'xyz', name: 'foo', type: 'symlink', path: 'foo', mode: '120000' },
|
|
597
|
+
shouldFail: true,
|
|
598
|
+
},
|
|
599
|
+
];
|
|
600
|
+
let passed = 0;
|
|
601
|
+
let failed = 0;
|
|
602
|
+
cases.forEach(testCase => {
|
|
603
|
+
const result = { name: testCase.name, status: 'failed' };
|
|
604
|
+
const parsed = GitLabTreeItemSchema.safeParse(testCase.input);
|
|
605
|
+
if (testCase.shouldFail) {
|
|
606
|
+
result.status = parsed.success ? 'failed' : 'passed';
|
|
607
|
+
if (parsed.success)
|
|
608
|
+
result.error = 'Expected schema validation to fail';
|
|
609
|
+
}
|
|
610
|
+
else if (parsed.success) {
|
|
611
|
+
result.status = 'passed';
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
result.error = parsed.error?.message || 'Schema validation failed';
|
|
615
|
+
}
|
|
616
|
+
if (result.status === 'passed') {
|
|
617
|
+
passed++;
|
|
618
|
+
console.log(`✅ ${result.name}`);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
failed++;
|
|
622
|
+
console.log(`❌ ${result.name}: ${result.error}`);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
|
626
|
+
return { passed, failed };
|
|
627
|
+
}
|
|
374
628
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
375
629
|
const getFileContentsResult = runGetFileContentsSchemaTests();
|
|
376
630
|
const fileContentResult = runGitLabFileContentSchemaTests();
|
|
377
631
|
const createPipelineResult = runCreatePipelineSchemaTests();
|
|
378
632
|
const createIssueNoteResult = runCreateIssueNoteSchemaTests();
|
|
379
|
-
const
|
|
380
|
-
const
|
|
633
|
+
const emojiReactionResult = runEmojiReactionSchemaTests();
|
|
634
|
+
const repositorySchemaResult = runGitLabRepositorySchemaTests();
|
|
635
|
+
const labelsCoercionResult = runLabelsCoercionSchemaTests();
|
|
636
|
+
const treeItemResult = runGitLabTreeItemSchemaTests();
|
|
637
|
+
const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + treeItemResult.passed;
|
|
638
|
+
const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + treeItemResult.failed;
|
|
381
639
|
console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
|
|
382
640
|
if (totalFailed > 0) {
|
|
383
641
|
process.exit(1);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Retry Tests
|
|
3
|
+
* Unit tests for headersToPlainObject, isNonReplayableBody, and wrapWithAuthRetry.
|
|
4
|
+
*
|
|
5
|
+
* These are pure-function / DI-based tests — no env vars or external services needed.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test } from "node:test";
|
|
8
|
+
import assert from "node:assert";
|
|
9
|
+
import { Headers, Response } from "node-fetch";
|
|
10
|
+
import { headersToPlainObject, isNonReplayableBody, wrapWithAuthRetry, } from "../auth-retry.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function mockFetch(status) {
|
|
15
|
+
return (async () => new Response("", { status }));
|
|
16
|
+
}
|
|
17
|
+
function mockFetchThenRetry() {
|
|
18
|
+
let callCount = 0;
|
|
19
|
+
return (async () => {
|
|
20
|
+
callCount++;
|
|
21
|
+
return new Response("", { status: callCount === 1 ? 401 : 200 });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function makeConfig(overrides) {
|
|
25
|
+
return {
|
|
26
|
+
isOAuthEnabled: () => true,
|
|
27
|
+
refreshToken: async () => "new-token",
|
|
28
|
+
onTokenRefreshed: () => { },
|
|
29
|
+
buildAuthHeaders: () => ({ Authorization: "Bearer new-token" }),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// headersToPlainObject
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
describe("headersToPlainObject", () => {
|
|
37
|
+
test("null returns empty object", () => {
|
|
38
|
+
assert.deepStrictEqual(headersToPlainObject(null), {});
|
|
39
|
+
});
|
|
40
|
+
test("undefined returns empty object", () => {
|
|
41
|
+
assert.deepStrictEqual(headersToPlainObject(undefined), {});
|
|
42
|
+
});
|
|
43
|
+
test("plain object passed through", () => {
|
|
44
|
+
const obj = { "Content-Type": "application/json", Accept: "text/html" };
|
|
45
|
+
assert.deepStrictEqual(headersToPlainObject(obj), obj);
|
|
46
|
+
});
|
|
47
|
+
test("Headers instance normalized", () => {
|
|
48
|
+
const h = new Headers();
|
|
49
|
+
h.set("x-custom", "value1");
|
|
50
|
+
h.set("authorization", "Bearer tok");
|
|
51
|
+
const result = headersToPlainObject(h);
|
|
52
|
+
assert.strictEqual(result["x-custom"], "value1");
|
|
53
|
+
assert.strictEqual(result["authorization"], "Bearer tok");
|
|
54
|
+
});
|
|
55
|
+
test("array of tuples normalized", () => {
|
|
56
|
+
const arr = [
|
|
57
|
+
["x-foo", "bar"],
|
|
58
|
+
["x-baz", "qux"],
|
|
59
|
+
];
|
|
60
|
+
assert.deepStrictEqual(headersToPlainObject(arr), {
|
|
61
|
+
"x-foo": "bar",
|
|
62
|
+
"x-baz": "qux",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// isNonReplayableBody
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
describe("isNonReplayableBody", () => {
|
|
70
|
+
test("null returns false", () => {
|
|
71
|
+
assert.strictEqual(isNonReplayableBody(null), false);
|
|
72
|
+
});
|
|
73
|
+
test("undefined returns false", () => {
|
|
74
|
+
assert.strictEqual(isNonReplayableBody(undefined), false);
|
|
75
|
+
});
|
|
76
|
+
test("empty string returns false", () => {
|
|
77
|
+
assert.strictEqual(isNonReplayableBody(""), false);
|
|
78
|
+
});
|
|
79
|
+
test("plain string returns false", () => {
|
|
80
|
+
assert.strictEqual(isNonReplayableBody("hello"), false);
|
|
81
|
+
});
|
|
82
|
+
test("object with .pipe() returns true (stream-like)", () => {
|
|
83
|
+
assert.strictEqual(isNonReplayableBody({ pipe: () => { } }), true);
|
|
84
|
+
});
|
|
85
|
+
test("object with .read() returns true (stream-like)", () => {
|
|
86
|
+
assert.strictEqual(isNonReplayableBody({ read: () => { } }), true);
|
|
87
|
+
});
|
|
88
|
+
test("object with .getBuffer() and .getBoundary() returns true (FormData-like)", () => {
|
|
89
|
+
assert.strictEqual(isNonReplayableBody({ getBuffer: () => { }, getBoundary: () => { } }), true);
|
|
90
|
+
});
|
|
91
|
+
test("object with only .getBuffer() (no .getBoundary()) returns false", () => {
|
|
92
|
+
assert.strictEqual(isNonReplayableBody({ getBuffer: () => { } }), false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// wrapWithAuthRetry
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
describe("wrapWithAuthRetry", () => {
|
|
99
|
+
test("non-401 response passes through unchanged", async () => {
|
|
100
|
+
const wrapped = wrapWithAuthRetry(mockFetch(200), makeConfig());
|
|
101
|
+
const res = await wrapped("http://example.com");
|
|
102
|
+
assert.strictEqual(res.status, 200);
|
|
103
|
+
});
|
|
104
|
+
test("401 when OAuth disabled passes through unchanged", async () => {
|
|
105
|
+
const config = makeConfig({ isOAuthEnabled: () => false });
|
|
106
|
+
const wrapped = wrapWithAuthRetry(mockFetch(401), config);
|
|
107
|
+
const res = await wrapped("http://example.com");
|
|
108
|
+
assert.strictEqual(res.status, 401);
|
|
109
|
+
});
|
|
110
|
+
test("401 with OAuth enabled triggers refresh and retry", async () => {
|
|
111
|
+
let refreshCalled = false;
|
|
112
|
+
let tokenSet = null;
|
|
113
|
+
const config = makeConfig({
|
|
114
|
+
refreshToken: async () => {
|
|
115
|
+
refreshCalled = true;
|
|
116
|
+
return "refreshed-token";
|
|
117
|
+
},
|
|
118
|
+
onTokenRefreshed: (token) => {
|
|
119
|
+
tokenSet = token;
|
|
120
|
+
},
|
|
121
|
+
buildAuthHeaders: () => ({ Authorization: "Bearer refreshed-token" }),
|
|
122
|
+
});
|
|
123
|
+
const base = mockFetchThenRetry();
|
|
124
|
+
const wrapped = wrapWithAuthRetry(base, config);
|
|
125
|
+
const res = await wrapped("http://example.com");
|
|
126
|
+
assert.strictEqual(res.status, 200);
|
|
127
|
+
assert.strictEqual(refreshCalled, true);
|
|
128
|
+
assert.strictEqual(tokenSet, "refreshed-token");
|
|
129
|
+
});
|
|
130
|
+
test("401 with non-replayable body skips retry", async () => {
|
|
131
|
+
let refreshCalled = false;
|
|
132
|
+
const config = makeConfig({
|
|
133
|
+
refreshToken: async () => {
|
|
134
|
+
refreshCalled = true;
|
|
135
|
+
return "tok";
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const wrapped = wrapWithAuthRetry(mockFetch(401), config);
|
|
139
|
+
const res = await wrapped("http://example.com", {
|
|
140
|
+
body: { pipe: () => { } }, // stream-like
|
|
141
|
+
});
|
|
142
|
+
assert.strictEqual(res.status, 401);
|
|
143
|
+
assert.strictEqual(refreshCalled, false);
|
|
144
|
+
});
|
|
145
|
+
test("concurrent 401s only trigger one refresh (stampede test)", async () => {
|
|
146
|
+
let refreshCount = 0;
|
|
147
|
+
let resolveRefresh = () => { };
|
|
148
|
+
const config = makeConfig({
|
|
149
|
+
refreshToken: () => {
|
|
150
|
+
refreshCount++;
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
resolveRefresh = resolve;
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
buildAuthHeaders: () => ({ Authorization: "Bearer stamped" }),
|
|
156
|
+
});
|
|
157
|
+
// Each call returns 401 first, then 200 on retry
|
|
158
|
+
let callCount = 0;
|
|
159
|
+
const base = (async () => {
|
|
160
|
+
callCount++;
|
|
161
|
+
// First two calls are the initial requests (both 401)
|
|
162
|
+
// Next two are the retries (both 200)
|
|
163
|
+
return new Response("", { status: callCount <= 2 ? 401 : 200 });
|
|
164
|
+
});
|
|
165
|
+
const wrapped = wrapWithAuthRetry(base, config);
|
|
166
|
+
// Fire two concurrent requests
|
|
167
|
+
const p1 = wrapped("http://example.com/a");
|
|
168
|
+
const p2 = wrapped("http://example.com/b");
|
|
169
|
+
// Wait a tick for both to hit the refresh path
|
|
170
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
171
|
+
// Resolve the single pending refresh
|
|
172
|
+
resolveRefresh("stamped-token");
|
|
173
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
174
|
+
assert.strictEqual(r1.status, 200);
|
|
175
|
+
assert.strictEqual(r2.status, 200);
|
|
176
|
+
assert.strictEqual(refreshCount, 1, "refresh should be called exactly once");
|
|
177
|
+
});
|
|
178
|
+
test("token refresh failure returns original 401 response", async () => {
|
|
179
|
+
const config = makeConfig({
|
|
180
|
+
refreshToken: async () => {
|
|
181
|
+
throw new Error("refresh exploded");
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const wrapped = wrapWithAuthRetry(mockFetch(401), config);
|
|
185
|
+
const res = await wrapped("http://example.com");
|
|
186
|
+
assert.strictEqual(res.status, 401);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -71,8 +71,8 @@ describe("Search Code Tools", () => {
|
|
|
71
71
|
if (mockGitLab)
|
|
72
72
|
await mockGitLab.stop();
|
|
73
73
|
});
|
|
74
|
-
// ---- 1. search toolset exposes exactly
|
|
75
|
-
describe("search toolset exposes exactly
|
|
74
|
+
// ---- 1. search toolset exposes exactly 4 tools ----
|
|
75
|
+
describe("search toolset exposes exactly 4 tools", () => {
|
|
76
76
|
let server;
|
|
77
77
|
let tools;
|
|
78
78
|
before(async () => {
|
|
@@ -83,8 +83,8 @@ describe("Search Code Tools", () => {
|
|
|
83
83
|
tools = await getToolNames(`http://${HOST}:${port}/mcp`);
|
|
84
84
|
});
|
|
85
85
|
after(() => cleanupServers([server]));
|
|
86
|
-
test("returns exactly
|
|
87
|
-
assert.strictEqual(tools.length,
|
|
86
|
+
test("returns exactly 4 tools", () => {
|
|
87
|
+
assert.strictEqual(tools.length, 4, `Expected 4 tools but got ${tools.length}: ${tools.join(", ")}`);
|
|
88
88
|
});
|
|
89
89
|
test("includes search_code", () => {
|
|
90
90
|
assert.ok(tools.includes("search_code"), "Expected search_code to be present");
|
|
@@ -95,6 +95,9 @@ describe("Search Code Tools", () => {
|
|
|
95
95
|
test("includes search_group_code", () => {
|
|
96
96
|
assert.ok(tools.includes("search_group_code"), "Expected search_group_code to be present");
|
|
97
97
|
});
|
|
98
|
+
test("includes discover_tools", () => {
|
|
99
|
+
assert.ok(tools.includes("discover_tools"), "Expected discover_tools to be present");
|
|
100
|
+
});
|
|
98
101
|
});
|
|
99
102
|
// ---- 2. search_code returns blob results ----
|
|
100
103
|
describe("search_code returns blob results", () => {
|
|
@@ -392,6 +392,9 @@ describe("Policy Edge Cases", { concurrency: 1 }, () => {
|
|
|
392
392
|
"update_issue", "delete_issue", "create_issue_note", "update_issue_note",
|
|
393
393
|
"list_issue_links", "list_issue_discussions", "get_issue_link",
|
|
394
394
|
"create_issue_link", "delete_issue_link", "create_note",
|
|
395
|
+
"list_issue_emoji_reactions", "list_issue_note_emoji_reactions",
|
|
396
|
+
"create_issue_emoji_reaction", "delete_issue_emoji_reaction",
|
|
397
|
+
"create_issue_note_emoji_reaction", "delete_issue_note_emoji_reaction",
|
|
395
398
|
].join(",");
|
|
396
399
|
const server = await launchMcp(mockGitLabUrl, {
|
|
397
400
|
GITLAB_TOOLSETS: "issues",
|
|
@@ -16,8 +16,8 @@ const MOCK_PORT_BASE = 9200;
|
|
|
16
16
|
const MCP_PORT_BASE = 3200;
|
|
17
17
|
// Known tool counts per toolset (from TOOLSET_DEFINITIONS)
|
|
18
18
|
const TOOLSET_TOOL_COUNTS = {
|
|
19
|
-
merge_requests:
|
|
20
|
-
issues:
|
|
19
|
+
merge_requests: 40,
|
|
20
|
+
issues: 20,
|
|
21
21
|
repositories: 7,
|
|
22
22
|
branches: 4,
|
|
23
23
|
projects: 8,
|
|
@@ -28,7 +28,7 @@ const TOOLSET_TOOL_COUNTS = {
|
|
|
28
28
|
releases: 7,
|
|
29
29
|
users: 5,
|
|
30
30
|
search: 3,
|
|
31
|
-
workitems:
|
|
31
|
+
workitems: 18,
|
|
32
32
|
webhooks: 3,
|
|
33
33
|
};
|
|
34
34
|
const DEFAULT_TOOLSETS = [
|
|
@@ -286,6 +286,8 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
|
|
|
286
286
|
"list_issue_links",
|
|
287
287
|
"list_issue_discussions",
|
|
288
288
|
"get_issue_link",
|
|
289
|
+
"list_issue_emoji_reactions",
|
|
290
|
+
"list_issue_note_emoji_reactions",
|
|
289
291
|
];
|
|
290
292
|
const writeIssueTools = [
|
|
291
293
|
"create_issue",
|
|
@@ -296,6 +298,10 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
|
|
|
296
298
|
"create_issue_link",
|
|
297
299
|
"delete_issue_link",
|
|
298
300
|
"create_note",
|
|
301
|
+
"create_issue_emoji_reaction",
|
|
302
|
+
"delete_issue_emoji_reaction",
|
|
303
|
+
"create_issue_note_emoji_reaction",
|
|
304
|
+
"delete_issue_note_emoji_reaction",
|
|
299
305
|
];
|
|
300
306
|
before(async () => {
|
|
301
307
|
const port = await nextMcpPort();
|