@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.
@@ -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 totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed;
380
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed;
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 3 tools ----
75
- describe("search toolset exposes exactly 3 tools", () => {
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 3 tools", () => {
87
- assert.strictEqual(tools.length, 3, `Expected 3 tools but got ${tools.length}: ${tools.join(", ")}`);
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: 34,
20
- issues: 14,
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: 12,
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();