@zereight/mcp-gitlab 2.0.34 → 2.0.35

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,251 @@
1
+ import { describe, test, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { spawn } from 'child_process';
4
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
5
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
6
+ const TEST_PROJECT_ID = '123';
7
+ const TEST_MR_IID = '1';
8
+ // Helper to call list_merge_request_changed_files
9
+ async function callListMergeRequestChangedFiles(args = {}, env) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn('node', ['build/index.js'], {
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ env: {
14
+ ...process.env,
15
+ ...env,
16
+ GITLAB_READ_ONLY_MODE: 'true'
17
+ }
18
+ });
19
+ let output = '';
20
+ let errorOutput = '';
21
+ proc.stdout?.on('data', d => output += d);
22
+ proc.stderr?.on('data', d => errorOutput += d);
23
+ proc.on('close', (code) => {
24
+ if (code !== 0)
25
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
26
+ // Find the JSON line in stdout
27
+ const line = output.split('\n').find(l => l.startsWith('{'));
28
+ if (!line)
29
+ return reject(new Error('No JSON output found'));
30
+ try {
31
+ const response = JSON.parse(line);
32
+ if (response.error) {
33
+ reject(response.error);
34
+ }
35
+ else {
36
+ // Parse the tool result content
37
+ const content = response.result?.content?.[0]?.text;
38
+ if (content) {
39
+ try {
40
+ resolve(JSON.parse(content));
41
+ }
42
+ catch (e) {
43
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
44
+ }
45
+ }
46
+ else {
47
+ resolve(response.result);
48
+ }
49
+ }
50
+ }
51
+ catch (e) {
52
+ reject(e);
53
+ }
54
+ });
55
+ proc.stdin?.end(JSON.stringify({
56
+ jsonrpc: "2.0", id: 1, method: "tools/call",
57
+ params: { name: "list_merge_request_changed_files", arguments: args }
58
+ }) + '\n');
59
+ });
60
+ }
61
+ // Helper to call get_merge_request_file_diff
62
+ async function callGetMergeRequestFileDiff(args = {}, env) {
63
+ return new Promise((resolve, reject) => {
64
+ const proc = spawn('node', ['build/index.js'], {
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ env: {
67
+ ...process.env,
68
+ ...env,
69
+ GITLAB_READ_ONLY_MODE: 'true'
70
+ }
71
+ });
72
+ let output = '';
73
+ let errorOutput = '';
74
+ proc.stdout?.on('data', d => output += d);
75
+ proc.stderr?.on('data', d => errorOutput += d);
76
+ proc.on('close', (code) => {
77
+ if (code !== 0)
78
+ return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
79
+ // Find the JSON line in stdout
80
+ const line = output.split('\n').find(l => l.startsWith('{'));
81
+ if (!line)
82
+ return reject(new Error('No JSON output found'));
83
+ try {
84
+ const response = JSON.parse(line);
85
+ if (response.error) {
86
+ reject(response.error);
87
+ }
88
+ else {
89
+ // Parse the tool result content
90
+ const content = response.result?.content?.[0]?.text;
91
+ if (content) {
92
+ try {
93
+ resolve(JSON.parse(content));
94
+ }
95
+ catch (e) {
96
+ reject(new Error(`Failed to parse tool output JSON: ${content}`));
97
+ }
98
+ }
99
+ else {
100
+ resolve(response.result);
101
+ }
102
+ }
103
+ }
104
+ catch (e) {
105
+ reject(e);
106
+ }
107
+ });
108
+ proc.stdin?.end(JSON.stringify({
109
+ jsonrpc: "2.0", id: 1, method: "tools/call",
110
+ params: { name: "get_merge_request_file_diff", arguments: args }
111
+ }) + '\n');
112
+ });
113
+ }
114
+ describe('list_merge_request_changed_files', () => {
115
+ let mockGitLab;
116
+ let mockGitLabUrl;
117
+ before(async () => {
118
+ const mockPort = await findMockServerPort(9150);
119
+ mockGitLab = new MockGitLabServer({
120
+ port: mockPort,
121
+ validTokens: [MOCK_TOKEN]
122
+ });
123
+ await mockGitLab.start();
124
+ mockGitLabUrl = mockGitLab.getUrl();
125
+ });
126
+ after(async () => {
127
+ await mockGitLab.stop();
128
+ });
129
+ test('returns all changed files without filtering', async () => {
130
+ const files = await callListMergeRequestChangedFiles({ project_id: TEST_PROJECT_ID, merge_request_iid: TEST_MR_IID }, {
131
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
132
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
133
+ });
134
+ assert.ok(Array.isArray(files), 'Response should be an array');
135
+ assert.strictEqual(files.length, 4, 'Should return 4 files');
136
+ // Check structure of returned files
137
+ for (const file of files) {
138
+ assert.ok(file.new_path !== undefined, 'Each file should have new_path');
139
+ assert.ok(file.old_path !== undefined, 'Each file should have old_path');
140
+ }
141
+ assert.strictEqual(files[0].new_path, 'src/index.ts');
142
+ assert.strictEqual(files[1].new_path, 'vendor/package/file.js');
143
+ assert.strictEqual(files[2].new_path, 'README.md');
144
+ assert.strictEqual(files[3].new_path, 'package-lock.json');
145
+ });
146
+ test('filters out vendor folder with ^vendor/ pattern', async () => {
147
+ const files = await callListMergeRequestChangedFiles({
148
+ project_id: TEST_PROJECT_ID,
149
+ merge_request_iid: TEST_MR_IID,
150
+ excluded_file_patterns: ['^vendor/']
151
+ }, {
152
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
153
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
154
+ });
155
+ assert.ok(Array.isArray(files), 'Response should be an array');
156
+ assert.strictEqual(files.length, 3, 'Should return 3 files (vendor filtered out)');
157
+ assert.strictEqual(files[0].new_path, 'src/index.ts');
158
+ assert.strictEqual(files[1].new_path, 'README.md');
159
+ assert.strictEqual(files[2].new_path, 'package-lock.json');
160
+ });
161
+ test('filters multiple patterns at once', async () => {
162
+ const files = await callListMergeRequestChangedFiles({
163
+ project_id: TEST_PROJECT_ID,
164
+ merge_request_iid: TEST_MR_IID,
165
+ excluded_file_patterns: ['^vendor/', 'package-lock\\.json']
166
+ }, {
167
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
168
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
169
+ });
170
+ assert.ok(Array.isArray(files), 'Response should be an array');
171
+ assert.strictEqual(files.length, 2, 'Should return 2 files (vendor and package-lock filtered out)');
172
+ assert.strictEqual(files[0].new_path, 'src/index.ts');
173
+ assert.strictEqual(files[1].new_path, 'README.md');
174
+ });
175
+ });
176
+ describe('get_merge_request_file_diff', () => {
177
+ let mockGitLab;
178
+ let mockGitLabUrl;
179
+ before(async () => {
180
+ const mockPort = await findMockServerPort(9200);
181
+ mockGitLab = new MockGitLabServer({
182
+ port: mockPort,
183
+ validTokens: [MOCK_TOKEN]
184
+ });
185
+ await mockGitLab.start();
186
+ mockGitLabUrl = mockGitLab.getUrl();
187
+ });
188
+ after(async () => {
189
+ await mockGitLab.stop();
190
+ });
191
+ test('returns diffs for existing files in single page', async () => {
192
+ // Request only first few files that fit in one page (per_page=20)
193
+ const fileDiff = await callGetMergeRequestFileDiff({
194
+ project_id: TEST_PROJECT_ID,
195
+ merge_request_iid: TEST_MR_IID,
196
+ file_paths: ['src/index.ts', 'README.md']
197
+ }, {
198
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
199
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
200
+ });
201
+ assert.ok(Array.isArray(fileDiff), 'Response should be an array');
202
+ assert.strictEqual(fileDiff.length, 2, 'Should return 2 diff results');
203
+ // Check that we got the correct files
204
+ const paths = fileDiff.map((f) => f.new_path || f.old_path).sort();
205
+ assert.deepStrictEqual(paths, ['README.md', 'src/index.ts'].sort());
206
+ });
207
+ test('handles pagination when result spans multiple pages', async () => {
208
+ // Request more files than fit in one page (we have 15 total, per_page defaults to 20)
209
+ // but let's use a smaller per_page by testing with unidiff param
210
+ const fileDiff = await callGetMergeRequestFileDiff({
211
+ project_id: TEST_PROJECT_ID,
212
+ merge_request_iid: TEST_MR_IID,
213
+ file_paths: [
214
+ 'src/index.ts',
215
+ 'config/settings.yml',
216
+ 'models/user.go'
217
+ ]
218
+ }, {
219
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
220
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
221
+ });
222
+ assert.ok(Array.isArray(fileDiff), 'Response should be an array');
223
+ assert.strictEqual(fileDiff.length, 3, 'Should return 3 diff results');
224
+ });
225
+ test('returns error objects for not-found files', async () => {
226
+ // Request some existing + non-existing files
227
+ const fileDiff = await callGetMergeRequestFileDiff({
228
+ project_id: TEST_PROJECT_ID,
229
+ merge_request_iid: TEST_MR_IID,
230
+ file_paths: ['src/index.ts', 'nonexistent/file.txt', 'also_missing.py']
231
+ }, {
232
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
233
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN
234
+ });
235
+ assert.ok(Array.isArray(fileDiff), 'Response should be an array');
236
+ // Should return 3 results: 1 success + 2 errors
237
+ assert.strictEqual(fileDiff.length, 3);
238
+ // Find the error entries
239
+ const errorEntries = fileDiff.filter((f) => f.error !== undefined);
240
+ const successEntries = fileDiff.filter((f) => f.error === undefined);
241
+ assert.strictEqual(errorEntries.length, 2, 'Should have 2 error entries');
242
+ assert.strictEqual(successEntries.length, 1, 'Should have 1 success entry');
243
+ // Verify error messages are helpful
244
+ const errorMsgs = errorEntries.map((e) => e.error);
245
+ assert.ok(errorMsgs.some(msg => msg.includes('nonexistent/file.txt')), 'Error message should mention nonexistent file');
246
+ assert.ok(errorMsgs.some(msg => msg.includes('also_missing.py')), 'Error message should mention other missing file');
247
+ // Check hint is present in at least one error
248
+ const hints = errorEntries.map((e) => e.hint).filter(Boolean);
249
+ assert.ok(hints.length > 0, 'Errors should include hints to check list_merge_request_changed_files');
250
+ });
251
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Search Code Tools Test Suite
3
+ *
4
+ * Tests search_code, search_project_code, and search_group_code tools
5
+ * using the mock GitLab server and streamable HTTP transport.
6
+ */
7
+ import { describe, test, after, before } from "node:test";
8
+ import assert from "node:assert";
9
+ import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
10
+ import { MockGitLabServer, findMockServerPort, } from "./utils/mock-gitlab-server.js";
11
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
12
+ const MOCK_TOKEN = "glpat-search-test-token";
13
+ // Port bases that don't conflict with other test suites
14
+ const MOCK_PORT_BASE = 9300;
15
+ const MCP_PORT_BASE = 3300;
16
+ let portCounter = 0;
17
+ async function nextMcpPort() {
18
+ return findAvailablePort(MCP_PORT_BASE + portCounter++ * 10);
19
+ }
20
+ async function launchMcpServer(mockGitLabUrl, mcpPort, extraEnv = {}) {
21
+ return launchServer({
22
+ mode: TransportMode.STREAMABLE_HTTP,
23
+ port: mcpPort,
24
+ timeout: 10000,
25
+ env: {
26
+ STREAMABLE_HTTP: "true",
27
+ REMOTE_AUTHORIZATION: "true",
28
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
29
+ ...extraEnv,
30
+ },
31
+ });
32
+ }
33
+ async function getToolNames(mcpUrl) {
34
+ const client = new CustomHeaderClient({
35
+ authorization: `Bearer ${MOCK_TOKEN}`,
36
+ });
37
+ await client.connect(mcpUrl);
38
+ const result = await client.listTools();
39
+ await client.disconnect();
40
+ return result.tools.map((t) => t.name);
41
+ }
42
+ async function callTool(mcpUrl, toolName, args = {}) {
43
+ const client = new CustomHeaderClient({
44
+ authorization: `Bearer ${MOCK_TOKEN}`,
45
+ });
46
+ await client.connect(mcpUrl);
47
+ const result = await client.callTool(toolName, args);
48
+ await client.disconnect();
49
+ // Parse the tool result content
50
+ const content = result?.content?.[0]?.text;
51
+ if (content) {
52
+ return JSON.parse(content);
53
+ }
54
+ return result;
55
+ }
56
+ // --- Tests ---
57
+ let mockGitLab;
58
+ let mockGitLabUrl;
59
+ describe("Search Code Tools", () => {
60
+ before(async () => {
61
+ const mockPort = await findMockServerPort(MOCK_PORT_BASE);
62
+ mockGitLab = new MockGitLabServer({
63
+ port: mockPort,
64
+ validTokens: [MOCK_TOKEN],
65
+ });
66
+ await mockGitLab.start();
67
+ mockGitLabUrl = mockGitLab.getUrl();
68
+ console.log(`Mock GitLab: ${mockGitLabUrl}`);
69
+ });
70
+ after(async () => {
71
+ if (mockGitLab)
72
+ await mockGitLab.stop();
73
+ });
74
+ // ---- 1. search toolset exposes exactly 3 tools ----
75
+ describe("search toolset exposes exactly 3 tools", () => {
76
+ let server;
77
+ let tools;
78
+ before(async () => {
79
+ const port = await nextMcpPort();
80
+ server = await launchMcpServer(mockGitLabUrl, port, {
81
+ GITLAB_TOOLSETS: "search",
82
+ });
83
+ tools = await getToolNames(`http://${HOST}:${port}/mcp`);
84
+ });
85
+ after(() => cleanupServers([server]));
86
+ test("returns exactly 3 tools", () => {
87
+ assert.strictEqual(tools.length, 3, `Expected 3 tools but got ${tools.length}: ${tools.join(", ")}`);
88
+ });
89
+ test("includes search_code", () => {
90
+ assert.ok(tools.includes("search_code"), "Expected search_code to be present");
91
+ });
92
+ test("includes search_project_code", () => {
93
+ assert.ok(tools.includes("search_project_code"), "Expected search_project_code to be present");
94
+ });
95
+ test("includes search_group_code", () => {
96
+ assert.ok(tools.includes("search_group_code"), "Expected search_group_code to be present");
97
+ });
98
+ });
99
+ // ---- 2. search_code returns blob results ----
100
+ describe("search_code returns blob results", () => {
101
+ let server;
102
+ let mcpUrl;
103
+ before(async () => {
104
+ const port = await nextMcpPort();
105
+ server = await launchMcpServer(mockGitLabUrl, port, {
106
+ GITLAB_TOOLSETS: "search",
107
+ });
108
+ mcpUrl = `http://${HOST}:${port}/mcp`;
109
+ });
110
+ after(() => cleanupServers([server]));
111
+ test("returns array with blob fields", async () => {
112
+ const result = await callTool(mcpUrl, "search_code", { search: "searchResult" });
113
+ assert.ok(Array.isArray(result), "Response should be an array");
114
+ assert.ok(result.length > 0, "Response should have at least one result");
115
+ const first = result[0];
116
+ assert.ok("filename" in first, "Result should have filename field");
117
+ assert.ok("path" in first, "Result should have path field");
118
+ assert.ok("startline" in first, "Result should have startline field");
119
+ });
120
+ test("result has correct filename", async () => {
121
+ const result = await callTool(mcpUrl, "search_code", { search: "searchResult" });
122
+ assert.strictEqual(result[0].filename, "index.ts");
123
+ });
124
+ test("result has correct path", async () => {
125
+ const result = await callTool(mcpUrl, "search_code", { search: "searchResult" });
126
+ assert.strictEqual(result[0].path, "src/index.ts");
127
+ });
128
+ test("result has correct startline", async () => {
129
+ const result = await callTool(mcpUrl, "search_code", { search: "searchResult" });
130
+ assert.strictEqual(result[0].startline, 42);
131
+ });
132
+ });
133
+ // ---- 3. search_project_code returns blob results for project ----
134
+ describe("search_project_code returns blob results for project", () => {
135
+ let server;
136
+ let mcpUrl;
137
+ before(async () => {
138
+ const port = await nextMcpPort();
139
+ server = await launchMcpServer(mockGitLabUrl, port, {
140
+ GITLAB_TOOLSETS: "search",
141
+ });
142
+ mcpUrl = `http://${HOST}:${port}/mcp`;
143
+ });
144
+ after(() => cleanupServers([server]));
145
+ test("returns array with project_id", async () => {
146
+ const result = await callTool(mcpUrl, "search_project_code", {
147
+ project_id: "123",
148
+ search: "searchResult",
149
+ });
150
+ assert.ok(Array.isArray(result), "Response should be an array");
151
+ assert.ok(result.length > 0, "Response should have at least one result");
152
+ });
153
+ test("result includes the correct project_id", async () => {
154
+ const result = await callTool(mcpUrl, "search_project_code", {
155
+ project_id: "123",
156
+ search: "searchResult",
157
+ });
158
+ assert.strictEqual(String(result[0].project_id), "123", "project_id should match requested project");
159
+ });
160
+ });
161
+ // ---- 3b. search_project_code handles URL-encoded namespaced paths ----
162
+ describe("search_project_code handles URL-encoded namespaced paths", () => {
163
+ let server;
164
+ let mcpUrl;
165
+ before(async () => {
166
+ const port = await nextMcpPort();
167
+ server = await launchMcpServer(mockGitLabUrl, port, {
168
+ GITLAB_TOOLSETS: "search",
169
+ });
170
+ mcpUrl = `http://${HOST}:${port}/mcp`;
171
+ });
172
+ after(() => cleanupServers([server]));
173
+ test("URL-encoded project path is not double-encoded", async () => {
174
+ // Pass a URL-encoded namespaced path (e.g. "my-group%2Fmy-project")
175
+ // The mock echoes back req.params.id (which Express decodes from the URL).
176
+ // Correct: decode → "my-group/my-project" → encode → "my-group%2Fmy-project" → Express decodes to "my-group/my-project"
177
+ // Double-encoded: encode "my-group%2Fmy-project" → "my-group%252Fmy-project" → Express decodes to "my-group%2Fmy-project"
178
+ const result = await callTool(mcpUrl, "search_project_code", {
179
+ project_id: "my-group%2Fmy-project",
180
+ search: "searchResult",
181
+ });
182
+ assert.ok(Array.isArray(result), "Response should be an array");
183
+ assert.ok(result.length > 0, "Response should have at least one result");
184
+ assert.strictEqual(result[0].project_id, "my-group/my-project", "project_id should be decoded (not double-encoded as 'my-group%2Fmy-project')");
185
+ });
186
+ test("plain numeric project_id still works", async () => {
187
+ const result = await callTool(mcpUrl, "search_project_code", {
188
+ project_id: "123",
189
+ search: "searchResult",
190
+ });
191
+ assert.strictEqual(result[0].project_id, "123");
192
+ });
193
+ });
194
+ // ---- 4. search_group_code returns blob results for group ----
195
+ describe("search_group_code returns blob results for group", () => {
196
+ let server;
197
+ let mcpUrl;
198
+ before(async () => {
199
+ const port = await nextMcpPort();
200
+ server = await launchMcpServer(mockGitLabUrl, port, {
201
+ GITLAB_TOOLSETS: "search",
202
+ });
203
+ mcpUrl = `http://${HOST}:${port}/mcp`;
204
+ });
205
+ after(() => cleanupServers([server]));
206
+ test("returns array of results", async () => {
207
+ const result = await callTool(mcpUrl, "search_group_code", {
208
+ group_id: "456",
209
+ search: "searchResult",
210
+ });
211
+ assert.ok(Array.isArray(result), "Response should be an array");
212
+ assert.ok(result.length > 0, "Response should have at least one result");
213
+ });
214
+ test("result has expected blob fields", async () => {
215
+ const result = await callTool(mcpUrl, "search_group_code", {
216
+ group_id: "456",
217
+ search: "searchResult",
218
+ });
219
+ const first = result[0];
220
+ assert.ok("filename" in first, "Result should have filename field");
221
+ assert.ok("path" in first, "Result should have path field");
222
+ });
223
+ });
224
+ // ---- 4b. search_group_code handles URL-encoded namespaced paths ----
225
+ describe("search_group_code handles URL-encoded namespaced paths", () => {
226
+ let server;
227
+ let mcpUrl;
228
+ before(async () => {
229
+ const port = await nextMcpPort();
230
+ server = await launchMcpServer(mockGitLabUrl, port, {
231
+ GITLAB_TOOLSETS: "search",
232
+ });
233
+ mcpUrl = `http://${HOST}:${port}/mcp`;
234
+ });
235
+ after(() => cleanupServers([server]));
236
+ test("URL-encoded group path is not double-encoded", async () => {
237
+ // The mock echoes back req.params.id in the ref field.
238
+ // Correct: decode → "my-org/my-group" → encode → "my-org%2Fmy-group" → Express decodes to "my-org/my-group"
239
+ // Double-encoded: encode "my-org%2Fmy-group" → "my-org%252Fmy-group" → Express decodes to "my-org%2Fmy-group"
240
+ const result = await callTool(mcpUrl, "search_group_code", {
241
+ group_id: "my-org%2Fmy-group",
242
+ search: "searchResult",
243
+ });
244
+ assert.ok(Array.isArray(result), "Response should be an array");
245
+ assert.ok(result.length > 0, "Response should have at least one result");
246
+ assert.strictEqual(result[0].ref, "my-org/my-group", "ref should contain decoded group path (not double-encoded as 'my-org%2Fmy-group')");
247
+ });
248
+ });
249
+ // ---- 5. search tools are not available without search toolset enabled ----
250
+ describe("search tools are not available without search toolset enabled", () => {
251
+ let server;
252
+ let tools;
253
+ before(async () => {
254
+ const port = await nextMcpPort();
255
+ // Launch without GITLAB_TOOLSETS=search (use a different toolset instead)
256
+ server = await launchMcpServer(mockGitLabUrl, port, {
257
+ GITLAB_TOOLSETS: "issues",
258
+ });
259
+ tools = await getToolNames(`http://${HOST}:${port}/mcp`);
260
+ });
261
+ after(() => cleanupServers([server]));
262
+ test("search_code is NOT in tool list", () => {
263
+ assert.ok(!tools.includes("search_code"), "search_code should NOT be present without search toolset");
264
+ });
265
+ test("search_project_code is NOT in tool list", () => {
266
+ assert.ok(!tools.includes("search_project_code"), "search_project_code should NOT be present without search toolset");
267
+ });
268
+ test("search_group_code is NOT in tool list", () => {
269
+ assert.ok(!tools.includes("search_group_code"), "search_group_code should NOT be present without search toolset");
270
+ });
271
+ });
272
+ });
@@ -16,17 +16,20 @@ const MOCK_PORT_BASE = 9200;
16
16
  const MCP_PORT_BASE = 3200;
17
17
  // Known tool counts per toolset (from TOOLSET_DEFINITIONS)
18
18
  const TOOLSET_TOOL_COUNTS = {
19
- merge_requests: 31,
19
+ merge_requests: 34,
20
20
  issues: 14,
21
21
  repositories: 7,
22
22
  branches: 4,
23
23
  projects: 8,
24
24
  labels: 5,
25
- pipelines: 12,
25
+ pipelines: 19,
26
26
  milestones: 9,
27
- wiki: 5,
27
+ wiki: 10,
28
28
  releases: 7,
29
29
  users: 5,
30
+ search: 3,
31
+ workitems: 12,
32
+ webhooks: 3,
30
33
  };
31
34
  const DEFAULT_TOOLSETS = [
32
35
  "merge_requests",
@@ -41,6 +44,7 @@ const DEFAULT_TOOLSETS = [
41
44
  "releases",
42
45
  "users",
43
46
  ];
47
+ const NON_DEFAULT_TOOLSETS = ["search", "webhooks"];
44
48
  const DEFAULT_TOOL_COUNT = DEFAULT_TOOLSETS.reduce((sum, id) => sum + TOOLSET_TOOL_COUNTS[id], 0);
45
49
  const ALL_TOOLSET_TOOL_COUNT = Object.values(TOOLSET_TOOL_COUNTS).reduce((sum, c) => sum + c, 0);
46
50
  // Representative tools per toolset for spot-checking
@@ -51,11 +55,13 @@ const TOOLSET_SAMPLE_TOOLS = {
51
55
  branches: ["create_branch", "list_commits"],
52
56
  projects: ["get_project", "list_namespaces", "list_group_iterations"],
53
57
  labels: ["list_labels", "create_label"],
54
- pipelines: ["list_pipelines", "create_pipeline", "cancel_pipeline_job"],
58
+ pipelines: ["list_pipelines", "create_pipeline", "cancel_pipeline_job", "list_deployments", "list_job_artifacts"],
55
59
  milestones: ["list_milestones", "create_milestone", "get_milestone_burndown_events"],
56
- wiki: ["list_wiki_pages", "create_wiki_page"],
60
+ wiki: ["list_wiki_pages", "create_wiki_page", "list_group_wiki_pages", "create_group_wiki_page"],
57
61
  releases: ["list_releases", "create_release", "download_release_asset"],
58
62
  users: ["get_users", "upload_markdown", "download_attachment"],
63
+ search: ["search_code", "search_project_code", "search_group_code"],
64
+ webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
59
65
  };
60
66
  // --- Helpers ---
61
67
  async function launchMcpServer(mockGitLabUrl, mcpPort, extraEnv = {}) {
@@ -97,7 +103,7 @@ let portCounter = 0;
97
103
  async function nextMcpPort() {
98
104
  return findAvailablePort(MCP_PORT_BASE + portCounter++ * 10);
99
105
  }
100
- describe("Toolset Filtering", () => {
106
+ describe("Toolset Filtering", { concurrency: 1 }, () => {
101
107
  before(async () => {
102
108
  const mockPort = await findMockServerPort(MOCK_PORT_BASE);
103
109
  mockGitLab = new MockGitLabServer({
@@ -130,9 +136,8 @@ describe("Toolset Filtering", () => {
130
136
  assertContainsAll(tools, TOOLSET_SAMPLE_TOOLS[id], id);
131
137
  }
132
138
  });
133
- test("includes all toolsets by default (no non-default toolsets)", () => {
134
- // All toolsets are now default, so default count equals all toolset count
135
- assert.strictEqual(tools.length, ALL_TOOLSET_TOOL_COUNT);
139
+ test("excludes non-default toolsets (search)", () => {
140
+ assertContainsNone(tools, TOOLSET_SAMPLE_TOOLS.search, "non-default search");
136
141
  });
137
142
  test("excludes execute_graphql (not in any toolset)", () => {
138
143
  assertContainsNone(tools, ["execute_graphql"], "unassigned");
@@ -196,14 +201,14 @@ describe("Toolset Filtering", () => {
196
201
  tools = await getToolNames(`http://${HOST}:${port}/mcp`);
197
202
  });
198
203
  after(() => cleanupServers([server]));
199
- test("returns default tools plus the two individual tools", () => {
200
- assert.strictEqual(tools.length, DEFAULT_TOOL_COUNT + 2);
204
+ test("returns default tools plus execute_graphql (list_pipelines already in default)", () => {
205
+ assert.strictEqual(tools.length, DEFAULT_TOOL_COUNT + 1);
201
206
  });
202
207
  test("includes the individually added tools", () => {
203
208
  assertContainsAll(tools, ["list_pipelines", "execute_graphql"], "individual");
204
209
  });
205
- test("does not include other pipeline tools", () => {
206
- assertContainsNone(tools, ["create_pipeline", "cancel_pipeline"], "other pipelines");
210
+ test("includes pipeline tools from default toolset", () => {
211
+ assertContainsAll(tools, ["create_pipeline", "cancel_pipeline"], "default pipelines");
207
212
  });
208
213
  });
209
214
  // ---- 5. Toolset + individual tools combined ----
@@ -262,8 +267,8 @@ describe("Toolset Filtering", () => {
262
267
  tools = await getToolNames(`http://${HOST}:${port}/mcp`);
263
268
  });
264
269
  after(() => cleanupServers([server]));
265
- test("returns default tools + wiki tools", () => {
266
- assert.strictEqual(tools.length, DEFAULT_TOOL_COUNT + TOOLSET_TOOL_COUNTS.wiki);
270
+ test("returns default tool count (wiki is already default)", () => {
271
+ assert.strictEqual(tools.length, DEFAULT_TOOL_COUNT);
267
272
  });
268
273
  test("includes wiki tools", () => {
269
274
  assertContainsAll(tools, TOOLSET_SAMPLE_TOOLS.wiki, "wiki");
@@ -425,8 +430,8 @@ describe("Toolset Filtering", () => {
425
430
  test("resolves mixed-case tool names to lowercase equivalents", () => {
426
431
  assertContainsAll(tools, ["list_pipelines", "execute_graphql"], "case-insensitive tools");
427
432
  });
428
- test("returns default tools plus the two individual tools", () => {
429
- assert.strictEqual(tools.length, DEFAULT_TOOL_COUNT + 2);
433
+ test("returns default tools plus execute_graphql (list_pipelines already in default)", () => {
434
+ assert.strictEqual(tools.length, DEFAULT_TOOL_COUNT + 1);
430
435
  });
431
436
  });
432
437
  // ---- 15. GITLAB_TOOLS with unknown tool names ----