@zereight/mcp-gitlab 2.0.33 → 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,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 ----
@@ -0,0 +1,148 @@
1
+ import { describe, test, before, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
8
+ const MOCK_TOKEN = 'glpat-mock-token-12345';
9
+ const TEST_PROJECT_ID = '123';
10
+ function callUploadMarkdown(args, env, timeoutMs = 15_000) {
11
+ return new Promise((resolve, reject) => {
12
+ const proc = spawn('node', ['build/index.js'], {
13
+ stdio: ['pipe', 'pipe', 'pipe'],
14
+ env: { ...process.env, ...env },
15
+ });
16
+ const timer = setTimeout(() => {
17
+ proc.kill();
18
+ reject(new Error(`Process timed out after ${timeoutMs}ms`));
19
+ }, timeoutMs);
20
+ let stdout = '';
21
+ let stderr = '';
22
+ proc.stdout?.on('data', (d) => (stdout += d.toString()));
23
+ proc.stderr?.on('data', (d) => (stderr += d.toString()));
24
+ proc.on('error', (err) => {
25
+ clearTimeout(timer);
26
+ reject(new Error(`Failed to spawn process: ${err.message}`));
27
+ });
28
+ proc.on('close', () => {
29
+ clearTimeout(timer);
30
+ const lines = stdout.split('\n').filter(l => l.trim().startsWith('{'));
31
+ for (const line of lines) {
32
+ try {
33
+ const parsed = JSON.parse(line);
34
+ if (parsed.id === 1) {
35
+ resolve(parsed);
36
+ return;
37
+ }
38
+ }
39
+ catch { /* try next line */ }
40
+ }
41
+ reject(new Error(`No matching JSON-RPC response found.\nstdout: ${stdout}\nstderr: ${stderr}`));
42
+ });
43
+ proc.stdin?.end(JSON.stringify({
44
+ jsonrpc: '2.0',
45
+ id: 1,
46
+ method: 'tools/call',
47
+ params: { name: 'upload_markdown', arguments: args },
48
+ }) + '\n');
49
+ });
50
+ }
51
+ const MOCK_UPLOAD_RESPONSE = {
52
+ id: 42,
53
+ alt: 'test-file.txt',
54
+ url: '/uploads/abc123secret/test-file.txt',
55
+ full_path: '/test-group/test-project/uploads/abc123secret/test-file.txt',
56
+ markdown: '[test-file.txt](/uploads/abc123secret/test-file.txt)',
57
+ };
58
+ describe('upload_markdown', () => {
59
+ let mockGitLab;
60
+ let env;
61
+ // Captured per-request state, reset before each invocation via the handler
62
+ let lastContentType;
63
+ let lastRawBody;
64
+ before(async () => {
65
+ const port = await findMockServerPort(9200);
66
+ mockGitLab = new MockGitLabServer({ port, validTokens: [MOCK_TOKEN] });
67
+ await mockGitLab.start();
68
+ env = {
69
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
70
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
71
+ };
72
+ mockGitLab.addMockHandler('post', `/projects/${TEST_PROJECT_ID}/uploads`, (req, res) => {
73
+ lastContentType = req.headers['content-type'];
74
+ const chunks = [];
75
+ req.on('data', (chunk) => chunks.push(chunk));
76
+ req.on('end', () => {
77
+ lastRawBody = Buffer.concat(chunks).toString('binary');
78
+ res.status(201).json(MOCK_UPLOAD_RESPONSE);
79
+ });
80
+ });
81
+ });
82
+ after(async () => {
83
+ await mockGitLab.stop();
84
+ });
85
+ test('Content-Type is multipart/form-data with a boundary', async () => {
86
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-ct-test.txt');
87
+ fs.writeFileSync(tmpFile, 'content-type test');
88
+ try {
89
+ await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
90
+ assert.ok(lastContentType, 'Content-Type header must be present');
91
+ assert.ok(lastContentType.startsWith('multipart/form-data'), `Expected multipart/form-data, got: ${lastContentType}`);
92
+ assert.ok(lastContentType.includes('boundary='), `Content-Type must include boundary, got: ${lastContentType}`);
93
+ }
94
+ finally {
95
+ fs.unlinkSync(tmpFile);
96
+ }
97
+ });
98
+ test('multipart body contains a "file" field with the file content', async () => {
99
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-body-test.txt');
100
+ const fileContent = 'hello from multipart upload test';
101
+ fs.writeFileSync(tmpFile, fileContent);
102
+ try {
103
+ await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
104
+ assert.ok(lastRawBody, 'Request body must be captured');
105
+ assert.ok(lastRawBody.includes('name="file"'), 'Multipart body should include a field named "file"');
106
+ assert.ok(lastRawBody.includes(fileContent), 'Multipart body should contain the uploaded file content');
107
+ }
108
+ finally {
109
+ fs.unlinkSync(tmpFile);
110
+ }
111
+ });
112
+ test('multipart body includes the original filename', async () => {
113
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-filename-check.txt');
114
+ fs.writeFileSync(tmpFile, 'filename check');
115
+ try {
116
+ await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
117
+ assert.ok(lastRawBody, 'Request body must be captured');
118
+ assert.ok(lastRawBody.includes('mcp-upload-filename-check.txt'), 'Multipart body should include the original filename');
119
+ }
120
+ finally {
121
+ fs.unlinkSync(tmpFile);
122
+ }
123
+ });
124
+ test('returns markdown, url, alt, and full_path from upload response', async () => {
125
+ const tmpFile = path.join(os.tmpdir(), 'mcp-upload-response-test.txt');
126
+ fs.writeFileSync(tmpFile, 'response field test');
127
+ try {
128
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
129
+ assert.ok(!raw.error, `Unexpected RPC error: ${raw.error?.message}`);
130
+ const text = raw.result?.content?.[0]?.text;
131
+ assert.ok(text, 'Result should contain a text content block');
132
+ const parsed = JSON.parse(text);
133
+ assert.strictEqual(parsed.markdown, MOCK_UPLOAD_RESPONSE.markdown);
134
+ assert.strictEqual(parsed.url, MOCK_UPLOAD_RESPONSE.url);
135
+ assert.strictEqual(parsed.alt, MOCK_UPLOAD_RESPONSE.alt);
136
+ assert.strictEqual(parsed.full_path, MOCK_UPLOAD_RESPONSE.full_path);
137
+ }
138
+ finally {
139
+ fs.unlinkSync(tmpFile);
140
+ }
141
+ });
142
+ test('returns an error when the file does not exist', async () => {
143
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/nonexistent/no-such-file.txt' }, env);
144
+ const hasError = typeof raw.error?.message === 'string' ||
145
+ raw.result?.content?.some(c => c.text && (c.text.toLowerCase().includes('not found') || c.text.toLowerCase().includes('error')));
146
+ assert.ok(hasError, 'Should return an error for a nonexistent file path');
147
+ });
148
+ });