@zereight/mcp-gitlab 2.1.68 → 2.1.70

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.
Files changed (40) hide show
  1. package/README.ko.md +11 -3
  2. package/README.md +237 -232
  3. package/README.pt-BR.md +8 -4
  4. package/README.zh-CN.md +7 -3
  5. package/build/cli/args.js +298 -0
  6. package/build/cli/compact-mcp-result.js +87 -0
  7. package/build/cli/curated.js +243 -0
  8. package/build/cli/exposure.js +72 -0
  9. package/build/cli/help.js +108 -0
  10. package/build/cli/output.js +65 -0
  11. package/build/cli/replay-command.js +53 -0
  12. package/build/cli/router.js +158 -0
  13. package/build/cli/tables.js +117 -0
  14. package/build/cli-boolean-flags.js +38 -0
  15. package/build/cli-command.js +14 -4
  16. package/build/config.js +30 -7
  17. package/build/index.js +176 -90
  18. package/build/oauth.js +93 -10
  19. package/build/scripts/generate-tool-docs.js +2 -1
  20. package/build/test/cli/args.test.js +175 -0
  21. package/build/test/cli/boolean-flags.test.js +106 -0
  22. package/build/test/cli/compact-mcp-result.test.js +93 -0
  23. package/build/test/cli/credentials.test.js +153 -0
  24. package/build/test/cli/exposure.test.js +104 -0
  25. package/build/test/cli/load-config-snapshot.js +5 -0
  26. package/build/test/cli/output.test.js +74 -0
  27. package/build/test/cli/replay-command.test.js +37 -0
  28. package/build/test/cli/router.test.js +183 -0
  29. package/build/test/gitlab-api-tool-errors.test.js +112 -0
  30. package/build/test/oauth-device-flow-tests.js +10 -0
  31. package/build/test/oauth-download-url.test.js +157 -0
  32. package/build/test/oauth-startup.test.js +204 -0
  33. package/build/test/test-dynamic-project-scope.js +2 -0
  34. package/build/test/test-geteffectiveprojectid.js +20 -26
  35. package/build/test/test-permission-mode.js +86 -0
  36. package/build/test/test-pipeline-schedules.js +3 -1
  37. package/build/test/utils/url.test.js +14 -1
  38. package/build/tools/denied-regex.js +38 -0
  39. package/build/utils/url.js +15 -0
  40. package/package.json +1 -1
@@ -193,6 +193,16 @@ describe("When reading the CLI command", () => {
193
193
  assert.equal(getPositionalCliCommand(["node", "/usr/local/bin/tsx", "index.ts", "auth", "--help"]), "auth");
194
194
  });
195
195
  });
196
+ describe("with --use-oauth before a curated command", () => {
197
+ it("should still return mr instead of consuming it as the flag value", () => {
198
+ assert.equal(getPositionalCliCommand(["node", "index.js", "--use-oauth", "mr", "list"]), "mr");
199
+ });
200
+ });
201
+ describe("with --read-only false before a curated command", () => {
202
+ it("should skip the boolean literal and return mr", () => {
203
+ assert.equal(getPositionalCliCommand(["node", "index.js", "--read-only", "false", "mr", "list"]), "mr");
204
+ });
205
+ });
196
206
  describe("with space-separated --api-url before auth", () => {
197
207
  it("should skip the URL value and return auth", () => {
198
208
  assert.equal(getPositionalCliCommand([
@@ -0,0 +1,157 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as net from "node:net";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import * as url from "node:url";
8
+ import { describe, it } from "node:test";
9
+ // The token key is derived at module load, so the secret must be set before importing it.
10
+ const DOWNLOAD_TOKEN_SECRET = "oauth-download-test-secret";
11
+ process.env.DOWNLOAD_TOKEN_SECRET = DOWNLOAD_TOKEN_SECRET;
12
+ const { decryptDownloadToken } = await import("../utils/download-token.js");
13
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
14
+ const SERVER_ENTRY = path.resolve(__dirname, "../build/index.js");
15
+ const HOST = "127.0.0.1";
16
+ const OAUTH_TOKEN = "oauth-download-test";
17
+ const MCP_ACCESS_TOKEN = "mcp-access-test";
18
+ const STARTUP_TIMEOUT_MS = 15_000;
19
+ const MCP_HEADERS = {
20
+ "Content-Type": "application/json",
21
+ Accept: "application/json, text/event-stream",
22
+ Authorization: `Bearer ${MCP_ACCESS_TOKEN}`,
23
+ };
24
+ function getFreePortAsync() {
25
+ return new Promise((resolve, reject) => {
26
+ const probe = net.createServer();
27
+ probe.on("error", reject);
28
+ probe.listen(0, HOST, () => {
29
+ const address = probe.address();
30
+ if (address === null || typeof address === "string") {
31
+ probe.close();
32
+ reject(new Error("probe server is not listening on a TCP port"));
33
+ return;
34
+ }
35
+ probe.close(() => resolve(address.port));
36
+ });
37
+ });
38
+ }
39
+ function buildServerEnv(port, tmpDir) {
40
+ const tokenPath = path.join(tmpDir, "token.json");
41
+ fs.writeFileSync(tokenPath, JSON.stringify({
42
+ access_token: OAUTH_TOKEN,
43
+ refresh_token: "refresh",
44
+ expires_in: 7200,
45
+ created_at: Date.now(),
46
+ token_type: "Bearer",
47
+ }));
48
+ const env = {};
49
+ for (const [key, value] of Object.entries(process.env)) {
50
+ if (value !== undefined && !key.startsWith("GITLAB_")) {
51
+ env[key] = value;
52
+ }
53
+ }
54
+ return {
55
+ ...env,
56
+ STREAMABLE_HTTP: "true",
57
+ HOST,
58
+ PORT: String(port),
59
+ MCP_SERVER_URL: "",
60
+ GITLAB_API_URL: `http://${HOST}:1/api/v4`,
61
+ GITLAB_USE_OAUTH: "true",
62
+ GITLAB_OAUTH_CLIENT_ID: "client-id",
63
+ GITLAB_OAUTH_TOKEN_PATH: tokenPath,
64
+ GITLAB_IS_OLD: "true",
65
+ USE_PIPELINE: "true",
66
+ DOWNLOAD_TOKEN_SECRET,
67
+ STREAMABLE_HTTP_AUTH_TOKEN: MCP_ACCESS_TOKEN,
68
+ };
69
+ }
70
+ async function waitForServerAsync(port, child) {
71
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
72
+ while (Date.now() < deadline) {
73
+ if (child.exitCode !== null) {
74
+ throw new Error(`server exited early with code ${child.exitCode}`);
75
+ }
76
+ const reachable = await new Promise(resolve => {
77
+ const socket = net.connect(port, HOST);
78
+ socket.once("connect", () => {
79
+ socket.destroy();
80
+ resolve(true);
81
+ });
82
+ socket.once("error", () => resolve(false));
83
+ });
84
+ if (reachable) {
85
+ return;
86
+ }
87
+ await new Promise(resolve => setTimeout(resolve, 100));
88
+ }
89
+ throw new Error("server did not start listening in time");
90
+ }
91
+ async function postMcpAsync(port, body, sessionId) {
92
+ return fetch(`http://${HOST}:${port}/mcp`, {
93
+ method: "POST",
94
+ headers: sessionId ? { ...MCP_HEADERS, "mcp-session-id": sessionId } : MCP_HEADERS,
95
+ body: JSON.stringify(body),
96
+ });
97
+ }
98
+ function readJsonRpcResult(sseBody) {
99
+ const dataLine = sseBody.split("\n").find(line => line.startsWith("data: "));
100
+ if (dataLine === undefined) {
101
+ throw new Error(`no SSE data line in response: ${sseBody}`);
102
+ }
103
+ return JSON.parse(dataLine.slice("data: ".length));
104
+ }
105
+ function readDownloadUrl(rpc) {
106
+ const text = JSON.parse(JSON.stringify(rpc)).result?.content?.[0]?.text;
107
+ if (typeof text !== "string") {
108
+ throw new Error(`unexpected tool response: ${JSON.stringify(rpc)}`);
109
+ }
110
+ const downloadUrl = JSON.parse(text).download_url;
111
+ if (typeof downloadUrl !== "string") {
112
+ throw new Error(`no download_url in tool response: ${text}`);
113
+ }
114
+ return downloadUrl;
115
+ }
116
+ async function requestDownloadUrlAsync() {
117
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-oauth-download-"));
118
+ const port = await getFreePortAsync();
119
+ const env = buildServerEnv(port, tmpDir);
120
+ const child = spawn(process.execPath, [SERVER_ENTRY], { env, stdio: "ignore" });
121
+ try {
122
+ await waitForServerAsync(port, child);
123
+ const init = await postMcpAsync(port, {
124
+ jsonrpc: "2.0",
125
+ id: 1,
126
+ method: "initialize",
127
+ params: {
128
+ protocolVersion: "2025-03-26",
129
+ capabilities: {},
130
+ clientInfo: { name: "oauth-download-url-test", version: "1.0" },
131
+ },
132
+ });
133
+ const sessionId = init.headers.get("mcp-session-id") ?? undefined;
134
+ const call = await postMcpAsync(port, {
135
+ jsonrpc: "2.0",
136
+ id: 2,
137
+ method: "tools/call",
138
+ params: { name: "download_job_artifacts", arguments: { project_id: "g/p", job_id: "1" } },
139
+ }, sessionId);
140
+ return readDownloadUrl(readJsonRpcResult(await call.text()));
141
+ }
142
+ finally {
143
+ child.kill("SIGKILL");
144
+ fs.rmSync(tmpDir, { recursive: true, force: true });
145
+ }
146
+ }
147
+ describe("When a remote server builds a download URL with a stored OAuth token", () => {
148
+ describe("with GITLAB_IS_OLD enabled", () => {
149
+ it("should embed the token as a bearer credential", async () => {
150
+ const downloadUrl = await requestDownloadUrlAsync();
151
+ const embedded = new URL(downloadUrl).searchParams.get("_token");
152
+ const decrypted = embedded === null ? null : decryptDownloadToken(embedded);
153
+ assert.equal(decrypted?.header, "Authorization");
154
+ assert.equal(decrypted?.token, `Bearer ${OAUTH_TOKEN}`);
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * OAuth startup tests.
3
+ *
4
+ * - isAuthInvalidTokenResponse: pure classifier — refresh failures that mean
5
+ * "re-login would fix it" vs transient/config errors that must not open a browser.
6
+ * - ensureOAuthToken: lazy first-use delivery — the caller must receive the
7
+ * token even when the on-disk token is still valid (regression: returning
8
+ * early on hasValidToken() alone leaves requests unauthenticated).
9
+ *
10
+ * Temp token files + a loopback mock for /oauth/token only; no MCP server.
11
+ * The missing-token and invalid_grant paths start the interactive browser
12
+ * flow and are deliberately not covered here.
13
+ */
14
+ import { describe, test, afterEach } from "node:test";
15
+ import assert from "node:assert";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import * as http from "node:http";
20
+ import { GitLabOAuth, ensureOAuthToken, isAuthInvalidTokenResponse } from "../oauth.js";
21
+ const validTokenFile = {
22
+ access_token: "cached-access-token",
23
+ refresh_token: "cached-refresh-token",
24
+ expires_in: 7200,
25
+ created_at: Date.now(),
26
+ token_type: "Bearer",
27
+ };
28
+ const expiredTokenFile = {
29
+ ...validTokenFile,
30
+ created_at: Date.now() - 10 * 3600 * 1000,
31
+ };
32
+ const tmpDirs = [];
33
+ const servers = [];
34
+ afterEach(async () => {
35
+ for (const server of servers.splice(0)) {
36
+ await new Promise(resolve => server.close(() => resolve()));
37
+ }
38
+ for (const dir of tmpDirs.splice(0)) {
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+ function makeTokenFile(data) {
43
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oauth-startup-"));
44
+ tmpDirs.push(dir);
45
+ const file = path.join(dir, "token.json");
46
+ fs.writeFileSync(file, JSON.stringify(data));
47
+ return file;
48
+ }
49
+ function makeClient(tokenStoragePath, gitlabUrl, extra) {
50
+ return new GitLabOAuth({
51
+ clientId: "test-client",
52
+ redirectUri: "http://127.0.0.1:8888/callback",
53
+ gitlabUrl,
54
+ scopes: ["api"],
55
+ tokenStoragePath,
56
+ ...extra,
57
+ });
58
+ }
59
+ async function startTokenServer(handler) {
60
+ const bodies = [];
61
+ const server = http.createServer((req, res) => {
62
+ let body = "";
63
+ req.on("data", (chunk) => {
64
+ body += chunk.toString();
65
+ });
66
+ req.on("end", () => {
67
+ bodies.push(body);
68
+ const { status, json } = handler();
69
+ res.writeHead(status, { "Content-Type": "application/json" });
70
+ res.end(JSON.stringify(json));
71
+ });
72
+ });
73
+ servers.push(server);
74
+ await new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve()));
75
+ const addr = server.address();
76
+ assert(addr && typeof addr !== "string");
77
+ return { url: `http://127.0.0.1:${addr.port}`, bodies };
78
+ }
79
+ describe("isAuthInvalidTokenResponse", () => {
80
+ test("400 invalid_grant is auth-invalid", () => {
81
+ assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_grant"}'), true);
82
+ });
83
+ test("400 invalid_token is auth-invalid", () => {
84
+ assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_token"}'), true);
85
+ });
86
+ test("401 invalid_grant is auth-invalid", () => {
87
+ assert.strictEqual(isAuthInvalidTokenResponse(401, '{"error":"invalid_grant"}'), true);
88
+ });
89
+ test("400 invalid_client is not auth-invalid (config error)", () => {
90
+ assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_client"}'), false);
91
+ });
92
+ test("400 invalid_request is not auth-invalid (config error)", () => {
93
+ assert.strictEqual(isAuthInvalidTokenResponse(400, '{"error":"invalid_request"}'), false);
94
+ });
95
+ test("500 server_error is not auth-invalid (transient)", () => {
96
+ assert.strictEqual(isAuthInvalidTokenResponse(500, '{"error":"server_error"}'), false);
97
+ });
98
+ test("429 is not auth-invalid (rate limited)", () => {
99
+ assert.strictEqual(isAuthInvalidTokenResponse(429, '{"error":"invalid_grant"}'), false);
100
+ });
101
+ test("non-JSON body is not auth-invalid", () => {
102
+ assert.strictEqual(isAuthInvalidTokenResponse(400, "gateway timeout"), false);
103
+ });
104
+ });
105
+ describe("ensureOAuthToken", () => {
106
+ test("null client is a no-op", async () => {
107
+ const delivered = [];
108
+ await ensureOAuthToken(null, null, t => {
109
+ delivered.push(t);
110
+ });
111
+ await ensureOAuthToken(undefined, undefined, t => {
112
+ delivered.push(t);
113
+ });
114
+ assert.deepStrictEqual(delivered, []);
115
+ });
116
+ test("first call with valid cached token delivers it without network", async () => {
117
+ // Closed port: any fetch attempt fails, proving the cached path is network-free.
118
+ const client = makeClient(makeTokenFile(validTokenFile), "http://127.0.0.1:9");
119
+ const delivered = [];
120
+ await ensureOAuthToken(client, null, t => {
121
+ delivered.push(t);
122
+ });
123
+ assert.deepStrictEqual(delivered, ["cached-access-token"]);
124
+ });
125
+ test("already-held valid token is not re-delivered", async () => {
126
+ const client = makeClient(makeTokenFile(validTokenFile), "http://127.0.0.1:9");
127
+ const delivered = [];
128
+ await ensureOAuthToken(client, "cached-access-token", t => {
129
+ delivered.push(t);
130
+ });
131
+ assert.deepStrictEqual(delivered, []);
132
+ });
133
+ test("expired token refreshes via /oauth/token and persists", async () => {
134
+ const { url, bodies } = await startTokenServer(() => ({
135
+ status: 200,
136
+ json: {
137
+ access_token: "fresh-token",
138
+ refresh_token: "fresh-refresh",
139
+ expires_in: 7200,
140
+ token_type: "Bearer",
141
+ },
142
+ }));
143
+ const file = makeTokenFile(expiredTokenFile);
144
+ const client = makeClient(file, url);
145
+ const delivered = [];
146
+ await ensureOAuthToken(client, null, t => {
147
+ delivered.push(t);
148
+ });
149
+ assert.deepStrictEqual(delivered, ["fresh-token"]);
150
+ assert.strictEqual(bodies.length, 1);
151
+ assert.match(bodies[0], /grant_type=refresh_token/);
152
+ assert.match(bodies[0], /refresh_token=cached-refresh-token/);
153
+ const saved = JSON.parse(fs.readFileSync(file, "utf8"));
154
+ assert.strictEqual(saved.access_token, "fresh-token");
155
+ });
156
+ test("transient refresh failure propagates without opening a browser", async () => {
157
+ const { url } = await startTokenServer(() => ({ status: 500, json: { error: "server_error" } }));
158
+ const client = makeClient(makeTokenFile(expiredTokenFile), url);
159
+ const delivered = [];
160
+ // Rejection (not a browser popup) proves the no-browser path.
161
+ await assert.rejects(() => ensureOAuthToken(client, null, t => {
162
+ delivered.push(t);
163
+ }), /Token refresh failed: 500/);
164
+ assert.deepStrictEqual(delivered, []);
165
+ });
166
+ test("token script resolves once, then fast path holds", async () => {
167
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oauth-startup-"));
168
+ tmpDirs.push(dir);
169
+ const client = makeClient(path.join(dir, "token.json"), "http://127.0.0.1:9", {
170
+ tokenScript: "echo script-token-1",
171
+ });
172
+ const delivered = [];
173
+ await ensureOAuthToken(client, null, t => {
174
+ delivered.push(t);
175
+ });
176
+ assert.deepStrictEqual(delivered, ["script-token-1"]);
177
+ await ensureOAuthToken(client, delivered[0], t => {
178
+ delivered.push(t);
179
+ });
180
+ assert.deepStrictEqual(delivered, ["script-token-1"]);
181
+ });
182
+ test("concurrent calls coalesce into a single refresh request", async () => {
183
+ const { url, bodies } = await startTokenServer(() => ({
184
+ status: 200,
185
+ json: {
186
+ access_token: "coalesced-token",
187
+ refresh_token: "new-refresh",
188
+ expires_in: 7200,
189
+ token_type: "Bearer",
190
+ },
191
+ }));
192
+ const file = makeTokenFile(expiredTokenFile);
193
+ const client = makeClient(file, url);
194
+ const deliveredA = [];
195
+ const deliveredB = [];
196
+ await Promise.all([
197
+ ensureOAuthToken(client, null, t => deliveredA.push(t)),
198
+ ensureOAuthToken(client, null, t => deliveredB.push(t)),
199
+ ]);
200
+ assert.deepStrictEqual(deliveredA, ["coalesced-token"]);
201
+ assert.deepStrictEqual(deliveredB, ["coalesced-token"]);
202
+ assert.strictEqual(bodies.length, 1);
203
+ });
204
+ });
@@ -79,6 +79,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
79
79
  ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
80
80
  ENABLE_STRICT_PROJECT_SCOPE: "true",
81
81
  GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
82
+ GITLAB_TOOLS: "execute_graphql",
82
83
  GITLAB_ALLOWED_PROJECT_IDS: "1,2",
83
84
  },
84
85
  });
@@ -286,6 +287,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
286
287
  REMOTE_AUTHORIZATION: "true",
287
288
  ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
288
289
  GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
290
+ GITLAB_TOOLS: "execute_graphql,get_vulnerability",
289
291
  },
290
292
  });
291
293
  servers.push(server);
@@ -168,6 +168,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
168
168
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
169
169
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
170
170
  GITLAB_READ_ONLY_MODE: 'false',
171
+ GITLAB_TOOLSETS: 'all',
171
172
  }
172
173
  });
173
174
  servers.push(server);
@@ -259,35 +260,27 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
259
260
  });
260
261
  test('should not fall back from bare numeric project IDs to groups', async () => {
261
262
  group789Requested = false;
262
- let didThrow = false;
263
- try {
264
- await client.callTool('list_work_items', {
265
- project_id: '789',
266
- });
267
- }
268
- catch (error) {
269
- didThrow = true;
270
- assert.ok(error instanceof Error);
271
- assert.ok(error.message.includes('404'), 'Should surface the project 404');
272
- }
273
- assert.ok(didThrow, 'Should have failed on the project 404');
263
+ const result = await client.callTool('list_work_items', {
264
+ project_id: '789',
265
+ });
266
+ assert.strictEqual(result.isError, true, 'Should return the project 404 as a tool error');
267
+ assert.ok(result.content, 'Should have error content');
268
+ const content = result.content[0];
269
+ assert.ok('text' in content, 'Error content should have text');
270
+ assert.ok(content.text.includes('404'), 'Should surface the project 404');
274
271
  assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
275
272
  console.log(' ✓ Bare numeric ID 789 did not fall back to /groups/789');
276
273
  });
277
274
  test('should not fall back from explicit numeric project IDs to groups', async () => {
278
275
  group789Requested = false;
279
- let didThrow = false;
280
- try {
281
- await client.callTool('list_work_items', {
282
- project_id: 'project:789',
283
- });
284
- }
285
- catch (error) {
286
- didThrow = true;
287
- assert.ok(error instanceof Error);
288
- assert.ok(error.message.includes('404'), 'Should surface the project 404');
289
- }
290
- assert.ok(didThrow, 'Should have failed on the project 404');
276
+ const result = await client.callTool('list_work_items', {
277
+ project_id: 'project:789',
278
+ });
279
+ assert.strictEqual(result.isError, true, 'Should return the project 404 as a tool error');
280
+ assert.ok(result.content, 'Should have error content');
281
+ const content = result.content[0];
282
+ assert.ok('text' in content, 'Error content should have text');
283
+ assert.ok(content.text.includes('404'), 'Should surface the project 404');
291
284
  assert.strictEqual(group789Requested, false, 'Should not request /groups/789');
292
285
  console.log(' ✓ Explicit project:789 did not fall back to /groups/789');
293
286
  });
@@ -318,6 +311,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
318
311
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
319
312
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
320
313
  GITLAB_READ_ONLY_MODE: 'true',
314
+ GITLAB_TOOLSETS: 'all',
321
315
  }
322
316
  });
323
317
  servers.push(server);
@@ -484,7 +478,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
484
478
  REMOTE_AUTHORIZATION: 'true',
485
479
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
486
480
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
487
- GITLAB_TOOLSETS: 'variables',
481
+ GITLAB_TOOLSETS: 'variables,repositories,groups,projects',
488
482
  }
489
483
  });
490
484
  servers.push(server);
@@ -582,7 +576,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
582
576
  REMOTE_AUTHORIZATION: 'true',
583
577
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
584
578
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
585
- GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines',
579
+ GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines,repositories,groups,projects',
586
580
  }
587
581
  });
588
582
  servers.push(server);
@@ -31,6 +31,8 @@ async function launchMcpServer(extraEnv = {}) {
31
31
  REMOTE_AUTHORIZATION: "true",
32
32
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
33
33
  GITLAB_TOOLSETS: "all",
34
+ // execute_graphql is in no toolset; opt in so the guards under test are reachable.
35
+ GITLAB_TOOLS: "execute_graphql",
34
36
  ...extraEnv,
35
37
  },
36
38
  });
@@ -280,6 +282,90 @@ describe("Permission Mode", { concurrency: 1 }, () => {
280
282
  }
281
283
  });
282
284
  });
285
+ describe("call-time enforcement of list filters (modify mode)", () => {
286
+ const servers = [];
287
+ after(() => cleanupServers(servers));
288
+ /** Calls a tool on a fresh client and returns the serialized result, or the error message. */
289
+ async function callOutcome(server, name, args) {
290
+ const client = await connectClient(server);
291
+ try {
292
+ return JSON.stringify(await client.callTool(name, args));
293
+ }
294
+ catch (error) {
295
+ return error instanceof Error ? error.message : String(error);
296
+ }
297
+ finally {
298
+ await client.disconnect();
299
+ }
300
+ }
301
+ test("rejects a regex-denied tool and an execute_graphql with no GITLAB_TOOLS entry", async () => {
302
+ const server = await launchMcpServer({
303
+ GITLAB_PERMISSION_MODE: "modify",
304
+ GITLAB_TOOLS: "",
305
+ GITLAB_DENIED_TOOLS_REGEX: "^merge_merge_request$",
306
+ });
307
+ servers.push(server);
308
+ const names = await getToolNames(server);
309
+ assert.ok(!names.includes("merge_merge_request"), "regex-denied tool must be unlisted");
310
+ assert.ok(!names.includes("execute_graphql"), "execute_graphql must be unlisted");
311
+ const merge = await callOutcome(server, "merge_merge_request", {
312
+ project_id: "1",
313
+ merge_request_iid: 1,
314
+ });
315
+ assert.ok(merge.includes("not available"), `merge_merge_request: ${merge}`);
316
+ const graphql = await callOutcome(server, "execute_graphql", {
317
+ query: "query { currentUser { id } }",
318
+ });
319
+ assert.ok(graphql.includes("not available"), `execute_graphql: ${graphql}`);
320
+ });
321
+ test("policy-hidden tool is unlisted but still callable", async () => {
322
+ const server = await launchMcpServer({
323
+ GITLAB_PERMISSION_MODE: "modify",
324
+ GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
325
+ });
326
+ servers.push(server);
327
+ const names = await getToolNames(server);
328
+ assert.ok(!names.includes("list_issues"), "hidden tool must be unlisted");
329
+ const outcome = await callOutcome(server, "list_issues", { project_id: "1" });
330
+ assert.ok(!outcome.includes("not available"), `hidden tool must stay callable: ${outcome}`);
331
+ });
332
+ test("policy-hidden tool that is also regex-denied is rejected", async () => {
333
+ const server = await launchMcpServer({
334
+ GITLAB_PERMISSION_MODE: "modify",
335
+ GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
336
+ GITLAB_DENIED_TOOLS_REGEX: "^list_issues$",
337
+ });
338
+ servers.push(server);
339
+ const names = await getToolNames(server);
340
+ assert.ok(!names.includes("list_issues"), "hidden tool must be unlisted");
341
+ const outcome = await callOutcome(server, "list_issues", { project_id: "1" });
342
+ assert.ok(outcome.includes("not available"), `regex denial must win over hidden exemption: ${outcome}`);
343
+ });
344
+ test("tool outside the enabled toolsets is rejected until discover_tools activates it", async () => {
345
+ const server = await launchMcpServer({
346
+ GITLAB_PERMISSION_MODE: "modify",
347
+ GITLAB_TOOLSETS: "issues",
348
+ });
349
+ servers.push(server);
350
+ const client = await connectClient(server);
351
+ try {
352
+ const before = await client
353
+ .callTool("list_pipelines", { project_id: "1" })
354
+ .then(r => JSON.stringify(r))
355
+ .catch((e) => e.message);
356
+ assert.ok(before.includes("not available"), `before activation: ${before}`);
357
+ await client.callTool("discover_tools", { category: "pipelines" });
358
+ const after = await client
359
+ .callTool("list_pipelines", { project_id: "1" })
360
+ .then(r => JSON.stringify(r))
361
+ .catch((e) => e.message);
362
+ assert.ok(!after.includes("not available"), `after activation: ${after}`);
363
+ }
364
+ finally {
365
+ await client.disconnect();
366
+ }
367
+ });
368
+ });
283
369
  describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
284
370
  let server;
285
371
  const servers = [];
@@ -255,7 +255,9 @@ describe("Pipeline schedule tools", () => {
255
255
  assert.strictEqual(result.inputs[0].name, "deploy_strategy");
256
256
  });
257
257
  test("get_pipeline_schedule surfaces a 404 as an error", async () => {
258
- await assert.rejects(() => callToolJson("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv), /404/);
258
+ const result = await callTool("get_pipeline_schedule", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: MISSING_SCHEDULE_ID }, baseEnv);
259
+ assert.strictEqual(result.isError, true);
260
+ assert.match(result.content?.[0]?.text ?? "", /404/);
259
261
  });
260
262
  test("list_pipeline_schedule_pipelines returns the triggered pipelines", async () => {
261
263
  const result = await callToolJson("list_pipeline_schedule_pipelines", { project_id: TEST_PROJECT_ID, pipeline_schedule_id: TEST_SCHEDULE_ID }, baseEnv);
@@ -1,6 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "../../utils/url.js";
3
+ import { encodeGitLabPath, encodeGitLabPathSegment, isCleartextRemoteUrl, normalizeGitLabApiUrl, } from "../../utils/url.js";
4
+ describe("When isCleartextRemoteUrl runs", () => {
5
+ test("should flag http to a remote host", () => {
6
+ assert.equal(isCleartextRemoteUrl("http://gitlab.example.com/api/v4"), true);
7
+ });
8
+ test("should allow https", () => {
9
+ assert.equal(isCleartextRemoteUrl("https://gitlab.example.com/api/v4"), false);
10
+ });
11
+ test("should allow http to loopback hosts", () => {
12
+ assert.equal(isCleartextRemoteUrl("http://127.0.0.1:8080/api/v4"), false);
13
+ assert.equal(isCleartextRemoteUrl("http://localhost/api/v4"), false);
14
+ assert.equal(isCleartextRemoteUrl("http://[::1]:3000/api/v4"), false);
15
+ });
16
+ });
4
17
  describe("When normalizeGitLabApiUrl runs", () => {
5
18
  test("should default to gitlab.com", () => {
6
19
  assert.equal(normalizeGitLabApiUrl(""), "https://gitlab.com/api/v4");
@@ -0,0 +1,38 @@
1
+ const MAX_PATTERN_LENGTH = 200;
2
+ const NESTED_QUANTIFIER_PATTERN = /(\(.*[+*?].*\)|\[.*\])[+*?]/;
3
+ // Only allow safe characters in tool regex patterns to prevent regex injection (CodeQL)
4
+ const SAFE_PATTERN_CHARS = /^[a-zA-Z0-9_|^$().*+?{}[\]\\:,\s-]+$/;
5
+ export function compileDeniedToolsRegex(pattern) {
6
+ if (!pattern) {
7
+ return { regex: undefined, error: undefined };
8
+ }
9
+ if (pattern.length > MAX_PATTERN_LENGTH) {
10
+ return {
11
+ regex: undefined,
12
+ error: `GITLAB_DENIED_TOOLS_REGEX pattern exceeds ${MAX_PATTERN_LENGTH} chars. Ignoring.`,
13
+ };
14
+ }
15
+ if (!SAFE_PATTERN_CHARS.test(pattern)) {
16
+ return {
17
+ regex: undefined,
18
+ error: "GITLAB_DENIED_TOOLS_REGEX contains invalid characters. Ignoring.",
19
+ };
20
+ }
21
+ if (NESTED_QUANTIFIER_PATTERN.test(pattern)) {
22
+ return {
23
+ regex: undefined,
24
+ error: "GITLAB_DENIED_TOOLS_REGEX contains potentially unsafe nested quantifiers. Ignoring.",
25
+ };
26
+ }
27
+ try {
28
+ const regex = new RegExp(pattern);
29
+ regex.test("sample_tool_name");
30
+ return { regex, error: undefined };
31
+ }
32
+ catch {
33
+ return {
34
+ regex: undefined,
35
+ error: `Invalid GITLAB_DENIED_TOOLS_REGEX pattern: "${pattern}". Ignoring.`,
36
+ };
37
+ }
38
+ }