@zereight/mcp-gitlab 2.0.35 → 2.1.0

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,688 @@
1
+ /**
2
+ * Token Optimization Test Suite
3
+ *
4
+ * Tests for Proposals C (Schema Slimming), F (3-Tier Policy), and H (Dynamic Discovery).
5
+ */
6
+ import { describe, test, after, before } from "node:test";
7
+ import assert from "node:assert";
8
+ import { z } from "zod";
9
+ import { toJSONSchema } from "../utils/schema.js";
10
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
11
+ import { MockGitLabServer, findMockServerPort, } from "./utils/mock-gitlab-server.js";
12
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
13
+ const MOCK_TOKEN = "glpat-token-opt-test";
14
+ // Port bases (offset to avoid collision with other suites)
15
+ const MOCK_PORT_BASE = 9400;
16
+ const MCP_PORT_BASE = 3400;
17
+ let portCounter = 0;
18
+ async function nextMcpPort() {
19
+ return findAvailablePort(MCP_PORT_BASE + portCounter++ * 10);
20
+ }
21
+ // Shared helpers (hoisted to outer scope to satisfy lint)
22
+ async function launchMcp(mockGitLabUrl, extraEnv = {}) {
23
+ const port = await nextMcpPort();
24
+ return launchServer({
25
+ mode: TransportMode.STREAMABLE_HTTP,
26
+ port,
27
+ timeout: 10000,
28
+ env: {
29
+ STREAMABLE_HTTP: "true",
30
+ REMOTE_AUTHORIZATION: "true",
31
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
32
+ ...extraEnv,
33
+ },
34
+ });
35
+ }
36
+ async function getClient(port) {
37
+ const client = new CustomHeaderClient({
38
+ authorization: `Bearer ${MOCK_TOKEN}`,
39
+ });
40
+ await client.connect(`http://${HOST}:${port}/mcp`);
41
+ return client;
42
+ }
43
+ async function rawMcpRequest(url, body, headers = {}) {
44
+ const response = await fetch(url, {
45
+ method: "POST",
46
+ headers: {
47
+ "Content-Type": "application/json",
48
+ Accept: "application/json, text/event-stream",
49
+ ...headers,
50
+ },
51
+ body: JSON.stringify(body),
52
+ });
53
+ const sessionId = response.headers.get("mcp-session-id");
54
+ // Notifications return 202/204 with no body
55
+ if (response.status === 202 || response.status === 204) {
56
+ return { data: null, sessionId };
57
+ }
58
+ if (!response.ok) {
59
+ const text = await response.text();
60
+ throw new Error(`HTTP ${response.status}: ${text}`);
61
+ }
62
+ const contentType = response.headers.get("content-type") || "";
63
+ if (contentType.includes("text/event-stream")) {
64
+ const text = await response.text();
65
+ const lines = text.split("\n");
66
+ let lastData = "";
67
+ for (const line of lines) {
68
+ if (line.startsWith("data: ")) {
69
+ lastData = line.slice(6);
70
+ }
71
+ }
72
+ return { data: lastData ? JSON.parse(lastData) : null, sessionId };
73
+ }
74
+ return { data: await response.json(), sessionId };
75
+ }
76
+ // ============================================================================
77
+ // Suite 1: Schema Slimming (Proposal C)
78
+ // ============================================================================
79
+ describe("Schema Slimming (Proposal C)", () => {
80
+ test("strips $schema key from root", () => {
81
+ const schema = z.object({ name: z.string() });
82
+ const result = toJSONSchema(schema);
83
+ assert.strictEqual(result.$schema, undefined, "$schema should be removed");
84
+ });
85
+ test("strips additionalProperties from root object", () => {
86
+ const schema = z.object({ name: z.string() });
87
+ const result = toJSONSchema(schema);
88
+ assert.strictEqual(result.additionalProperties, undefined, "additionalProperties should be removed");
89
+ });
90
+ test("strips additionalProperties from nested objects", () => {
91
+ const schema = z.object({
92
+ outer: z.object({
93
+ inner: z.string(),
94
+ }),
95
+ });
96
+ const result = toJSONSchema(schema);
97
+ // Check nested object
98
+ const outerProp = result.properties?.outer;
99
+ assert.ok(outerProp, "outer property should exist");
100
+ assert.strictEqual(outerProp.additionalProperties, undefined, "nested additionalProperties should be removed");
101
+ });
102
+ test("preserves type, properties, and required fields", () => {
103
+ const schema = z.object({
104
+ name: z.string(),
105
+ age: z.number().optional(),
106
+ });
107
+ const result = toJSONSchema(schema);
108
+ assert.strictEqual(result.type, "object");
109
+ assert.ok(result.properties?.name, "name property should exist");
110
+ assert.ok(result.properties?.age, "age property should exist");
111
+ // name is required, age is optional
112
+ assert.ok(Array.isArray(result.required) && result.required.includes("name"), "name should be required");
113
+ });
114
+ test("handles nullable fields correctly (not marked as required)", () => {
115
+ const schema = z.object({
116
+ required_field: z.string(),
117
+ nullable_field: z.string().nullable().optional(),
118
+ });
119
+ const result = toJSONSchema(schema);
120
+ const required = result.required || [];
121
+ assert.ok(required.includes("required_field"), "required_field should be required");
122
+ assert.ok(!required.includes("nullable_field"), "nullable_field should NOT be required");
123
+ });
124
+ });
125
+ // ============================================================================
126
+ // Suite 2: Tool Policy (Proposal F) - Integration Tests
127
+ // ============================================================================
128
+ describe("Tool Policy (Proposal F)", { concurrency: 1 }, () => {
129
+ let mockGitLab;
130
+ let mockGitLabUrl;
131
+ before(async () => {
132
+ const mockPort = await findMockServerPort(MOCK_PORT_BASE);
133
+ mockGitLab = new MockGitLabServer({
134
+ port: mockPort,
135
+ validTokens: [MOCK_TOKEN],
136
+ });
137
+ await mockGitLab.start();
138
+ mockGitLabUrl = mockGitLab.getUrl();
139
+ });
140
+ after(async () => {
141
+ if (mockGitLab)
142
+ await mockGitLab.stop();
143
+ });
144
+ // ---- Hidden policy ----
145
+ describe("GITLAB_TOOL_POLICY_HIDDEN", () => {
146
+ let server;
147
+ let client;
148
+ before(async () => {
149
+ server = await launchMcp(mockGitLabUrl, {
150
+ GITLAB_TOOL_POLICY_HIDDEN: "list_issues,get_issue",
151
+ });
152
+ client = await getClient(server.port ?? 0);
153
+ });
154
+ after(async () => {
155
+ await client.disconnect();
156
+ cleanupServers([server]);
157
+ });
158
+ test("hidden tools are not in tool list", async () => {
159
+ const result = await client.listTools();
160
+ const names = new Set(result.tools.map((t) => t.name));
161
+ assert.ok(!names.has("list_issues"), "list_issues should be hidden");
162
+ assert.ok(!names.has("get_issue"), "get_issue should be hidden");
163
+ });
164
+ test("non-hidden tools are still present", async () => {
165
+ const result = await client.listTools();
166
+ const names = new Set(result.tools.map((t) => t.name));
167
+ assert.ok(names.has("create_issue"), "create_issue should be present");
168
+ });
169
+ });
170
+ // ---- Approve policy ----
171
+ describe("GITLAB_TOOL_POLICY_APPROVE", () => {
172
+ let server;
173
+ let client;
174
+ before(async () => {
175
+ server = await launchMcp(mockGitLabUrl, {
176
+ GITLAB_TOOLSETS: "issues",
177
+ GITLAB_TOOL_POLICY_APPROVE: "create_issue",
178
+ });
179
+ client = await getClient(server.port ?? 0);
180
+ });
181
+ after(async () => {
182
+ await client.disconnect();
183
+ cleanupServers([server]);
184
+ });
185
+ test("approve tool is visible in tool list", async () => {
186
+ const result = await client.listTools();
187
+ const names = new Set(result.tools.map((t) => t.name));
188
+ assert.ok(names.has("create_issue"), "create_issue should be in tool list");
189
+ });
190
+ test("approve tool returns confirmation prompt without _confirmed", async () => {
191
+ const result = await client.callTool("create_issue", {
192
+ project_id: "test/project",
193
+ title: "test",
194
+ });
195
+ const text = result.content[0]?.text || "";
196
+ assert.ok(text.includes("requires confirmation"), `Expected confirmation prompt, got: ${text}`);
197
+ });
198
+ });
199
+ // ---- Raw HTTP annotations verification (bypasses SDK stripping) ----
200
+ describe("Raw HTTP annotations", () => {
201
+ let server;
202
+ let mcpUrl;
203
+ let tools = [];
204
+ before(async () => {
205
+ server = await launchMcp(mockGitLabUrl, {
206
+ GITLAB_TOOLSETS: "issues",
207
+ GITLAB_TOOL_POLICY_APPROVE: "create_issue",
208
+ });
209
+ mcpUrl = `http://${HOST}:${server.port ?? 0}/mcp`;
210
+ const authHeader = { authorization: `Bearer ${MOCK_TOKEN}` };
211
+ // Initialize session
212
+ const initRes = await rawMcpRequest(mcpUrl, {
213
+ jsonrpc: "2.0",
214
+ id: 1,
215
+ method: "initialize",
216
+ params: {
217
+ protocolVersion: "2024-11-05",
218
+ capabilities: {},
219
+ clientInfo: { name: "raw-test", version: "1.0.0" },
220
+ },
221
+ }, authHeader);
222
+ const sid = initRes.sessionId ?? "";
223
+ // Send initialized notification
224
+ await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", method: "notifications/initialized" }, { "mcp-session-id": sid, ...authHeader });
225
+ // Get raw tools/list
226
+ const listRes = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sid, ...authHeader });
227
+ tools = listRes.data.result?.tools ?? [];
228
+ });
229
+ after(async () => {
230
+ cleanupServers([server]);
231
+ });
232
+ test("confirmationHint=true on approve-policy tools", () => {
233
+ const createIssue = tools.find((t) => t.name === "create_issue");
234
+ assert.ok(createIssue, "create_issue should be in raw response");
235
+ assert.strictEqual(createIssue.annotations?.confirmationHint, true, "create_issue should have confirmationHint=true");
236
+ });
237
+ test("readOnlyHint=true on read-only tools", () => {
238
+ const listIssues = tools.find((t) => t.name === "list_issues");
239
+ assert.ok(listIssues, "list_issues should be in raw response");
240
+ assert.strictEqual(listIssues.annotations?.readOnlyHint, true, "list_issues should have readOnlyHint=true");
241
+ });
242
+ test("readOnlyHint absent on write tools", () => {
243
+ const createIssue = tools.find((t) => t.name === "create_issue");
244
+ assert.ok(createIssue, "create_issue should exist");
245
+ assert.strictEqual(createIssue.annotations?.readOnlyHint, undefined, "create_issue should NOT have readOnlyHint");
246
+ });
247
+ test("destructiveHint=true on destructive tools", () => {
248
+ const deleteIssue = tools.find((t) => t.name === "delete_issue");
249
+ assert.ok(deleteIssue, "delete_issue should be in issues toolset");
250
+ assert.strictEqual(deleteIssue.annotations?.destructiveHint, true, "delete_issue should have destructiveHint=true");
251
+ });
252
+ test("destructiveHint absent on non-destructive tools", () => {
253
+ const listIssues = tools.find((t) => t.name === "list_issues");
254
+ assert.ok(listIssues, "list_issues should exist");
255
+ assert.strictEqual(listIssues.annotations?.destructiveHint, undefined, "list_issues should NOT have destructiveHint");
256
+ });
257
+ test("openWorldHint=true on all tools", () => {
258
+ assert.ok(tools.length > 0, "Should have tools");
259
+ for (const tool of tools) {
260
+ assert.strictEqual(tool.annotations?.openWorldHint, true, `${tool.name} should have openWorldHint=true`);
261
+ }
262
+ });
263
+ test("annotation combinations: destructive tool has destructive+openWorld but no readOnly", () => {
264
+ const deleteIssue = tools.find((t) => t.name === "delete_issue");
265
+ assert.ok(deleteIssue, "delete_issue should exist");
266
+ const ann = deleteIssue.annotations;
267
+ assert.strictEqual(ann?.destructiveHint, true, "destructiveHint should be true");
268
+ assert.strictEqual(ann?.openWorldHint, true, "openWorldHint should be true");
269
+ assert.strictEqual(ann?.readOnlyHint, undefined, "readOnlyHint should be absent");
270
+ });
271
+ });
272
+ });
273
+ // ============================================================================
274
+ // Suite 2b: Policy Edge Cases
275
+ // ============================================================================
276
+ describe("Policy Edge Cases", { concurrency: 1 }, () => {
277
+ let mockGitLab;
278
+ let mockGitLabUrl;
279
+ before(async () => {
280
+ const mockPort = await findMockServerPort(MOCK_PORT_BASE + 50);
281
+ mockGitLab = new MockGitLabServer({
282
+ port: mockPort,
283
+ validTokens: [MOCK_TOKEN],
284
+ });
285
+ await mockGitLab.start();
286
+ mockGitLabUrl = mockGitLab.getUrl();
287
+ });
288
+ after(async () => {
289
+ if (mockGitLab)
290
+ await mockGitLab.stop();
291
+ });
292
+ // Edge case 1: hidden beats approve when same tool in both
293
+ test("hidden takes precedence over approve for same tool", async () => {
294
+ const server = await launchMcp(mockGitLabUrl, {
295
+ GITLAB_TOOLSETS: "issues",
296
+ GITLAB_TOOL_POLICY_HIDDEN: "create_issue",
297
+ GITLAB_TOOL_POLICY_APPROVE: "create_issue",
298
+ });
299
+ try {
300
+ const client = await getClient(server.port ?? 0);
301
+ const result = await client.listTools();
302
+ const names = new Set(result.tools.map((t) => t.name));
303
+ assert.ok(!names.has("create_issue"), "create_issue should be hidden even if also in approve");
304
+ await client.disconnect();
305
+ }
306
+ finally {
307
+ cleanupServers([server]);
308
+ }
309
+ });
310
+ // Edge case 2: multiple approve tools (with whitespace) get confirmationHint via raw HTTP
311
+ test("multiple approve tools with whitespace get confirmationHint", async () => {
312
+ const server = await launchMcp(mockGitLabUrl, {
313
+ GITLAB_TOOLSETS: "issues",
314
+ GITLAB_TOOL_POLICY_APPROVE: " create_issue , update_issue ",
315
+ });
316
+ try {
317
+ const mcpUrl = `http://${HOST}:${server.port ?? 0}/mcp`;
318
+ const authHeader = { authorization: `Bearer ${MOCK_TOKEN}` };
319
+ const initRes = await rawMcpRequest(mcpUrl, {
320
+ jsonrpc: "2.0",
321
+ id: 1,
322
+ method: "initialize",
323
+ params: {
324
+ protocolVersion: "2024-11-05",
325
+ capabilities: {},
326
+ clientInfo: { name: "raw-test", version: "1.0.0" },
327
+ },
328
+ }, authHeader);
329
+ const sid = initRes.sessionId ?? "";
330
+ await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", method: "notifications/initialized" }, { "mcp-session-id": sid, ...authHeader });
331
+ const listRes = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sid, ...authHeader });
332
+ const tools = listRes.data.result?.tools ?? [];
333
+ const createIssue = tools.find((t) => t.name === "create_issue");
334
+ const updateIssue = tools.find((t) => t.name === "update_issue");
335
+ assert.ok(createIssue, "create_issue should exist");
336
+ assert.ok(updateIssue, "update_issue should exist");
337
+ assert.strictEqual(createIssue.annotations?.confirmationHint, true);
338
+ assert.strictEqual(updateIssue.annotations?.confirmationHint, true);
339
+ // non-approve tool should NOT have confirmationHint
340
+ const listIssues = tools.find((t) => t.name === "list_issues");
341
+ assert.ok(listIssues, "list_issues should exist");
342
+ assert.strictEqual(listIssues.annotations?.confirmationHint, undefined);
343
+ }
344
+ finally {
345
+ cleanupServers([server]);
346
+ }
347
+ });
348
+ // Edge case 3: empty env vars - no crash, normal behavior
349
+ test("empty policy env vars cause no side effects", async () => {
350
+ const server = await launchMcp(mockGitLabUrl, {
351
+ GITLAB_TOOLSETS: "issues",
352
+ GITLAB_TOOL_POLICY_HIDDEN: "",
353
+ GITLAB_TOOL_POLICY_APPROVE: "",
354
+ });
355
+ try {
356
+ const client = await getClient(server.port ?? 0);
357
+ const result = await client.listTools();
358
+ const names = new Set(result.tools.map((t) => t.name));
359
+ // All issues tools should be present
360
+ assert.ok(names.has("create_issue"), "create_issue should be present");
361
+ assert.ok(names.has("list_issues"), "list_issues should be present");
362
+ assert.ok(names.has("delete_issue"), "delete_issue should be present");
363
+ await client.disconnect();
364
+ }
365
+ finally {
366
+ cleanupServers([server]);
367
+ }
368
+ });
369
+ // Edge case 4: non-existent tool name in hidden - silently ignored
370
+ test("non-existent tool name in hidden policy is silently ignored", async () => {
371
+ const server = await launchMcp(mockGitLabUrl, {
372
+ GITLAB_TOOLSETS: "issues",
373
+ GITLAB_TOOL_POLICY_HIDDEN: "nonexistent_tool_xyz_123",
374
+ });
375
+ try {
376
+ const client = await getClient(server.port ?? 0);
377
+ const result = await client.listTools();
378
+ const names = new Set(result.tools.map((t) => t.name));
379
+ // Real tools should be unaffected
380
+ assert.ok(names.has("create_issue"), "create_issue should be present");
381
+ assert.ok(names.has("list_issues"), "list_issues should be present");
382
+ await client.disconnect();
383
+ }
384
+ finally {
385
+ cleanupServers([server]);
386
+ }
387
+ });
388
+ // Edge case 5: hide all tools in toolset - only discover_tools survives
389
+ test("hiding all toolset tools leaves only discover_tools", async () => {
390
+ const allIssueTools = [
391
+ "create_issue", "list_issues", "my_issues", "get_issue",
392
+ "update_issue", "delete_issue", "create_issue_note", "update_issue_note",
393
+ "list_issue_links", "list_issue_discussions", "get_issue_link",
394
+ "create_issue_link", "delete_issue_link", "create_note",
395
+ ].join(",");
396
+ const server = await launchMcp(mockGitLabUrl, {
397
+ GITLAB_TOOLSETS: "issues",
398
+ GITLAB_TOOL_POLICY_HIDDEN: allIssueTools,
399
+ });
400
+ try {
401
+ const client = await getClient(server.port ?? 0);
402
+ const result = await client.listTools();
403
+ const names = result.tools.map((t) => t.name);
404
+ // Only discover_tools should remain (always injected)
405
+ assert.strictEqual(names.length, 1, `Expected only discover_tools, got: ${names.join(", ")}`);
406
+ assert.strictEqual(names[0], "discover_tools");
407
+ await client.disconnect();
408
+ }
409
+ finally {
410
+ cleanupServers([server]);
411
+ }
412
+ });
413
+ // Edge case 6: approve tool with _confirmed=true bypasses confirmation prompt
414
+ test("approve tool executes when _confirmed is true", async () => {
415
+ const server = await launchMcp(mockGitLabUrl, {
416
+ GITLAB_TOOLSETS: "issues",
417
+ GITLAB_TOOL_POLICY_APPROVE: "create_issue",
418
+ });
419
+ try {
420
+ const client = await getClient(server.port ?? 0);
421
+ // Without _confirmed -> confirmation prompt
422
+ const blocked = await client.callTool("create_issue", {
423
+ project_id: "test/project",
424
+ title: "test issue",
425
+ });
426
+ const blockedText = blocked.content[0]?.text || "";
427
+ assert.ok(blockedText.includes("requires confirmation"), "without _confirmed should get prompt");
428
+ // With _confirmed -> bypasses confirmation, may hit mock 404 (expected)
429
+ let passedThrough = false;
430
+ try {
431
+ const result = await client.callTool("create_issue", {
432
+ project_id: "test/project",
433
+ title: "test issue",
434
+ _confirmed: true,
435
+ });
436
+ const text = result.content[0]?.text || "";
437
+ // If we get here, the tool executed (no confirmation prompt)
438
+ passedThrough = !text.includes("requires confirmation");
439
+ }
440
+ catch {
441
+ // Mock 404 / API error = tool DID execute past confirmation gate
442
+ passedThrough = true;
443
+ }
444
+ assert.ok(passedThrough, "_confirmed=true should bypass confirmation gate");
445
+ await client.disconnect();
446
+ }
447
+ finally {
448
+ cleanupServers([server]);
449
+ }
450
+ });
451
+ // Edge case 7: DENIED_TOOLS_REGEX + hidden applied together
452
+ test("regex denial and hidden policy combine correctly", async () => {
453
+ const server = await launchMcp(mockGitLabUrl, {
454
+ GITLAB_TOOLSETS: "issues",
455
+ GITLAB_DENIED_TOOLS_REGEX: "^delete_",
456
+ GITLAB_TOOL_POLICY_HIDDEN: "create_issue",
457
+ });
458
+ try {
459
+ const client = await getClient(server.port ?? 0);
460
+ const result = await client.listTools();
461
+ const names = new Set(result.tools.map((t) => t.name));
462
+ // delete_issue blocked by regex
463
+ assert.ok(!names.has("delete_issue"), "delete_issue should be denied by regex");
464
+ // delete_issue_link also blocked by regex
465
+ assert.ok(!names.has("delete_issue_link"), "delete_issue_link should be denied by regex");
466
+ // create_issue blocked by hidden
467
+ assert.ok(!names.has("create_issue"), "create_issue should be hidden");
468
+ // Other tools survive
469
+ assert.ok(names.has("list_issues"), "list_issues should be present");
470
+ assert.ok(names.has("get_issue"), "get_issue should be present");
471
+ await client.disconnect();
472
+ }
473
+ finally {
474
+ cleanupServers([server]);
475
+ }
476
+ });
477
+ // Edge case 8: approve on read-only tool - both readOnlyHint + confirmationHint
478
+ // Edge case 9: approve on destructive tool - both destructiveHint + confirmationHint
479
+ describe("approve annotation overlap via raw HTTP", () => {
480
+ let server;
481
+ let tools = [];
482
+ before(async () => {
483
+ server = await launchMcp(mockGitLabUrl, {
484
+ GITLAB_TOOLSETS: "issues",
485
+ GITLAB_TOOL_POLICY_APPROVE: "list_issues,delete_issue",
486
+ });
487
+ const mcpUrl = `http://${HOST}:${server.port ?? 0}/mcp`;
488
+ const authHeader = { authorization: `Bearer ${MOCK_TOKEN}` };
489
+ const initRes = await rawMcpRequest(mcpUrl, {
490
+ jsonrpc: "2.0",
491
+ id: 1,
492
+ method: "initialize",
493
+ params: {
494
+ protocolVersion: "2024-11-05",
495
+ capabilities: {},
496
+ clientInfo: { name: "raw-test", version: "1.0.0" },
497
+ },
498
+ }, authHeader);
499
+ const sid = initRes.sessionId ?? "";
500
+ await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", method: "notifications/initialized" }, { "mcp-session-id": sid, ...authHeader });
501
+ const listRes = await rawMcpRequest(mcpUrl, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, { "mcp-session-id": sid, ...authHeader });
502
+ tools = listRes.data.result?.tools ?? [];
503
+ });
504
+ after(async () => {
505
+ cleanupServers([server]);
506
+ });
507
+ test("read-only + approve: both readOnlyHint and confirmationHint present", () => {
508
+ const listIssues = tools.find((t) => t.name === "list_issues");
509
+ assert.ok(listIssues, "list_issues should exist");
510
+ const ann = listIssues.annotations;
511
+ assert.strictEqual(ann?.readOnlyHint, true, "readOnlyHint should be true");
512
+ assert.strictEqual(ann?.confirmationHint, true, "confirmationHint should be true");
513
+ assert.strictEqual(ann?.destructiveHint, undefined, "destructiveHint should be absent");
514
+ });
515
+ test("destructive + approve: both destructiveHint and confirmationHint present", () => {
516
+ const deleteIssue = tools.find((t) => t.name === "delete_issue");
517
+ assert.ok(deleteIssue, "delete_issue should exist");
518
+ const ann = deleteIssue.annotations;
519
+ assert.strictEqual(ann?.destructiveHint, true, "destructiveHint should be true");
520
+ assert.strictEqual(ann?.confirmationHint, true, "confirmationHint should be true");
521
+ assert.strictEqual(ann?.readOnlyHint, undefined, "readOnlyHint should be absent");
522
+ });
523
+ });
524
+ // Edge case 10: hidden tools are invisible but still callable (hidden = ListTools only)
525
+ test("hidden tool is absent from listing but still callable", async () => {
526
+ const server = await launchMcp(mockGitLabUrl, {
527
+ GITLAB_TOOLSETS: "issues",
528
+ GITLAB_TOOL_POLICY_HIDDEN: "list_issues",
529
+ });
530
+ try {
531
+ const client = await getClient(server.port ?? 0);
532
+ // Not visible in tool list
533
+ const result = await client.listTools();
534
+ const names = new Set(result.tools.map((t) => t.name));
535
+ assert.ok(!names.has("list_issues"), "list_issues should be absent from listing");
536
+ // But still callable (hidden only affects ListTools, not CallTool)
537
+ const callResult = await client.callTool("list_issues", {
538
+ project_id: "test/project",
539
+ });
540
+ const text = callResult.content[0]?.text || "";
541
+ // Should get real response (even if error from mock), NOT "unknown tool"
542
+ assert.ok(!text.includes("Unknown tool"), "hidden tool should still be callable");
543
+ await client.disconnect();
544
+ }
545
+ finally {
546
+ cleanupServers([server]);
547
+ }
548
+ });
549
+ });
550
+ // ============================================================================
551
+ // Suite 3: Dynamic Discovery (Proposal H) - Integration Tests
552
+ // ============================================================================
553
+ describe("Dynamic Discovery (Proposal H)", { concurrency: 1 }, () => {
554
+ let mockGitLab;
555
+ let mockGitLabUrl;
556
+ before(async () => {
557
+ const mockPort = await findMockServerPort(MOCK_PORT_BASE + 100);
558
+ mockGitLab = new MockGitLabServer({
559
+ port: mockPort,
560
+ validTokens: [MOCK_TOKEN],
561
+ });
562
+ await mockGitLab.start();
563
+ mockGitLabUrl = mockGitLab.getUrl();
564
+ });
565
+ after(async () => {
566
+ if (mockGitLab)
567
+ await mockGitLab.stop();
568
+ });
569
+ // ---- discover_tools always present ----
570
+ describe("discover_tools availability", () => {
571
+ let server;
572
+ let client;
573
+ before(async () => {
574
+ server = await launchMcp(mockGitLabUrl, {
575
+ GITLAB_TOOLSETS: "issues",
576
+ });
577
+ client = await getClient(server.port ?? 0);
578
+ });
579
+ after(async () => {
580
+ await client.disconnect();
581
+ cleanupServers([server]);
582
+ });
583
+ test("discover_tools is in tool list even with limited toolsets", async () => {
584
+ const result = await client.listTools();
585
+ const names = new Set(result.tools.map((t) => t.name));
586
+ assert.ok(names.has("discover_tools"), "discover_tools should always be present");
587
+ });
588
+ test("discover_tools without category returns category list", async () => {
589
+ const result = await client.callTool("discover_tools", {});
590
+ const text = result.content[0]?.text || "";
591
+ const data = JSON.parse(text);
592
+ assert.ok(Array.isArray(data.categories), "Should return categories array");
593
+ assert.ok(data.categories.length >= 14, `Should have at least 14 categories, got ${data.categories.length}`);
594
+ // Verify each category has expected fields
595
+ const first = data.categories[0];
596
+ assert.ok("id" in first, "Category should have id");
597
+ assert.ok("toolCount" in first, "Category should have toolCount");
598
+ assert.ok("active" in first, "Category should have active status");
599
+ });
600
+ test("discover_tools with invalid category returns error", async () => {
601
+ const result = await client.callTool("discover_tools", {
602
+ category: "nonexistent",
603
+ });
604
+ const text = result.content[0]?.text || "";
605
+ assert.ok(text.includes("Unknown category"), `Expected error message, got: ${text}`);
606
+ });
607
+ });
608
+ // ---- Dynamic toolset activation ----
609
+ describe("toolset activation", () => {
610
+ let server;
611
+ let client;
612
+ before(async () => {
613
+ server = await launchMcp(mockGitLabUrl, {
614
+ GITLAB_TOOLSETS: "issues",
615
+ });
616
+ client = await getClient(server.port ?? 0);
617
+ });
618
+ after(async () => {
619
+ await client.disconnect();
620
+ cleanupServers([server]);
621
+ });
622
+ test("pipelines tools not present before activation", async () => {
623
+ const result = await client.listTools();
624
+ const names = new Set(result.tools.map((t) => t.name));
625
+ assert.ok(!names.has("list_pipelines"), "list_pipelines should NOT be present before activation");
626
+ });
627
+ test("activating pipelines adds the tools", async () => {
628
+ const activateResult = await client.callTool("discover_tools", {
629
+ category: "pipelines",
630
+ });
631
+ const text = activateResult.content[0]?.text || "";
632
+ const data = JSON.parse(text);
633
+ assert.strictEqual(data.activated, "pipelines");
634
+ assert.ok(data.addedTools.includes("list_pipelines"), "list_pipelines should be in addedTools");
635
+ // Verify tools now appear in ListTools
636
+ const listResult = await client.listTools();
637
+ const names = new Set(listResult.tools.map((t) => t.name));
638
+ assert.ok(names.has("list_pipelines"), "list_pipelines should be present after activation");
639
+ assert.ok(names.has("get_pipeline"), "get_pipeline should be present after activation");
640
+ });
641
+ test("re-activating already active toolset returns already-active message", async () => {
642
+ const result = await client.callTool("discover_tools", {
643
+ category: "pipelines",
644
+ });
645
+ const text = result.content[0]?.text || "";
646
+ assert.ok(text.includes("already active"), `Expected already-active message, got: ${text}`);
647
+ });
648
+ });
649
+ // ---- Hidden policy respected by discover_tools ----
650
+ describe("discover_tools respects hidden policy", () => {
651
+ let server;
652
+ let client;
653
+ before(async () => {
654
+ server = await launchMcp(mockGitLabUrl, {
655
+ GITLAB_TOOLSETS: "issues",
656
+ GITLAB_TOOL_POLICY_HIDDEN: "delete_issue,delete_issue_link",
657
+ });
658
+ client = await getClient(server.port ?? 0);
659
+ });
660
+ after(async () => {
661
+ await client.disconnect();
662
+ cleanupServers([server]);
663
+ });
664
+ test("hidden tools not present after discover_tools activation", async () => {
665
+ // Re-activate issues toolset (partially active from GITLAB_TOOLSETS)
666
+ // Force activation of a non-default toolset that also has hidden tools
667
+ const listBefore = await client.listTools();
668
+ const namesBefore = new Set(listBefore.tools.map((t) => t.name));
669
+ assert.ok(!namesBefore.has("delete_issue"), "delete_issue should be hidden initially");
670
+ assert.ok(!namesBefore.has("delete_issue_link"), "delete_issue_link should be hidden initially");
671
+ // Activate pipelines (different toolset) to ensure discover_tools works
672
+ await client.callTool("discover_tools", { category: "pipelines" });
673
+ // Re-list tools - hidden tools from issues should still be absent
674
+ const listAfter = await client.listTools();
675
+ const namesAfter = new Set(listAfter.tools.map((t) => t.name));
676
+ assert.ok(!namesAfter.has("delete_issue"), "delete_issue should remain hidden after discover_tools");
677
+ assert.ok(!namesAfter.has("delete_issue_link"), "delete_issue_link should remain hidden after discover_tools");
678
+ // Non-hidden issues tools should still be present
679
+ assert.ok(namesAfter.has("list_issues"), "list_issues should still be present");
680
+ });
681
+ test("discover_tools with no new tools returns filtered message", async () => {
682
+ // issues toolset already active from GITLAB_TOOLSETS, so re-activating should say already active
683
+ const result = await client.callTool("discover_tools", { category: "issues" });
684
+ const text = result.content[0]?.text || "";
685
+ assert.ok(text.includes("already active") || text.includes("no additional tools"), `Expected already-active or no-additional message, got: ${text}`);
686
+ });
687
+ });
688
+ });