@zereight/mcp-gitlab 2.1.68 → 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.
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { formatCliError, formatToolOutput } from "../../cli/output.js";
4
+ describe("When formatToolOutput prints a tool result", () => {
5
+ describe("with json mode", () => {
6
+ it("should write the unwrapped payload as JSON", () => {
7
+ const formatted = formatToolOutput({
8
+ result: { content: [{ type: "text", text: JSON.stringify({ id: 1, username: "tao" }) }] },
9
+ mode: "json",
10
+ tableSpec: undefined,
11
+ });
12
+ assert.equal(formatted.stdout, "{\"id\":1,\"username\":\"tao\"}\n");
13
+ assert.equal(formatted.stderr, "");
14
+ assert.equal(formatted.isError, false);
15
+ });
16
+ });
17
+ describe("with table mode for a list", () => {
18
+ it("should print iid state title columns", () => {
19
+ const formatted = formatToolOutput({
20
+ result: {
21
+ content: [
22
+ {
23
+ type: "text",
24
+ text: JSON.stringify([
25
+ { iid: 45, state: "opened", title: "fix: pipeline retry race" },
26
+ { iid: 44, state: "opened", title: "chore: bump deps" },
27
+ ]),
28
+ },
29
+ ],
30
+ },
31
+ mode: "table",
32
+ tableSpec: { kind: "list", columns: ["iid", "state", "title"] },
33
+ });
34
+ assert.match(formatted.stdout, /IID\s+STATE\s+TITLE/);
35
+ assert.match(formatted.stdout, /45\s+opened\s+fix: pipeline retry race/);
36
+ assert.equal(formatted.stderr, "");
37
+ });
38
+ });
39
+ describe("with a non-text content block first", () => {
40
+ it("should unwrap the first text payload", () => {
41
+ const formatted = formatToolOutput({
42
+ result: {
43
+ content: [
44
+ { type: "image", data: "xxx" },
45
+ { type: "text", text: JSON.stringify({ id: 2 }) },
46
+ ],
47
+ },
48
+ mode: "json",
49
+ tableSpec: undefined,
50
+ });
51
+ assert.equal(formatted.stdout, "{\"id\":2}\n");
52
+ });
53
+ });
54
+ describe("with an API-style error object", () => {
55
+ it("should mark the result as an error", () => {
56
+ const formatted = formatToolOutput({
57
+ result: { content: [{ type: "text", text: "GitLab API error: 404 Not Found" }], isError: true },
58
+ mode: "json",
59
+ tableSpec: undefined,
60
+ });
61
+ assert.equal(formatted.isError, true);
62
+ });
63
+ });
64
+ });
65
+ describe("When formatCliError prints a thrown error", () => {
66
+ describe("with a token in the message", () => {
67
+ it("should redact the token", () => {
68
+ const leaked = `glpat-${"notarealtokenvalue"}`;
69
+ const text = formatCliError(new Error(`auth failed ${leaked}`));
70
+ assert.match(text, /\[REDACTED\]/);
71
+ assert.equal(text.includes(leaked), false);
72
+ });
73
+ });
74
+ });
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { buildToolReplayCommand } from "../../cli/replay-command.js";
4
+ describe("When buildToolReplayCommand prints a CLI invocation", () => {
5
+ describe("with scalar tool arguments", () => {
6
+ it("should emit kebab-case flags without the token", () => {
7
+ const command = buildToolReplayCommand("list_issues", {
8
+ project_id: "123",
9
+ state: "opened",
10
+ token: "glpat-secret",
11
+ });
12
+ assert.equal(command, "zereight-mcp-gitlab tool list_issues --project-id 123 --state opened");
13
+ });
14
+ });
15
+ describe("with a nested object", () => {
16
+ it("should pass it through --args-json", () => {
17
+ const command = buildToolReplayCommand("execute_graphql", {
18
+ query: "{ currentUser { id } }",
19
+ variables: { n: 1 },
20
+ });
21
+ assert.match(command, /tool execute_graphql/);
22
+ assert.match(command, /--query/);
23
+ assert.match(command, /--args-json/);
24
+ assert.equal(command.includes("glpat"), false);
25
+ });
26
+ });
27
+ describe("with confirmation metadata", () => {
28
+ it("should omit _confirmed", () => {
29
+ const command = buildToolReplayCommand("push_files", {
30
+ project_id: "1",
31
+ _confirmed: true,
32
+ });
33
+ assert.equal(command.includes("_confirmed"), false);
34
+ assert.match(command, /--project-id 1/);
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,183 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isCliInvocation, resolveCli } from "../../cli/router.js";
4
+ function argv(...args) {
5
+ return ["node", "index.js", ...args];
6
+ }
7
+ const OPEN_EXPOSURE = {
8
+ isExposed() {
9
+ return true;
10
+ },
11
+ needsConfirmation() {
12
+ return false;
13
+ },
14
+ exposureRefusal() {
15
+ return undefined;
16
+ },
17
+ };
18
+ function resolve(args, extra = {}) {
19
+ return resolveCli(args, { exposure: extra.exposure ?? OPEN_EXPOSURE, permissionMode: extra.permissionMode });
20
+ }
21
+ describe("When resolveCli classifies argv", () => {
22
+ describe("with no positional command", () => {
23
+ it("should stay on the MCP server path", () => {
24
+ const resolved = resolve(argv("--token", "glpat-xxxxxxxxxxxxxxxxxxxx"));
25
+ assert.equal(resolved.kind, "server");
26
+ });
27
+ });
28
+ describe("with --help", () => {
29
+ it("should print global help without starting the server", () => {
30
+ const resolved = resolve(argv("--help"));
31
+ assert.equal(resolved.kind, "help");
32
+ if (resolved.kind === "help") {
33
+ assert.match(resolved.text, /tool <tool-name>/);
34
+ assert.match(resolved.text, /auth/);
35
+ }
36
+ });
37
+ });
38
+ describe("with --help after an unknown positional", () => {
39
+ it("should print global help instead of starting the server", () => {
40
+ const resolved = resolve(argv("not-a-command", "--help"));
41
+ assert.equal(resolved.kind, "help");
42
+ });
43
+ });
44
+ describe("with tool whoami", () => {
45
+ it("should run whoami with json output by default", () => {
46
+ const resolved = resolve(argv("tool", "whoami"));
47
+ assert.equal(resolved.kind, "run");
48
+ if (resolved.kind === "run") {
49
+ assert.equal(resolved.toolName, "whoami");
50
+ assert.equal(resolved.output, "json");
51
+ assert.deepEqual(resolved.args, {});
52
+ }
53
+ });
54
+ });
55
+ });
56
+ describe("When a curated command is resolved", () => {
57
+ describe("with mr list --group-id", () => {
58
+ it("should call list_group_merge_requests", () => {
59
+ const resolved = resolve(argv("mr", "list", "--group-id", "42", "--state", "opened"));
60
+ assert.equal(resolved.kind, "run");
61
+ if (resolved.kind === "run") {
62
+ assert.equal(resolved.toolName, "list_group_merge_requests");
63
+ assert.equal(resolved.args.group_id, "42");
64
+ assert.equal(resolved.output, "table");
65
+ }
66
+ });
67
+ });
68
+ describe("with issue close", () => {
69
+ it("should call update_issue with state_event close", () => {
70
+ const resolved = resolve(argv("issue", "close", "--project-id", "123", "--issue-iid", "9"));
71
+ assert.equal(resolved.kind, "run");
72
+ if (resolved.kind === "run") {
73
+ assert.equal(resolved.toolName, "update_issue");
74
+ assert.equal(resolved.args.issue_iid, "9");
75
+ assert.equal(resolved.args.state_event, "close");
76
+ }
77
+ });
78
+ });
79
+ describe("with mr merge and --yes=false", () => {
80
+ it("should reject the inline value instead of merging", () => {
81
+ const resolved = resolve(argv("mr", "merge", "--project-id", "123", "--mr-iid", "45", "--yes=false"));
82
+ assert.equal(resolved.kind, "usage");
83
+ if (resolved.kind === "usage") {
84
+ assert.match(resolved.message, /--yes does not accept a value/);
85
+ }
86
+ });
87
+ });
88
+ describe("with mr merge and no --yes", () => {
89
+ it("should refuse until --yes is passed without dumping argument values", () => {
90
+ const resolved = resolve(argv("mr", "merge", "--project-id", "123", "--mr-iid", "45"));
91
+ assert.equal(resolved.kind, "refused");
92
+ if (resolved.kind === "refused") {
93
+ assert.match(resolved.message, /--yes/);
94
+ assert.match(resolved.message, /merge_merge_request/);
95
+ assert.match(resolved.message, /project_id/);
96
+ assert.equal(resolved.message.includes("123"), false);
97
+ }
98
+ });
99
+ });
100
+ describe("with variable list scoped via --args-json", () => {
101
+ it("should call list_group_variables when group_id is only in JSON", () => {
102
+ const resolved = resolve(argv("variable", "list", "--args-json", '{"group_id":"42"}'));
103
+ assert.equal(resolved.kind, "run");
104
+ if (resolved.kind === "run") {
105
+ assert.equal(resolved.toolName, "list_group_variables");
106
+ assert.equal(resolved.args.group_id, "42");
107
+ }
108
+ });
109
+ });
110
+ });
111
+ describe("When permission mode blocks a write", () => {
112
+ describe("with readonly mode and issue create", () => {
113
+ it("should refuse before dispatch", () => {
114
+ const resolved = resolve(argv("issue", "create", "--project-id", "123", "--title", "bug"), { permissionMode: "readonly" });
115
+ assert.equal(resolved.kind, "refused");
116
+ if (resolved.kind === "refused") {
117
+ assert.match(resolved.message, /read-only/);
118
+ }
119
+ });
120
+ });
121
+ });
122
+ describe("When isCliInvocation inspects argv", () => {
123
+ describe("with a curated group", () => {
124
+ it("should treat it as a human CLI invocation", () => {
125
+ assert.equal(isCliInvocation(argv("mr", "list")), true);
126
+ });
127
+ });
128
+ describe("with --help", () => {
129
+ it("should treat it as a human CLI invocation", () => {
130
+ assert.equal(isCliInvocation(argv("--help")), true);
131
+ });
132
+ });
133
+ describe("with only a server token flag", () => {
134
+ it("should stay on the MCP server path", () => {
135
+ assert.equal(isCliInvocation(argv("--token", "x")), false);
136
+ });
137
+ });
138
+ });
139
+ describe("When MCP exposure filters block a CLI tool", () => {
140
+ describe("with a denied-tools regex", () => {
141
+ it("should refuse before dispatch", () => {
142
+ const resolved = resolve(argv("tool", "whoami"), {
143
+ exposure: {
144
+ isExposed() {
145
+ return false;
146
+ },
147
+ needsConfirmation() {
148
+ return false;
149
+ },
150
+ exposureRefusal(toolName) {
151
+ return `${toolName} is blocked by GITLAB_DENIED_TOOLS_REGEX`;
152
+ },
153
+ },
154
+ });
155
+ assert.equal(resolved.kind, "refused");
156
+ if (resolved.kind === "refused") {
157
+ assert.match(resolved.message, /GITLAB_DENIED_TOOLS_REGEX/);
158
+ }
159
+ });
160
+ });
161
+ describe("with GITLAB_TOOL_POLICY_APPROVE", () => {
162
+ it("should require --yes", () => {
163
+ const resolved = resolve(argv("tool", "whoami"), {
164
+ exposure: {
165
+ isExposed() {
166
+ return true;
167
+ },
168
+ needsConfirmation() {
169
+ return true;
170
+ },
171
+ exposureRefusal() {
172
+ return undefined;
173
+ },
174
+ },
175
+ });
176
+ assert.equal(resolved.kind, "refused");
177
+ if (resolved.kind === "refused") {
178
+ assert.match(resolved.message, /GITLAB_TOOL_POLICY_APPROVE/);
179
+ assert.match(resolved.message, /--yes/);
180
+ }
181
+ });
182
+ });
183
+ });
@@ -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,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);
@@ -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
  });
@@ -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 = [];