@zereight/mcp-gitlab 2.1.17 → 2.1.19

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.
@@ -79,7 +79,6 @@ function addOAuthEndpoints(mockGitLab, validToken, clientId, baseUrl) {
79
79
  // Test suite: Discovery endpoints
80
80
  // ---------------------------------------------------------------------------
81
81
  describe("MCP OAuth — Discovery Endpoints", () => {
82
- let mcpUrl;
83
82
  let mcpBaseUrl;
84
83
  let mockGitLab;
85
84
  let servers = [];
@@ -94,7 +93,6 @@ describe("MCP OAuth — Discovery Endpoints", () => {
94
93
  addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
95
94
  const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
96
95
  mcpBaseUrl = `http://${HOST}:${mcpPort}`;
97
- mcpUrl = `${mcpBaseUrl}/mcp`;
98
96
  const server = await launchServer({
99
97
  mode: TransportMode.STREAMABLE_HTTP,
100
98
  port: mcpPort,
@@ -285,13 +283,6 @@ describe("MCP OAuth — /mcp Auth Enforcement", () => {
285
283
  describe("MCP OAuth — BoundedClientCache", () => {
286
284
  // Access the internal class via a minimal provider (it's not exported directly)
287
285
  // by driving it through the public clientsStore API.
288
- function makeClient(id, redirectUri = "https://example.com/cb") {
289
- return {
290
- client_id: id,
291
- redirect_uris: [redirectUri],
292
- token_endpoint_auth_method: "none",
293
- };
294
- }
295
286
  async function buildCachingProvider() {
296
287
  // Spin up a DCR stub that returns a stable client_id from the request body.
297
288
  // The stub reads client_id from the incoming body (set by the SDK's register
@@ -380,7 +371,7 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
380
371
  test("verifyAccessToken throws on non-OK response", async () => {
381
372
  // Spin up a tiny local server that always returns 401
382
373
  const { createServer } = await import("node:http");
383
- const stub = createServer((req, res) => {
374
+ const stub = createServer((_req, res) => {
384
375
  res.writeHead(401);
385
376
  res.end(JSON.stringify({ error: "invalid_token" }));
386
377
  });
@@ -599,3 +590,195 @@ describe("MCP OAuth — Header Auth Fallback", () => {
599
590
  console.log(" ✓ No auth still returns 401");
600
591
  });
601
592
  });
593
+ // ---------------------------------------------------------------------------
594
+ // Test suite: Group Allowlist (unit tests — exchangeAuthorizationCode)
595
+ // ---------------------------------------------------------------------------
596
+ describe("MCP OAuth — Group Allowlist", () => {
597
+ let stubServer;
598
+ let stubUrl;
599
+ // Mutable state lets each test control the stub's response without
600
+ // spinning up a new server.
601
+ let groupsForPage = () => [];
602
+ let totalPages = 1;
603
+ before(async () => {
604
+ const { createServer } = await import("node:http");
605
+ stubServer = createServer((req, res) => {
606
+ const url = new URL(req.url, "http://127.0.0.1");
607
+ res.setHeader("Content-Type", "application/json");
608
+ // Token exchange — passthrough mode calls POST /oauth/token
609
+ if (req.method === "POST" && url.pathname === "/oauth/token") {
610
+ res.writeHead(200).end(JSON.stringify({
611
+ access_token: MOCK_OAUTH_TOKEN,
612
+ token_type: "bearer",
613
+ expires_in: 7200,
614
+ refresh_token: "mock-refresh",
615
+ scope: "api",
616
+ }));
617
+ return;
618
+ }
619
+ if (req.method === "GET" && url.pathname === "/api/v4/groups") {
620
+ const page = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
621
+ res
622
+ .writeHead(200, { "x-total-pages": String(totalPages) })
623
+ .end(JSON.stringify(groupsForPage(page)));
624
+ return;
625
+ }
626
+ res.writeHead(404).end("{}");
627
+ });
628
+ await new Promise(resolve => stubServer.listen(0, "127.0.0.1", resolve));
629
+ const addr = stubServer.address();
630
+ stubUrl = `http://127.0.0.1:${addr.port}`;
631
+ });
632
+ after(() => {
633
+ stubServer.close();
634
+ });
635
+ async function makeProvider(allowedGroups = undefined, callbackProxyEnabled = false) {
636
+ const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
637
+ return createGitLabOAuthProvider(stubUrl, "test-app-id", "GitLab MCP Server", false, undefined, // customScopes
638
+ allowedGroups, callbackProxyEnabled, callbackProxyEnabled ? "https://mcp.example.test/callback" : "" // callbackUrl
639
+ );
640
+ }
641
+ function exchange(provider) {
642
+ return provider.exchangeAuthorizationCode({}, "mock-auth-code");
643
+ }
644
+ test("no allowedGroups — groups API not called, tokens returned", async () => {
645
+ const provider = await makeProvider(undefined);
646
+ groupsForPage = () => []; // wrong groups — proves endpoint isn't called
647
+ totalPages = 1;
648
+ const tokens = await exchange(provider);
649
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
650
+ console.log(" ✓ No allowedGroups: token issued without group check");
651
+ });
652
+ test("user is direct member of the allowed group → token issued", async () => {
653
+ const provider = await makeProvider(["my-org"]);
654
+ groupsForPage = () => [{ full_path: "my-org" }];
655
+ totalPages = 1;
656
+ const tokens = await exchange(provider);
657
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
658
+ console.log(" ✓ Direct group member: token issued");
659
+ });
660
+ test("user in first-level subgroup of allowed group → token issued", async () => {
661
+ const provider = await makeProvider(["my-org"]);
662
+ groupsForPage = () => [{ full_path: "my-org/team-a" }];
663
+ totalPages = 1;
664
+ const tokens = await exchange(provider);
665
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
666
+ console.log(" ✓ First-level subgroup member: token issued");
667
+ });
668
+ test("user in nested subgroup → token issued", async () => {
669
+ const provider = await makeProvider(["my-org"]);
670
+ groupsForPage = () => [{ full_path: "my-org/team-a/squad-1" }];
671
+ totalPages = 1;
672
+ const tokens = await exchange(provider);
673
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
674
+ console.log(" ✓ Nested subgroup member: token issued");
675
+ });
676
+ test("user not in any matching group → token issuance denied", async () => {
677
+ const provider = await makeProvider(["my-org"]);
678
+ groupsForPage = () => [{ full_path: "other-org" }];
679
+ totalPages = 1;
680
+ await assert.rejects(() => exchange(provider), (err) => {
681
+ assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
682
+ return true;
683
+ });
684
+ console.log(" ✓ Non-member: token issuance denied");
685
+ });
686
+ test("prefix spoofing rejected — my-org2 does not match my-org", async () => {
687
+ const provider = await makeProvider(["my-org"]);
688
+ groupsForPage = () => [{ full_path: "my-org2" }];
689
+ totalPages = 1;
690
+ await assert.rejects(() => exchange(provider), (err) => {
691
+ assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
692
+ return true;
693
+ });
694
+ console.log(" ✓ my-org2 correctly rejected (not a sub-path of my-org)");
695
+ });
696
+ test("user matches second of multiple allowed groups → token issued", async () => {
697
+ const provider = await makeProvider(["team-x", "my-org"]);
698
+ groupsForPage = () => [{ full_path: "my-org/backend" }];
699
+ totalPages = 1;
700
+ const tokens = await exchange(provider);
701
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
702
+ console.log(" ✓ Match on second allowed group: token issued");
703
+ });
704
+ test("match found on page 2 of paginated groups response → token issued", async () => {
705
+ const provider = await makeProvider(["my-org"]);
706
+ totalPages = 2;
707
+ groupsForPage = page => page === 1 ? [{ full_path: "other-org" }] : [{ full_path: "my-org/team-b" }];
708
+ const tokens = await exchange(provider);
709
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
710
+ console.log(" ✓ Group found on page 2: token issued");
711
+ });
712
+ test("user not in group across all pages → token issuance denied", async () => {
713
+ const provider = await makeProvider(["my-org"]);
714
+ totalPages = 2;
715
+ groupsForPage = () => [{ full_path: "other-org" }];
716
+ await assert.rejects(() => exchange(provider), (err) => {
717
+ assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
718
+ return true;
719
+ });
720
+ console.log(" ✓ Non-member across all pages: token issuance denied");
721
+ });
722
+ test("callback-proxy exchange also enforces allowed groups", async () => {
723
+ const provider = await makeProvider(["my-org"], true);
724
+ groupsForPage = () => [{ full_path: "my-org/team-a" }];
725
+ totalPages = 1;
726
+ const proxyCode = "proxy-code-for-group-test";
727
+ const clientId = "test-client";
728
+ const redirectUri = "https://client.example.test/callback";
729
+ provider._storedTokens.set(proxyCode, {
730
+ tokens: {
731
+ access_token: MOCK_OAUTH_TOKEN,
732
+ token_type: "bearer",
733
+ expires_in: 7200,
734
+ refresh_token: "mock-refresh",
735
+ scope: "api",
736
+ },
737
+ clientId,
738
+ clientCodeChallenge: "",
739
+ clientRedirectUri: redirectUri,
740
+ createdAt: Date.now(),
741
+ });
742
+ const tokens = await provider.exchangeAuthorizationCode({
743
+ client_id: clientId,
744
+ }, proxyCode, undefined, redirectUri);
745
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
746
+ console.log(" ✓ Callback-proxy exchange enforces allowedGroups before issuing tokens");
747
+ });
748
+ test("callback-proxy exchange denies users outside allowed groups", async () => {
749
+ const provider = await makeProvider(["my-org"], true);
750
+ groupsForPage = () => [{ full_path: "other-org/team-a" }];
751
+ totalPages = 1;
752
+ const proxyCode = "proxy-code-for-group-deny-test";
753
+ const clientId = "test-client-deny";
754
+ const redirectUri = "https://client.example.test/callback";
755
+ provider._storedTokens.set(proxyCode, {
756
+ tokens: {
757
+ access_token: MOCK_OAUTH_TOKEN,
758
+ token_type: "bearer",
759
+ expires_in: 7200,
760
+ refresh_token: "mock-refresh",
761
+ scope: "api",
762
+ },
763
+ clientId,
764
+ clientCodeChallenge: "",
765
+ clientRedirectUri: redirectUri,
766
+ createdAt: Date.now(),
767
+ });
768
+ await assert.rejects(() => provider.exchangeAuthorizationCode({
769
+ client_id: clientId,
770
+ }, proxyCode, undefined, redirectUri), (err) => {
771
+ assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
772
+ return true;
773
+ });
774
+ console.log(" ✓ Callback-proxy exchange denies users outside allowedGroups");
775
+ });
776
+ test("matching is case-insensitive", async () => {
777
+ const provider = await makeProvider(["My-Org"]);
778
+ groupsForPage = () => [{ full_path: "my-org/team-a" }];
779
+ totalPages = 1;
780
+ const tokens = await exchange(provider);
781
+ assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
782
+ console.log(" ✓ Case-insensitive match: token issued");
783
+ });
784
+ });
@@ -10,7 +10,6 @@ import { CustomHeaderClient } from './clients/custom-header-client.js';
10
10
  // Test constants
11
11
  const MOCK_TOKEN = 'glpat-mock-token-12345';
12
12
  const MOCK_JOB_TOKEN = 'glcbt-mock-job-token-9876';
13
- const TEST_PROJECT_ID = '123';
14
13
  // Port ranges to avoid collisions
15
14
  const MOCK_GITLAB_PORT_BASE = 9000;
16
15
  const MOCK_GITLAB_PORT_OFFSET = 500; // Offset for timeout test suite
@@ -22,6 +21,26 @@ const TIMEOUT_BUFFER_MS = 1000; // Extra time beyond timeout to ensure expiratio
22
21
  const TIMEOUT_TEST_WAIT_MS = SESSION_TIMEOUT_SECONDS * 1000 + TIMEOUT_BUFFER_MS;
23
22
  const KEEPALIVE_INTERVAL_MS = 2000; // Must be less than SESSION_TIMEOUT_SECONDS
24
23
  const KEEPALIVE_REQUEST_COUNT = 3; // Number of keepalive requests to test
24
+ async function getMetrics(mcpUrl) {
25
+ const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics');
26
+ const response = await fetch(metricsUrl);
27
+ assert.strictEqual(response.status, 200, 'metrics endpoint should be available');
28
+ return (await response.json());
29
+ }
30
+ async function waitForSessionDecrease(mcpUrl, beforeTimeout, timeoutMs = 3000) {
31
+ const startedAt = Date.now();
32
+ let last = await getMetrics(mcpUrl);
33
+ while (Date.now() - startedAt < timeoutMs) {
34
+ if (last.activeSessions < beforeTimeout.activeSessions &&
35
+ last.authenticatedSessions < beforeTimeout.authenticatedSessions) {
36
+ return;
37
+ }
38
+ await new Promise(resolve => setTimeout(resolve, 100));
39
+ last = await getMetrics(mcpUrl);
40
+ }
41
+ assert.ok(last.activeSessions < beforeTimeout.activeSessions, `activeSessions should decrease after timeout (${last.activeSessions} !< ${beforeTimeout.activeSessions})`);
42
+ assert.ok(last.authenticatedSessions < beforeTimeout.authenticatedSessions, `authenticatedSessions should decrease after timeout (${last.authenticatedSessions} !< ${beforeTimeout.authenticatedSessions})`);
43
+ }
25
44
  console.log('🔐 Remote Authorization Test Suite');
26
45
  console.log('');
27
46
  describe('Remote Authorization - Basic Functionality', () => {
@@ -178,6 +197,7 @@ describe('Remote Authorization - Session Timeout', () => {
178
197
  test('session timeout expiration - inactivity expires auth', async () => {
179
198
  // Add a small delay to ensure server is ready/clean from previous test
180
199
  await new Promise(resolve => setTimeout(resolve, 1000));
200
+ const baseline = await getMetrics(mcpUrl);
181
201
  // Step 1: Connect WITH auth header to establish session
182
202
  const clientWithAuth = new CustomHeaderClient({
183
203
  'authorization': `Bearer ${MOCK_TOKEN}`
@@ -190,9 +210,14 @@ describe('Remote Authorization - Session Timeout', () => {
190
210
  const sessionId = clientWithAuth.getSessionId();
191
211
  assert.ok(sessionId, 'Session ID should exist');
192
212
  console.log(` ℹ️ Session ID: ${sessionId}`);
213
+ const beforeTimeout = await getMetrics(mcpUrl);
214
+ assert.strictEqual(beforeTimeout.activeSessions, baseline.activeSessions + 1, 'Session should occupy one additional active slot');
215
+ assert.strictEqual(beforeTimeout.authenticatedSessions, baseline.authenticatedSessions + 1, 'Session should add one authenticated session');
193
216
  // Step 2: Wait for timeout WITHOUT making any requests
194
217
  console.log(` ⏳ Waiting ${TIMEOUT_TEST_WAIT_MS / 1000}s for timeout without activity...`);
195
218
  await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
219
+ await waitForSessionDecrease(mcpUrl, beforeTimeout);
220
+ console.log(' ✓ Timeout closed transport and released active session slot');
196
221
  // Step 3: Try to make request WITHOUT auth header - should fail with 401
197
222
  try {
198
223
  const response = await fetch(mcpUrl, {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema, GitLabMarkdownUploadSchema, } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, CreateCommitStatusSchema, ListCommitStatusesSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, CreateIssueSchema, ListIssuesSchema, ListMergeRequestsSchema, ListLabelsSchema, GitLabMergeRequestSchema, GitLabTreeItemSchema, GetMergeRequestSchema, ListMergeRequestPipelinesSchema, GetRepositoryTreeSchema, GitLabUserFullSchema, GitLabMarkdownUploadSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -1385,6 +1385,106 @@ function runGitLabUserFullSchemaTests() {
1385
1385
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
1386
1386
  return { passed, failed };
1387
1387
  }
1388
+ function runGitLabDependencyProxySchemaTests() {
1389
+ console.log('🧪 Testing GitLabDependencyProxySchema...');
1390
+ const cases = [
1391
+ {
1392
+ name: 'schema:dependency_proxy:minimal',
1393
+ input: { enabled: true, blob_count: 0, total_size: '0' },
1394
+ shouldFail: false,
1395
+ },
1396
+ {
1397
+ name: 'schema:dependency_proxy:full',
1398
+ input: {
1399
+ enabled: true,
1400
+ blob_count: 42,
1401
+ total_size: '1234567890',
1402
+ image_prefix: 'gitlab.example.com:5050/my-group/dependency_proxy/containers',
1403
+ ttl_policy: { enabled: true, ttl: 90 },
1404
+ },
1405
+ shouldFail: false,
1406
+ },
1407
+ {
1408
+ name: 'schema:dependency_proxy:nulls-allowed',
1409
+ input: { enabled: false, blob_count: null, total_size: null, image_prefix: null, ttl_policy: null },
1410
+ shouldFail: false,
1411
+ },
1412
+ ];
1413
+ let passed = 0;
1414
+ let failed = 0;
1415
+ cases.forEach(testCase => {
1416
+ const result = { name: testCase.name, status: 'failed' };
1417
+ const parsed = GitLabDependencyProxySchema.safeParse(testCase.input);
1418
+ if (testCase.shouldFail) {
1419
+ result.status = parsed.success ? 'failed' : 'passed';
1420
+ if (parsed.success)
1421
+ result.error = 'Expected schema validation to fail';
1422
+ }
1423
+ else if (parsed.success) {
1424
+ result.status = 'passed';
1425
+ }
1426
+ else {
1427
+ result.error = parsed.error?.message || 'Schema validation failed';
1428
+ }
1429
+ if (result.status === 'passed') {
1430
+ passed++;
1431
+ console.log(`✅ ${result.name}`);
1432
+ }
1433
+ else {
1434
+ failed++;
1435
+ console.log(`❌ ${result.name}: ${result.error}`);
1436
+ }
1437
+ });
1438
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
1439
+ return { passed, failed };
1440
+ }
1441
+ function runGitLabDependencyProxyBlobSchemaTests() {
1442
+ console.log('🧪 Testing GitLabDependencyProxyBlobSchema...');
1443
+ const cases = [
1444
+ {
1445
+ name: 'schema:dependency_proxy_blob:basic',
1446
+ input: { file_name: 'sha256:abc123', size: '2.5 MiB', created_at: '2026-01-01T00:00:00Z' },
1447
+ shouldFail: false,
1448
+ },
1449
+ {
1450
+ name: 'schema:dependency_proxy_blob:no-created-at',
1451
+ input: { file_name: 'sha256:def456', size: '512 KiB' },
1452
+ shouldFail: false,
1453
+ },
1454
+ {
1455
+ name: 'schema:dependency_proxy_blob:size-must-be-string',
1456
+ input: { file_name: 'sha256:ghi789', size: 12345 },
1457
+ shouldFail: true,
1458
+ },
1459
+ ];
1460
+ let passed = 0;
1461
+ let failed = 0;
1462
+ cases.forEach(testCase => {
1463
+ const result = { name: testCase.name, status: 'failed' };
1464
+ const parsed = GitLabDependencyProxyBlobSchema.safeParse(testCase.input);
1465
+ if (testCase.shouldFail) {
1466
+ result.status = parsed.success ? 'failed' : 'passed';
1467
+ if (parsed.success)
1468
+ result.error = 'Expected schema validation to fail';
1469
+ }
1470
+ else if (parsed.success) {
1471
+ result.status = 'passed';
1472
+ }
1473
+ else {
1474
+ result.error = parsed.error?.message || 'Schema validation failed';
1475
+ }
1476
+ if (result.status === 'passed') {
1477
+ passed++;
1478
+ console.log(`✅ ${result.name}`);
1479
+ }
1480
+ else {
1481
+ failed++;
1482
+ console.log(`❌ ${result.name}: ${result.error}`);
1483
+ }
1484
+ });
1485
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
1486
+ return { passed, failed };
1487
+ }
1388
1488
  if (import.meta.url === `file://${process.argv[1]}`) {
1389
1489
  const getFileContentsResult = runGetFileContentsSchemaTests();
1390
1490
  const fileContentResult = runGitLabFileContentSchemaTests();
@@ -1403,8 +1503,10 @@ if (import.meta.url === `file://${process.argv[1]}`) {
1403
1503
  const repositoryTreeResult = runGetRepositoryTreeSchemaTests();
1404
1504
  const gitLabUserFullResult = runGitLabUserFullSchemaTests();
1405
1505
  const gitLabMarkdownUploadResult = runGitLabMarkdownUploadSchemaTests();
1406
- const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + approvedByUsernamesResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed;
1407
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + approvedByUsernamesResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed;
1506
+ const dependencyProxyResult = runGitLabDependencyProxySchemaTests();
1507
+ const dependencyProxyBlobResult = runGitLabDependencyProxyBlobSchemaTests();
1508
+ const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + approvedByUsernamesResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed + dependencyProxyResult.passed + dependencyProxyBlobResult.passed;
1509
+ const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + approvedByUsernamesResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed + dependencyProxyResult.failed + dependencyProxyBlobResult.failed;
1408
1510
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
1409
1511
  if (totalFailed > 0) {
1410
1512
  process.exit(1);
@@ -34,7 +34,7 @@ function loadMaterial(current, previous) {
34
34
  return m;
35
35
  }
36
36
  function makeProvider(material, { callbackProxy = true } = {}) {
37
- return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
37
+ return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
38
38
  ? {
39
39
  material,
40
40
  clientTtlSeconds: 86400,
@@ -31,6 +31,7 @@ function loadMaterial(current, previous) {
31
31
  function makeProvider(material, { callbackProxy = false } = {}) {
32
32
  return createGitLabOAuthProvider("https://gitlab.example.com", "real-gitlab-app-id", "GitLab MCP Server (test)", false, // readOnly
33
33
  undefined, // customScopes
34
+ undefined, // allowedGroups
34
35
  callbackProxy, callbackProxy ? "https://mcp.example.com/callback" : "", material
35
36
  ? {
36
37
  material,
@@ -0,0 +1,191 @@
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-dependency-proxy";
6
+ const TEST_GROUP_PATH = "my-group";
7
+ async function callTool(toolName, args, env) {
8
+ return new Promise((resolve, reject) => {
9
+ const proc = spawn("node", ["build/index.js"], {
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ env: { ...process.env, ...env },
12
+ });
13
+ let output = "";
14
+ let errorOutput = "";
15
+ proc.stdout?.on("data", (d) => (output += d));
16
+ proc.stderr?.on("data", (d) => (errorOutput += d));
17
+ proc.on("close", code => {
18
+ if (code !== 0) {
19
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
20
+ }
21
+ const line = output.split("\n").find(l => l.startsWith("{"));
22
+ if (!line)
23
+ return reject(new Error("No JSON output found"));
24
+ try {
25
+ const response = JSON.parse(line);
26
+ if (response.error) {
27
+ reject(new Error(response.error?.message ?? String(response.error)));
28
+ }
29
+ else {
30
+ const content = response.result?.content?.[0]?.text;
31
+ resolve(content ? JSON.parse(content) : response.result);
32
+ }
33
+ }
34
+ catch (e) {
35
+ reject(e);
36
+ }
37
+ });
38
+ proc.stdin?.end(JSON.stringify({
39
+ jsonrpc: "2.0",
40
+ id: 1,
41
+ method: "tools/call",
42
+ params: { name: toolName, arguments: args },
43
+ }) + "\n");
44
+ });
45
+ }
46
+ describe("dependency proxy tools", () => {
47
+ let mockServer;
48
+ let mockPort;
49
+ let baseEnv;
50
+ before(async () => {
51
+ mockPort = await findMockServerPort();
52
+ mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
53
+ // Mock GraphQL endpoint for get/list operations
54
+ mockServer.addRootHandler("post", "/api/graphql", (req, res) => {
55
+ const { query } = req.body;
56
+ if (query.includes("updateDependencyProxySettings")) {
57
+ res.json({ data: { updateDependencyProxySettings: { errors: [] } } });
58
+ }
59
+ else if (query.includes("dependencyProxySetting")) {
60
+ res.json({
61
+ data: {
62
+ group: {
63
+ dependencyProxySetting: { enabled: true },
64
+ dependencyProxyBlobCount: 3,
65
+ dependencyProxyTotalSize: "15728640",
66
+ dependencyProxyImagePrefix: `localhost:${mockPort}/my-group/dependency_proxy/containers`,
67
+ dependencyProxyImageTtlPolicy: { enabled: true, ttl: 90 },
68
+ },
69
+ },
70
+ });
71
+ }
72
+ else if (query.includes("dependencyProxyBlobs")) {
73
+ res.json({
74
+ data: {
75
+ group: {
76
+ dependencyProxyBlobs: {
77
+ nodes: [
78
+ { fileName: "sha256:abc123", size: "5 MiB", createdAt: "2026-01-01T00:00:00Z" },
79
+ { fileName: "sha256:def456", size: "10 MiB", createdAt: "2026-01-02T00:00:00Z" },
80
+ ],
81
+ pageInfo: { hasNextPage: false, endCursor: null },
82
+ },
83
+ },
84
+ },
85
+ });
86
+ }
87
+ else {
88
+ res.status(400).json({ errors: [{ message: "Unexpected GraphQL query" }] });
89
+ }
90
+ });
91
+ // Mock REST endpoint for purge
92
+ mockServer.addMockHandler("delete", `/groups/${TEST_GROUP_PATH}/dependency_proxy/cache`, (_req, res) => {
93
+ res.status(202).send();
94
+ });
95
+ await mockServer.start();
96
+ baseEnv = {
97
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
98
+ GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
99
+ GITLAB_TOOLSETS: "dependency_proxy",
100
+ };
101
+ });
102
+ after(async () => {
103
+ await mockServer.stop();
104
+ });
105
+ test("get_dependency_proxy_settings returns enabled status and blob info", async () => {
106
+ const result = await callTool("get_dependency_proxy_settings", { group_id: TEST_GROUP_PATH }, baseEnv);
107
+ assert.strictEqual(result.enabled, true);
108
+ assert.strictEqual(result.blob_count, 3);
109
+ assert.strictEqual(result.total_size, "15728640");
110
+ assert.ok(result.ttl_policy?.enabled);
111
+ });
112
+ test("list_dependency_proxy_blobs returns blobs with string sizes", async () => {
113
+ const result = await callTool("list_dependency_proxy_blobs", { group_id: TEST_GROUP_PATH }, baseEnv);
114
+ assert.ok(Array.isArray(result.blobs));
115
+ assert.strictEqual(result.blobs.length, 2);
116
+ assert.strictEqual(typeof result.blobs[0].size, "string");
117
+ assert.strictEqual(result.blobs[0].file_name, "sha256:abc123");
118
+ });
119
+ test("purge_dependency_proxy_cache returns scheduled status", async () => {
120
+ const result = await callTool("purge_dependency_proxy_cache", { group_id: TEST_GROUP_PATH }, baseEnv);
121
+ assert.strictEqual(result.status, "success");
122
+ assert.ok(result.message.includes("scheduled"));
123
+ });
124
+ test("update_dependency_proxy_settings enables the proxy and returns settings", async () => {
125
+ const result = await callTool("update_dependency_proxy_settings", { group_id: TEST_GROUP_PATH, enabled: true }, baseEnv);
126
+ assert.strictEqual(result.enabled, true);
127
+ });
128
+ test("update_dependency_proxy_settings rejects empty options", async () => {
129
+ await assert.rejects(() => callTool("update_dependency_proxy_settings", { group_id: TEST_GROUP_PATH }, baseEnv), /At least one of enabled, identity, or secret must be provided/);
130
+ });
131
+ test("dependency_proxy tools are absent when toolset is not activated", async () => {
132
+ return new Promise((resolve, reject) => {
133
+ const proc = spawn("node", ["build/index.js"], {
134
+ stdio: ["pipe", "pipe", "pipe"],
135
+ env: {
136
+ ...process.env,
137
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
138
+ GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`,
139
+ // No GITLAB_TOOLSETS — default toolsets only
140
+ },
141
+ });
142
+ let output = "";
143
+ proc.stdout?.on("data", (d) => (output += d));
144
+ proc.on("close", () => {
145
+ try {
146
+ const line = output.split("\n").find(l => l.startsWith("{"));
147
+ if (!line)
148
+ return reject(new Error("No JSON output found"));
149
+ const response = JSON.parse(line);
150
+ const tools = response.result?.tools ?? [];
151
+ const names = tools.map(t => t.name);
152
+ assert.ok(!names.includes("get_dependency_proxy_settings"), "tool should not be in default toolset");
153
+ assert.ok(!names.includes("purge_dependency_proxy_cache"), "tool should not be in default toolset");
154
+ resolve();
155
+ }
156
+ catch (e) {
157
+ reject(e);
158
+ }
159
+ });
160
+ proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
161
+ });
162
+ });
163
+ test("write tools are absent from tools/list in read-only mode", async () => {
164
+ return new Promise((resolve, reject) => {
165
+ const proc = spawn("node", ["build/index.js"], {
166
+ stdio: ["pipe", "pipe", "pipe"],
167
+ env: { ...process.env, ...baseEnv, GITLAB_READ_ONLY_MODE: "true" },
168
+ });
169
+ let output = "";
170
+ proc.stdout?.on("data", (d) => (output += d));
171
+ proc.on("close", () => {
172
+ try {
173
+ const line = output.split("\n").find(l => l.startsWith("{"));
174
+ if (!line)
175
+ return reject(new Error("No JSON output found"));
176
+ const response = JSON.parse(line);
177
+ const names = (response.result?.tools ?? []).map((t) => t.name);
178
+ assert.ok(!names.includes("purge_dependency_proxy_cache"), "purge should be absent in read-only mode");
179
+ assert.ok(!names.includes("update_dependency_proxy_settings"), "update should be absent in read-only mode");
180
+ assert.ok(names.includes("get_dependency_proxy_settings"), "get should be present in read-only mode");
181
+ assert.ok(names.includes("list_dependency_proxy_blobs"), "list should be present in read-only mode");
182
+ resolve();
183
+ }
184
+ catch (e) {
185
+ reject(e);
186
+ }
187
+ });
188
+ proc.stdin?.end(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) + "\n");
189
+ });
190
+ });
191
+ });