@zereight/mcp-gitlab 2.1.18 → 2.1.20

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, {
@@ -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,111 @@
1
+ import { describe, test, before, after } from "node:test";
2
+ import assert from "node:assert";
3
+ import { spawn } from "child_process";
4
+ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
5
+ const MOCK_TOKEN = "glpat-mock-token-12345";
6
+ const TEST_PROJECT_ID = "123";
7
+ async function callListIssues(args = {}, env) {
8
+ return new Promise((resolve, reject) => {
9
+ const proc = spawn("node", ["build/index.js"], {
10
+ stdio: ["pipe", "pipe", "pipe"],
11
+ env: {
12
+ ...process.env,
13
+ ...env,
14
+ GITLAB_READ_ONLY_MODE: "true",
15
+ },
16
+ });
17
+ let output = "";
18
+ let errorOutput = "";
19
+ proc.stdout?.on("data", d => (output += d));
20
+ proc.stderr?.on("data", d => (errorOutput += d));
21
+ proc.on("close", code => {
22
+ if (code !== 0) {
23
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
24
+ }
25
+ const line = output.split("\n").find(l => l.startsWith("{"));
26
+ if (!line) {
27
+ return reject(new Error("No JSON output found"));
28
+ }
29
+ try {
30
+ const response = JSON.parse(line);
31
+ if (response.error) {
32
+ reject(response.error);
33
+ }
34
+ else {
35
+ const content = response.result?.content?.[0]?.text;
36
+ if (content) {
37
+ try {
38
+ resolve(JSON.parse(content));
39
+ }
40
+ catch {
41
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
42
+ }
43
+ }
44
+ else {
45
+ resolve(response.result);
46
+ }
47
+ }
48
+ }
49
+ catch (e) {
50
+ reject(e);
51
+ }
52
+ });
53
+ proc.stdin?.end(JSON.stringify({
54
+ jsonrpc: "2.0",
55
+ id: 1,
56
+ method: "tools/call",
57
+ params: { name: "list_issues", arguments: args },
58
+ }) + "\n");
59
+ });
60
+ }
61
+ describe("list_issues", () => {
62
+ let mockGitLab;
63
+ let mockGitLabUrl;
64
+ before(async () => {
65
+ const mockPort = await findMockServerPort(9000);
66
+ mockGitLab = new MockGitLabServer({
67
+ port: mockPort,
68
+ validTokens: [MOCK_TOKEN],
69
+ });
70
+ await mockGitLab.start();
71
+ mockGitLabUrl = mockGitLab.getUrl();
72
+ });
73
+ after(async () => {
74
+ await mockGitLab.stop();
75
+ });
76
+ test("lists project-specific issues", async () => {
77
+ const issues = await callListIssues({ project_id: TEST_PROJECT_ID }, {
78
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
79
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
80
+ });
81
+ assert.ok(Array.isArray(issues), "Response should be an array");
82
+ assert.strictEqual(issues.length, 1, "Should return 1 mock issue");
83
+ const firstIssue = issues[0];
84
+ assert.ok(firstIssue && typeof firstIssue === "object" && "title" in firstIssue);
85
+ const title = Reflect.get(firstIssue, "title");
86
+ assert.strictEqual(title, "Test Issue 1");
87
+ });
88
+ test("prefers author_username over author_id when both are provided", async () => {
89
+ let capturedUrl;
90
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/issues`, (req, res) => {
91
+ capturedUrl = req.originalUrl;
92
+ res.json([]);
93
+ });
94
+ try {
95
+ await callListIssues({
96
+ project_id: TEST_PROJECT_ID,
97
+ author_id: "42",
98
+ author_username: "alice",
99
+ }, {
100
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
101
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
102
+ });
103
+ assert.ok(capturedUrl, "Mock handler should have received a request");
104
+ assert.match(capturedUrl, /author_username=alice/, "Request should include author_username");
105
+ assert.doesNotMatch(capturedUrl, /author_id=/, "Request should not include author_id");
106
+ }
107
+ finally {
108
+ mockGitLab.clearCustomHandlers();
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,155 @@
1
+ import { after, before, describe, test } 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-protected-branches";
6
+ const TEST_PROJECT_ID = "123";
7
+ const TEST_BRANCH = "main";
8
+ function buildProtectedBranch(overrides = {}) {
9
+ return {
10
+ id: 1,
11
+ name: TEST_BRANCH,
12
+ push_access_levels: [
13
+ { access_level: 30, access_level_description: "Developers + Maintainers" },
14
+ ],
15
+ merge_access_levels: [{ access_level: 40, access_level_description: "Maintainers" }],
16
+ unprotect_access_levels: [{ access_level: 60, access_level_description: "Administrators" }],
17
+ allow_force_push: false,
18
+ code_owner_approval_required: false,
19
+ ...overrides,
20
+ };
21
+ }
22
+ async function callTool(toolName, args, env) {
23
+ return new Promise((resolve, reject) => {
24
+ const proc = spawn("node", ["build/index.js"], {
25
+ stdio: ["pipe", "pipe", "pipe"],
26
+ env: {
27
+ ...process.env,
28
+ ...env,
29
+ },
30
+ });
31
+ let output = "";
32
+ let errorOutput = "";
33
+ proc.stdout?.on("data", (d) => (output += d));
34
+ proc.stderr?.on("data", (d) => (errorOutput += d));
35
+ proc.on("close", code => {
36
+ if (code !== 0) {
37
+ reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
38
+ return;
39
+ }
40
+ const line = output.split("\n").find(l => l.startsWith("{"));
41
+ if (!line) {
42
+ reject(new Error("No JSON output found"));
43
+ return;
44
+ }
45
+ const response = JSON.parse(line);
46
+ if (response.error) {
47
+ reject(new Error(response.error.message ?? JSON.stringify(response.error)));
48
+ return;
49
+ }
50
+ const content = response.result?.content?.[0]?.text;
51
+ if (!content) {
52
+ resolve(response.result);
53
+ return;
54
+ }
55
+ try {
56
+ resolve(JSON.parse(content));
57
+ }
58
+ catch {
59
+ resolve(content);
60
+ }
61
+ });
62
+ proc.stdin?.end(JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ id: 1,
65
+ method: "tools/call",
66
+ params: { name: toolName, arguments: args },
67
+ }) + "\n");
68
+ });
69
+ }
70
+ describe("protected branch tools", () => {
71
+ let mockGitLab;
72
+ let mockGitLabUrl;
73
+ before(async () => {
74
+ const mockPort = await findMockServerPort(20500, 50);
75
+ mockGitLab = new MockGitLabServer({
76
+ port: mockPort,
77
+ validTokens: [MOCK_TOKEN],
78
+ });
79
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
80
+ assert.strictEqual(req.query.search, "main");
81
+ assert.strictEqual(req.query.page, "2");
82
+ assert.strictEqual(req.query.per_page, "10");
83
+ res.json([buildProtectedBranch()]);
84
+ });
85
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
86
+ res.json(buildProtectedBranch());
87
+ });
88
+ mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
89
+ assert.deepStrictEqual(req.body, {
90
+ name: TEST_BRANCH,
91
+ push_access_level: 30,
92
+ merge_access_level: 40,
93
+ unprotect_access_level: 60,
94
+ allow_force_push: false,
95
+ code_owner_approval_required: false,
96
+ });
97
+ res.status(201).json(buildProtectedBranch());
98
+ });
99
+ mockGitLab.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
100
+ res.status(204).send();
101
+ });
102
+ mockGitLab.addMockHandler("put", `/projects/${TEST_PROJECT_ID}`, (req, res) => {
103
+ assert.deepStrictEqual(req.body, { default_branch: TEST_BRANCH });
104
+ res.json({ id: Number(TEST_PROJECT_ID), default_branch: TEST_BRANCH });
105
+ });
106
+ await mockGitLab.start();
107
+ mockGitLabUrl = mockGitLab.getUrl();
108
+ });
109
+ after(async () => {
110
+ await mockGitLab.stop();
111
+ });
112
+ const env = () => ({
113
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
114
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
115
+ GITLAB_TOOLSETS: "branches",
116
+ });
117
+ test("list_protected_branches forwards search and pagination", async () => {
118
+ const result = await callTool("list_protected_branches", { project_id: TEST_PROJECT_ID, search: "main", page: 2, per_page: 10 }, env());
119
+ assert.ok(Array.isArray(result));
120
+ assert.strictEqual(result[0].name, TEST_BRANCH);
121
+ });
122
+ test("get_protected_branch parses a single protected branch", async () => {
123
+ const result = await callTool("get_protected_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
124
+ assert.strictEqual(result.name, TEST_BRANCH);
125
+ assert.strictEqual(result.allow_force_push, false);
126
+ });
127
+ test("protect_branch posts normalized branch name, access levels, and false booleans", async () => {
128
+ const result = await callTool("protect_branch", {
129
+ project_id: TEST_PROJECT_ID,
130
+ branch_name: TEST_BRANCH,
131
+ push_access_level: "30",
132
+ merge_access_level: 40,
133
+ unprotect_access_level: 60,
134
+ allow_force_push: "false",
135
+ code_owner_approval_required: "false",
136
+ }, env());
137
+ assert.strictEqual(result.name, TEST_BRANCH);
138
+ });
139
+ test("protect_branch rejects invalid access levels before calling GitLab", async () => {
140
+ await assert.rejects(() => callTool("protect_branch", {
141
+ project_id: TEST_PROJECT_ID,
142
+ branch_name: TEST_BRANCH,
143
+ push_access_level: 99,
144
+ }, env()), /Access level must be one of/);
145
+ });
146
+ test("unprotect_branch sends DELETE and returns status", async () => {
147
+ const result = await callTool("unprotect_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
148
+ assert.deepStrictEqual(result, { status: "unprotected", branch: TEST_BRANCH });
149
+ });
150
+ test("update_default_branch sends the expected PUT body", async () => {
151
+ const result = await callTool("update_default_branch", { project_id: TEST_PROJECT_ID, default_branch: TEST_BRANCH }, env());
152
+ assert.strictEqual(result.status, "updated");
153
+ assert.strictEqual(result.default_branch, TEST_BRANCH);
154
+ });
155
+ });
@@ -19,7 +19,7 @@ const TOOLSET_TOOL_COUNTS = {
19
19
  merge_requests: 41,
20
20
  issues: 24,
21
21
  repositories: 7,
22
- branches: 10,
22
+ branches: 15,
23
23
  projects: 9,
24
24
  labels: 5,
25
25
  ci: 2,
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import { sanitizeToolArguments } from "../../utils/tool-args.js";
3
+ import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "../../utils/tool-args.js";
4
4
  describe("When sanitizeToolArguments runs", () => {
5
5
  describe("with top-level null optionals", () => {
6
6
  test("should omit null and undefined keys for generic tools", () => {
@@ -73,3 +73,50 @@ describe("When sanitizeToolArguments runs", () => {
73
73
  });
74
74
  });
75
75
  });
76
+ describe("When cleanMutuallyExclusiveIdUsernameOptions runs", () => {
77
+ describe("with list_issues author filters", () => {
78
+ test("should drop author_id when author_username is also set", () => {
79
+ const result = cleanMutuallyExclusiveIdUsernameOptions({
80
+ author_id: "42",
81
+ author_username: "alice",
82
+ state: "opened",
83
+ });
84
+ assert.deepEqual(result, {
85
+ author_username: "alice",
86
+ state: "opened",
87
+ });
88
+ });
89
+ });
90
+ describe("with list_issues assignee filters", () => {
91
+ test("should drop assignee_id when assignee_username is also set", () => {
92
+ const result = cleanMutuallyExclusiveIdUsernameOptions({
93
+ assignee_id: "7",
94
+ assignee_username: ["bob"],
95
+ });
96
+ assert.deepEqual(result, {
97
+ assignee_username: ["bob"],
98
+ });
99
+ });
100
+ test("should keep assignee_id when assignee_username is an empty array", () => {
101
+ const result = cleanMutuallyExclusiveIdUsernameOptions({
102
+ assignee_id: "7",
103
+ assignee_username: [],
104
+ });
105
+ assert.deepEqual(result, {
106
+ assignee_id: "7",
107
+ assignee_username: [],
108
+ });
109
+ });
110
+ });
111
+ describe("with list_merge_requests reviewer filters", () => {
112
+ test("should drop reviewer_id when reviewer_username is also set", () => {
113
+ const result = cleanMutuallyExclusiveIdUsernameOptions({
114
+ reviewer_id: "3",
115
+ reviewer_username: "carol",
116
+ }, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
117
+ assert.deepEqual(result, {
118
+ reviewer_username: "carol",
119
+ });
120
+ });
121
+ });
122
+ });