@zereight/mcp-gitlab 2.1.67 → 2.1.69

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 (39) hide show
  1. package/README.ko.md +8 -4
  2. package/README.md +14 -5
  3. package/README.pt-BR.md +589 -0
  4. package/README.zh-CN.md +8 -4
  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 +329 -44
  18. package/build/oauth.js +93 -10
  19. package/build/schemas.js +187 -0
  20. package/build/scripts/generate-tool-docs.js +7 -1
  21. package/build/scripts/tool-coverage/coverage.js +2 -0
  22. package/build/test/cli/args.test.js +175 -0
  23. package/build/test/cli/boolean-flags.test.js +106 -0
  24. package/build/test/cli/compact-mcp-result.test.js +93 -0
  25. package/build/test/cli/exposure.test.js +104 -0
  26. package/build/test/cli/load-config-snapshot.js +5 -0
  27. package/build/test/cli/output.test.js +74 -0
  28. package/build/test/cli/replay-command.test.js +37 -0
  29. package/build/test/cli/router.test.js +183 -0
  30. package/build/test/oauth-device-flow-tests.js +10 -0
  31. package/build/test/oauth-startup.test.js +204 -0
  32. package/build/test/test-dynamic-project-scope.js +162 -0
  33. package/build/test/test-geteffectiveprojectid.js +4 -2
  34. package/build/test/test-permission-mode.js +87 -0
  35. package/build/test/test-snippets.js +735 -0
  36. package/build/test/test-toolset-filtering.js +3 -0
  37. package/build/tools/denied-regex.js +38 -0
  38. package/build/tools/registry.js +42 -1
  39. package/package.json +1 -1
@@ -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
+ });
@@ -5,6 +5,22 @@ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server
5
5
  import { CustomHeaderClient } from "./clients/custom-header-client.js";
6
6
  const MOCK_TOKEN = "glpat-dynamic-scope-token";
7
7
  const SCOPE_HEADER = "x-gitlab-allowed-project-ids";
8
+ function parseToolJson(result) {
9
+ assert.ok(result.isError !== true, "tool should succeed");
10
+ const first = result.content[0];
11
+ assert.ok(first !== undefined && first.type === "text");
12
+ return JSON.parse(first.text);
13
+ }
14
+ function snippetTitleFrom(value) {
15
+ if (value === null || typeof value !== "object" || !("title" in value)) {
16
+ assert.fail("expected a snippet object with title");
17
+ }
18
+ const title = value.title;
19
+ if (typeof title !== "string") {
20
+ assert.fail("expected snippet title to be a string");
21
+ }
22
+ return title;
23
+ }
8
24
  async function connectClient(mcpUrl, scope) {
9
25
  const headers = { authorization: `Bearer ${MOCK_TOKEN}` };
10
26
  if (scope !== undefined) {
@@ -63,6 +79,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
63
79
  ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
64
80
  ENABLE_STRICT_PROJECT_SCOPE: "true",
65
81
  GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
82
+ GITLAB_TOOLS: "execute_graphql",
66
83
  GITLAB_ALLOWED_PROJECT_IDS: "1,2",
67
84
  },
68
85
  });
@@ -270,6 +287,7 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
270
287
  REMOTE_AUTHORIZATION: "true",
271
288
  ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
272
289
  GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
290
+ GITLAB_TOOLS: "execute_graphql,get_vulnerability",
273
291
  },
274
292
  });
275
293
  servers.push(server);
@@ -323,6 +341,150 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
323
341
  }
324
342
  });
325
343
  });
344
+ describe("with session-scoped snippet tools", () => {
345
+ let mockGitLab;
346
+ const servers = [];
347
+ let mcpUrl;
348
+ const snippetHits = [];
349
+ before(async () => {
350
+ const mockPort = await findMockServerPort();
351
+ mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
352
+ mockGitLab.addMockHandler("get", "/snippets", (_req, res) => {
353
+ snippetHits.push("personal:list");
354
+ res.json([{ id: 99, title: "personal snippet of the token owner" }]);
355
+ });
356
+ mockGitLab.addMockHandler("get", "/projects/5/snippets", (_req, res) => {
357
+ snippetHits.push("project:list");
358
+ res.json([{ id: 42, title: "project snippet" }]);
359
+ });
360
+ mockGitLab.addMockHandler("get", "/projects/5/snippets/42", (_req, res) => {
361
+ snippetHits.push("project:get");
362
+ res.json({ id: 42, title: "project snippet" });
363
+ });
364
+ mockGitLab.addMockHandler("get", "/snippets/42", (_req, res) => {
365
+ snippetHits.push("personal:get");
366
+ res.json({ id: 42, title: "personal snippet of the token owner" });
367
+ });
368
+ mockGitLab.addMockHandler("delete", "/snippets/99", (_req, res) => {
369
+ snippetHits.push("personal:delete");
370
+ res.status(204).end();
371
+ });
372
+ mockGitLab.addMockHandler("delete", "/projects/5/snippets/42", (_req, res) => {
373
+ snippetHits.push("project:delete");
374
+ res.status(204).end();
375
+ });
376
+ mockGitLab.addMockHandler("post", "/snippets", (_req, res) => {
377
+ snippetHits.push("personal:create");
378
+ res.json({ id: 99, title: "personal snippet of the token owner" });
379
+ });
380
+ mockGitLab.addMockHandler("post", "/projects/5/snippets", (_req, res) => {
381
+ snippetHits.push("project:create");
382
+ res.json({ id: 42, title: "project snippet" });
383
+ });
384
+ mockGitLab.addMockHandler("put", "/snippets/42", (_req, res) => {
385
+ snippetHits.push("personal:update");
386
+ res.json({ id: 42, title: "updated personal snippet" });
387
+ });
388
+ mockGitLab.addMockHandler("put", "/projects/5/snippets/42", (_req, res) => {
389
+ snippetHits.push("project:update");
390
+ res.json({ id: 42, title: "updated project snippet" });
391
+ });
392
+ await mockGitLab.start();
393
+ const mcpPort = await findAvailablePort(3200);
394
+ const server = await launchServer({
395
+ mode: TransportMode.STREAMABLE_HTTP,
396
+ port: mcpPort,
397
+ timeout: 5000,
398
+ env: {
399
+ STREAMABLE_HTTP: "true",
400
+ REMOTE_AUTHORIZATION: "true",
401
+ ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
402
+ GITLAB_TOOLSETS: "snippets",
403
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
404
+ },
405
+ });
406
+ servers.push(server);
407
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
408
+ });
409
+ after(async () => {
410
+ cleanupServers(servers);
411
+ await mockGitLab?.stop();
412
+ });
413
+ test("list_snippets without project_id stays within the session scope", async () => {
414
+ snippetHits.length = 0;
415
+ const client = await connectClient(mcpUrl, "5");
416
+ try {
417
+ const result = await client.callTool("list_snippets", {});
418
+ assert.ok(result.content, "Should have content");
419
+ const content = result.content[0];
420
+ assert.ok("text" in content, "Content should have text");
421
+ const snippets = JSON.parse(content.text);
422
+ assert.strictEqual(snippets[0].title, "project snippet");
423
+ }
424
+ finally {
425
+ await client.disconnect();
426
+ }
427
+ assert.deepStrictEqual(snippetHits, ["project:list"], "must route to the scoped project, not the personal /snippets endpoint");
428
+ });
429
+ test("delete_snippet without project_id stays within the session scope", async () => {
430
+ snippetHits.length = 0;
431
+ const client = await connectClient(mcpUrl, "5");
432
+ try {
433
+ await client.callTool("delete_snippet", { snippet_id: 42 });
434
+ }
435
+ finally {
436
+ await client.disconnect();
437
+ }
438
+ assert.deepStrictEqual(snippetHits, ["project:delete"], "must route to the scoped project, not the personal /snippets endpoint");
439
+ });
440
+ test("get_snippet without project_id stays within the session scope", async () => {
441
+ snippetHits.length = 0;
442
+ const client = await connectClient(mcpUrl, "5");
443
+ try {
444
+ const result = await client.callTool("get_snippet", { snippet_id: 42 });
445
+ assert.ok(result.content, "Should have content");
446
+ const content = result.content[0];
447
+ assert.ok("text" in content, "Content should have text");
448
+ const snippet = JSON.parse(content.text);
449
+ assert.strictEqual(snippet.title, "project snippet");
450
+ }
451
+ finally {
452
+ await client.disconnect();
453
+ }
454
+ assert.deepStrictEqual(snippetHits, ["project:get"], "must route to the scoped project, not the personal /snippets endpoint");
455
+ });
456
+ test("create_snippet without project_id stays within the session scope", async () => {
457
+ snippetHits.length = 0;
458
+ const client = await connectClient(mcpUrl, "5");
459
+ try {
460
+ const result = await client.callTool("create_snippet", {
461
+ title: "scoped create",
462
+ file_name: "note.md",
463
+ content: "hi",
464
+ });
465
+ assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "project snippet");
466
+ }
467
+ finally {
468
+ await client.disconnect();
469
+ }
470
+ assert.deepStrictEqual(snippetHits, ["project:create"], "must route to the scoped project, not the personal /snippets endpoint");
471
+ });
472
+ test("update_snippet without project_id stays within the session scope", async () => {
473
+ snippetHits.length = 0;
474
+ const client = await connectClient(mcpUrl, "5");
475
+ try {
476
+ const result = await client.callTool("update_snippet", {
477
+ snippet_id: 42,
478
+ title: "scoped update",
479
+ });
480
+ assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "updated project snippet");
481
+ }
482
+ finally {
483
+ await client.disconnect();
484
+ }
485
+ assert.deepStrictEqual(snippetHits, ["project:update"], "must route to the scoped project, not the personal /snippets endpoint");
486
+ });
487
+ });
326
488
  describe("with ENABLE_DYNAMIC_PROJECT_SCOPE disabled", () => {
327
489
  let mockGitLab;
328
490
  const servers = [];
@@ -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);
@@ -318,6 +319,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
318
319
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
319
320
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
320
321
  GITLAB_READ_ONLY_MODE: 'true',
322
+ GITLAB_TOOLSETS: 'all',
321
323
  }
322
324
  });
323
325
  servers.push(server);
@@ -484,7 +486,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
484
486
  REMOTE_AUTHORIZATION: 'true',
485
487
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
486
488
  GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
487
- GITLAB_TOOLSETS: 'variables',
489
+ GITLAB_TOOLSETS: 'variables,repositories,groups,projects',
488
490
  }
489
491
  });
490
492
  servers.push(server);
@@ -582,7 +584,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
582
584
  REMOTE_AUTHORIZATION: 'true',
583
585
  GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
584
586
  GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
585
- GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines',
587
+ GITLAB_TOOLSETS: 'variables,merge_requests,issues,pipelines,repositories,groups,projects',
586
588
  }
587
589
  });
588
590
  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
  });
@@ -83,6 +85,7 @@ const DELETE_SAMPLE_TOOLS = [
83
85
  "delete_group_milestone",
84
86
  "delete_project_variable",
85
87
  "delete_webhook",
88
+ "delete_snippet",
86
89
  ];
87
90
  // Destructive teardown tools whose names do not start with `delete_`. All five are listed
88
91
  // here (list-hiding) and exercised with explicit `callTool` literals below (call-rejection),
@@ -279,6 +282,90 @@ describe("Permission Mode", { concurrency: 1 }, () => {
279
282
  }
280
283
  });
281
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
+ });
282
369
  describe("readonly mode via GITLAB_PERMISSION_MODE", () => {
283
370
  let server;
284
371
  const servers = [];