@dotdrelle/wiki-manager 0.6.17

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,402 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
+
4
+ function envValue(key) {
5
+ if (key in process.env) return process.env[key];
6
+ const filePath = managerEnvFile();
7
+ if (!existsSync(filePath)) return undefined;
8
+ return readEnvFile(filePath)[key];
9
+ }
10
+
11
+ function interpolateEnv(value) {
12
+ return value.replace(/\$\{([^}]+)\}/g, (_, expr) => {
13
+ const sep = expr.indexOf(':-');
14
+ if (sep !== -1) return envValue(expr.slice(0, sep)) ?? expr.slice(sep + 2);
15
+ return envValue(expr) ?? '';
16
+ });
17
+ }
18
+
19
+ function normalizeHeaders(headers) {
20
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return {};
21
+ return Object.fromEntries(
22
+ Object.entries(headers)
23
+ .filter(([key, value]) => key && typeof value === 'string' && value)
24
+ .map(([key, value]) => [key.toLowerCase(), interpolateEnv(value)]),
25
+ );
26
+ }
27
+
28
+ function normalizeExternalUrlForRuntime(url) {
29
+ if (process.env.WIKI_MANAGER_KEEP_DOCKER_HOST === '1') return url;
30
+ try {
31
+ const parsed = new URL(url);
32
+ if (parsed.hostname === 'host.docker.internal') {
33
+ parsed.hostname = 'localhost';
34
+ return parsed.toString();
35
+ }
36
+ } catch {
37
+ return url;
38
+ }
39
+ return url;
40
+ }
41
+
42
+ function readExternalMcpEndpoints() {
43
+ const filePath = managerMcpEndpointsFile();
44
+ if (!existsSync(filePath)) return {};
45
+ const raw = JSON.parse(readFileSync(filePath, 'utf8'));
46
+ const servers = raw?.mcpServers ?? raw?.servers ?? {};
47
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return {};
48
+ return Object.fromEntries(
49
+ Object.entries(servers)
50
+ .filter(([, endpoint]) => endpoint?.url)
51
+ .map(([name, endpoint]) => [
52
+ name,
53
+ {
54
+ ...endpointStatus(true),
55
+ url: normalizeExternalUrlForRuntime(interpolateEnv(String(endpoint.url))),
56
+ configuredUrl: interpolateEnv(String(endpoint.url)),
57
+ headers: normalizeHeaders(endpoint.headers),
58
+ external: true,
59
+ },
60
+ ]),
61
+ );
62
+ }
63
+
64
+ function endpointStatus(configured, detail = '') {
65
+ return {
66
+ status: configured ? 'configured' : 'missing',
67
+ detail,
68
+ };
69
+ }
70
+
71
+ const MCP_SERVICE_MAP = {
72
+ wiki: 'mcp-http',
73
+ production: 'production-mcp',
74
+ };
75
+
76
+ export function buildMcpStatus(session) {
77
+ const workspaceEnv = session.workspaceEnv ?? {};
78
+ const external = readExternalMcpEndpoints();
79
+
80
+ return {
81
+ wiki: {
82
+ ...endpointStatus(
83
+ workspaceEnv.WIKI_MCP_PORT && workspaceEnv.WIKI_MCP_AUTH_TOKEN,
84
+ workspaceEnv.WIKI_MCP_PORT ? `:${workspaceEnv.WIKI_MCP_PORT}` : '',
85
+ ),
86
+ url: workspaceEnv.WIKI_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.WIKI_MCP_PORT}/mcp` : null,
87
+ token: workspaceEnv.WIKI_MCP_AUTH_TOKEN || null,
88
+ },
89
+ production: {
90
+ ...endpointStatus(
91
+ workspaceEnv.PRODUCTION_MCP_PORT && workspaceEnv.PRODUCTION_MCP_AUTH_TOKEN,
92
+ workspaceEnv.PRODUCTION_MCP_PORT ? `:${workspaceEnv.PRODUCTION_MCP_PORT}` : '',
93
+ ),
94
+ url: workspaceEnv.PRODUCTION_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.PRODUCTION_MCP_PORT}/mcp/` : null,
95
+ token: workspaceEnv.PRODUCTION_MCP_AUTH_TOKEN || null,
96
+ activeConfigPath: session.wikirc?.fileName || null,
97
+ },
98
+ ...external,
99
+ };
100
+ }
101
+
102
+ export function applyMcpRuntimeStatus(mcpStatus, serviceStates = {}) {
103
+ const next = {};
104
+ for (const [name, value] of Object.entries(mcpStatus ?? {})) {
105
+ const service = MCP_SERVICE_MAP[name];
106
+ if (!service || value.status === 'missing') {
107
+ next[name] = value;
108
+ continue;
109
+ }
110
+ const runtime = serviceStates[service];
111
+ next[name] = {
112
+ ...value,
113
+ status: runtime?.running ? 'connected' : 'configured',
114
+ runtime: runtime?.state || 'not running',
115
+ };
116
+ }
117
+ return next;
118
+ }
119
+
120
+ function parseMcpResponse(text) {
121
+ const trimmed = text.trim();
122
+ if (!trimmed) return null;
123
+ const dataLines = trimmed
124
+ .split(/\r?\n/)
125
+ .filter((line) => line.startsWith('data:'))
126
+ .map((line) => line.slice('data:'.length).trim());
127
+ if (dataLines.length > 0) {
128
+ const data = dataLines.join('\n');
129
+ return data ? JSON.parse(data) : null;
130
+ }
131
+ return JSON.parse(trimmed);
132
+ }
133
+
134
+ function compactDescription(value) {
135
+ const text = String(value).replace(/\s+/g, ' ').trim();
136
+ return text.length > 420 ? `${text.slice(0, 417)}...` : text;
137
+ }
138
+
139
+ function clarifyToolDescription(serverName, toolName, description) {
140
+ const base = compactDescription(description ?? '');
141
+ if (serverName === 'cme' && toolName.startsWith('cme_export')) {
142
+ return compactDescription([
143
+ base,
144
+ 'Use only for Confluence/CME/source export into raw/untracked. Not for wiki deliverable export.',
145
+ ].filter(Boolean).join(' '));
146
+ }
147
+ if (serverName === 'production' && toolName === 'production_start_job') {
148
+ return compactDescription([
149
+ base,
150
+ 'Production export means wiki deliverable/publication export only. Do not use type=export for Confluence/CME/source export; use cme_export_run instead.',
151
+ ].filter(Boolean).join(' '));
152
+ }
153
+ return base;
154
+ }
155
+
156
+ async function listMcpTools(endpoint) {
157
+ if (!endpoint.url) throw new Error('missing endpoint URL');
158
+ const payload = await mcpRequest(endpoint, 'tools/list', {});
159
+ return payload?.result?.tools ?? [];
160
+ }
161
+
162
+ async function mcpRequest(endpoint, method, params, signal, options = {}) {
163
+ if (!endpoint.url) throw new Error('missing endpoint URL');
164
+ const controller = new AbortController();
165
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000);
166
+ const requestSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
167
+
168
+ const buildHeaders = () => {
169
+ const h = {
170
+ accept: 'application/json, text/event-stream',
171
+ 'content-type': 'application/json',
172
+ ...(endpoint.headers ?? {}),
173
+ };
174
+ if (endpoint.token) h.authorization = `Bearer ${endpoint.token}`;
175
+ if (endpoint._sessionId) h['mcp-session-id'] = endpoint._sessionId;
176
+ return h;
177
+ };
178
+
179
+ const doRequest = async (m, p) => {
180
+ const response = await fetch(endpoint.url, {
181
+ method: 'POST',
182
+ signal: requestSignal,
183
+ headers: buildHeaders(),
184
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: m, params: p }),
185
+ });
186
+ const sid = response.headers.get('mcp-session-id');
187
+ if (sid) endpoint._sessionId = sid;
188
+ return response;
189
+ };
190
+
191
+ try {
192
+ let response = await doRequest(method, params);
193
+ let text = await response.text();
194
+
195
+ if (response.status === 400 && /session ID/i.test(text)) {
196
+ endpoint._sessionId = null;
197
+ const initResponse = await fetch(endpoint.url, {
198
+ method: 'POST',
199
+ signal: requestSignal,
200
+ headers: buildHeaders(),
201
+ body: JSON.stringify({
202
+ jsonrpc: '2.0',
203
+ id: 0,
204
+ method: 'initialize',
205
+ params: {
206
+ protocolVersion: '2025-06-18',
207
+ capabilities: {},
208
+ clientInfo: { name: 'wiki-manager', version: '0.6.17' },
209
+ },
210
+ }),
211
+ });
212
+ await initResponse.text();
213
+ const sessionId = initResponse.headers.get('mcp-session-id');
214
+ if (!initResponse.ok || !sessionId) {
215
+ throw new Error(`initialize failed: ${initResponse.status}`);
216
+ }
217
+ endpoint._sessionId = sessionId;
218
+ // Fire-and-forget: complete the handshake without blocking the retry
219
+ fetch(endpoint.url, {
220
+ method: 'POST',
221
+ headers: buildHeaders(),
222
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
223
+ }).catch(() => {});
224
+ response = await doRequest(method, params);
225
+ text = await response.text();
226
+ }
227
+
228
+ if (!response.ok) throw new Error(`${response.status} ${text.slice(0, 160)}`.trim());
229
+ const payload = parseMcpResponse(text);
230
+ if (payload?.error) throw new Error(payload.error.message ?? JSON.stringify(payload.error));
231
+ return payload;
232
+ } finally {
233
+ clearTimeout(timeout);
234
+ }
235
+ }
236
+
237
+ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, signal) {
238
+ const endpoint = mcpStatus?.[serverName];
239
+ if (!endpoint) throw new Error(`Unknown MCP: ${serverName}`);
240
+ if (endpoint.status !== 'connected') throw new Error(`MCP is not connected: ${serverName}`);
241
+ const shouldInjectConfigPath =
242
+ serverName === 'production' &&
243
+ toolName === 'production_start_job' &&
244
+ endpoint.activeConfigPath &&
245
+ !args.configPath;
246
+ const toolArgs = {
247
+ ...args,
248
+ ...(shouldInjectConfigPath ? { configPath: endpoint.activeConfigPath } : {}),
249
+ };
250
+ const timeoutMs = serverName === 'documents' && toolName === 'documents_convert_to_markdown' ? 600_000 : 8000;
251
+ const payload = await mcpRequest(endpoint, 'tools/call', {
252
+ name: toolName,
253
+ arguments: toolArgs,
254
+ }, signal, { timeoutMs });
255
+ if (payload?.result?.isError) {
256
+ throw new Error(formatMcpToolResult(payload.result));
257
+ }
258
+ return payload?.result ?? null;
259
+ }
260
+
261
+ export function formatMcpToolResult(result) {
262
+ if (!result) return 'No result.';
263
+ const content = result.content;
264
+ if (!Array.isArray(content)) return JSON.stringify(result, null, 2);
265
+ return content
266
+ .map((item) => {
267
+ if (item.type === 'text') return item.text ?? '';
268
+ return JSON.stringify(item, null, 2);
269
+ })
270
+ .filter(Boolean)
271
+ .join('\n\n')
272
+ .trim() || 'No result.';
273
+ }
274
+
275
+ export async function discoverMcpTools(mcpStatus) {
276
+ const next = {};
277
+ await Promise.all(Object.entries(mcpStatus ?? {}).map(async ([name, value]) => {
278
+ if (value.status === 'missing') {
279
+ next[name] = value;
280
+ return;
281
+ }
282
+ try {
283
+ const tools = await listMcpTools(value);
284
+ next[name] = {
285
+ ...value,
286
+ status: 'connected',
287
+ tools: tools.map((tool) => ({
288
+ name: tool.name,
289
+ description: tool.description ?? '',
290
+ inputSchema: tool.inputSchema,
291
+ })),
292
+ toolError: null,
293
+ };
294
+ } catch (err) {
295
+ const message = err instanceof Error ? err.message : String(err);
296
+ next[name] = {
297
+ ...value,
298
+ tools: [],
299
+ toolError: message,
300
+ };
301
+ }
302
+ }));
303
+ return next;
304
+ }
305
+
306
+ export function formatMcpTools(mcpStatus, filterName = null) {
307
+ const lines = [];
308
+ const entries = Object.entries(mcpStatus ?? {}).filter(([name]) => !filterName || name === filterName);
309
+ for (const [name, value] of entries) {
310
+ if (value.status !== 'connected') continue;
311
+ const tools = value.tools ?? [];
312
+ lines.push(`### ${name}`, '');
313
+ if (tools.length === 0) {
314
+ lines.push('No tools discovered.', '');
315
+ continue;
316
+ }
317
+ for (const tool of tools.slice(0, 20)) {
318
+ lines.push(`**Tool:** \`${tool.name}\``);
319
+ lines.push(`**Description:** ${compactDescription(tool.description ?? '') || '-'}`);
320
+ lines.push('');
321
+ lines.push('---');
322
+ lines.push('');
323
+ }
324
+ if (tools.length > 20) {
325
+ lines.push(`_${tools.length - 20} more tools hidden._`, '');
326
+ }
327
+ }
328
+ if (lines.length > 0) return lines.join('\n').trimEnd();
329
+ return filterName
330
+ ? `No connected MCP tools discovered for ${filterName}.`
331
+ : 'No connected MCP tools discovered.';
332
+ }
333
+
334
+ export function formatMcpToolSummary(mcpStatus) {
335
+ const lines = [];
336
+ for (const [name, value] of Object.entries(mcpStatus ?? {})) {
337
+ if (value.status !== 'connected') continue;
338
+ const count = value.tools?.length ?? 0;
339
+ lines.push(`- ${name}: ${count} tool${count === 1 ? '' : 's'}`);
340
+ }
341
+ return lines.length > 0 ? lines.join('\n') : 'No connected MCP tools discovered.';
342
+ }
343
+
344
+ export function formatMcpToolsForAgent(mcpStatus) {
345
+ const sections = [];
346
+ for (const [name, value] of Object.entries(mcpStatus ?? {})) {
347
+ if (value.status !== 'connected') continue;
348
+ const tools = value.tools ?? [];
349
+ if (tools.length === 0) {
350
+ sections.push(`${name}: connected, tools not discovered yet`);
351
+ continue;
352
+ }
353
+ sections.push(`${name}: ${tools.map((tool) => tool.name).join(', ')}`);
354
+ }
355
+ return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
356
+ }
357
+
358
+ export function buildLlmTools(mcpStatus) {
359
+ const tools = [];
360
+ for (const [serverName, value] of Object.entries(mcpStatus ?? {})) {
361
+ if (value.status !== 'connected') continue;
362
+ for (const tool of value.tools ?? []) {
363
+ tools.push({
364
+ type: 'function',
365
+ function: {
366
+ name: `${serverName}__${tool.name}`,
367
+ description: clarifyToolDescription(serverName, tool.name, tool.description),
368
+ parameters: tool.inputSchema ?? { type: 'object', properties: {} },
369
+ },
370
+ });
371
+ }
372
+ }
373
+ return tools;
374
+ }
375
+
376
+ export function parseToolCallName(name) {
377
+ const sep = name.indexOf('__');
378
+ if (sep === -1) return { server: null, tool: name };
379
+ return { server: name.slice(0, sep), tool: name.slice(sep + 2) };
380
+ }
381
+
382
+ export function mcpStatusMarker(status) {
383
+ if (status === 'connected') return '●';
384
+ if (status === 'configured') return '◐';
385
+ return '○';
386
+ }
387
+
388
+ export function formatMcpStatus(mcpStatus) {
389
+ const entries = Object.entries(mcpStatus ?? {});
390
+ if (entries.length === 0) return '○ none';
391
+ return entries
392
+ .map(([name, value]) => {
393
+ const marker = mcpStatusMarker(value.status);
394
+ const detail = [value.status, value.detail, value.runtime ? `(${value.runtime})` : '']
395
+ .filter(Boolean)
396
+ .join(' ');
397
+ const tools = value.tools ? ` tools=${value.tools.length}` : '';
398
+ const error = value.toolError ? ` toolsError=${value.toolError}` : '';
399
+ return `${marker} ${name}${detail ? ` ${detail}` : ''}${tools}${error}`;
400
+ })
401
+ .join('\n');
402
+ }
@@ -0,0 +1,228 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { buildMcpStatus, callMcpTool } from './mcp.js';
7
+
8
+ test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
9
+ const originalCwd = process.cwd();
10
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-mcp-endpoints-'));
11
+ await writeFile(
12
+ path.join(root, 'mcp.endpoints.json'),
13
+ JSON.stringify({
14
+ mcpServers: {
15
+ external: {
16
+ url: 'http://127.0.0.1:9999/mcp/',
17
+ headers: {
18
+ 'x-api-key': 'secret',
19
+ Authorization: 'Bearer token',
20
+ },
21
+ },
22
+ },
23
+ }),
24
+ 'utf8',
25
+ );
26
+
27
+ try {
28
+ process.chdir(root);
29
+ const status = buildMcpStatus({ workspaceEnv: {} });
30
+ assert.equal(status.external.url, 'http://127.0.0.1:9999/mcp/');
31
+ assert.deepEqual(status.external.headers, {
32
+ 'x-api-key': 'secret',
33
+ authorization: 'Bearer token',
34
+ });
35
+ assert.equal(status.external.external, true);
36
+ assert.equal(status.cme, undefined);
37
+ } finally {
38
+ process.chdir(originalCwd);
39
+ }
40
+ });
41
+
42
+ test('buildMcpStatus interpolates external endpoints from manager .env', async () => {
43
+ const originalCwd = process.cwd();
44
+ const originalToken = process.env.TEST_EXTERNAL_TOKEN;
45
+ const originalPort = process.env.TEST_EXTERNAL_PORT;
46
+ const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-mcp-env-'));
47
+ await writeFile(
48
+ path.join(root, '.env'),
49
+ [
50
+ 'TEST_EXTERNAL_TOKEN=from-env-file',
51
+ 'TEST_EXTERNAL_PORT=4567',
52
+ '',
53
+ ].join('\n'),
54
+ 'utf8',
55
+ );
56
+ await writeFile(
57
+ path.join(root, 'mcp.endpoints.json'),
58
+ JSON.stringify({
59
+ mcpServers: {
60
+ external: {
61
+ url: 'http://host.docker.internal:${TEST_EXTERNAL_PORT:-9999}/mcp/',
62
+ headers: {
63
+ Authorization: 'Bearer ${TEST_EXTERNAL_TOKEN}',
64
+ },
65
+ },
66
+ },
67
+ }),
68
+ 'utf8',
69
+ );
70
+ delete process.env.TEST_EXTERNAL_TOKEN;
71
+ delete process.env.TEST_EXTERNAL_PORT;
72
+
73
+ try {
74
+ process.chdir(root);
75
+ const status = buildMcpStatus({ workspaceEnv: {} });
76
+ assert.equal(status.external.url, 'http://localhost:4567/mcp/');
77
+ assert.equal(status.external.configuredUrl, 'http://host.docker.internal:4567/mcp/');
78
+ assert.deepEqual(status.external.headers, {
79
+ authorization: 'Bearer from-env-file',
80
+ });
81
+ } finally {
82
+ process.chdir(originalCwd);
83
+ if (originalToken === undefined) delete process.env.TEST_EXTERNAL_TOKEN;
84
+ else process.env.TEST_EXTERNAL_TOKEN = originalToken;
85
+ if (originalPort === undefined) delete process.env.TEST_EXTERNAL_PORT;
86
+ else process.env.TEST_EXTERNAL_PORT = originalPort;
87
+ }
88
+ });
89
+
90
+ test('callMcpTool injects active configPath for production_start_job', async () => {
91
+ const originalFetch = globalThis.fetch;
92
+ let requestBody = null;
93
+ globalThis.fetch = async (_url, init) => {
94
+ requestBody = JSON.parse(init.body);
95
+ return {
96
+ ok: true,
97
+ status: 200,
98
+ headers: { get: () => null },
99
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
100
+ };
101
+ };
102
+
103
+ try {
104
+ await callMcpTool(
105
+ {
106
+ production: {
107
+ status: 'connected',
108
+ url: 'http://127.0.0.1:3000/mcp/',
109
+ token: 'token',
110
+ activeConfigPath: '.wikirc.yaml.openai',
111
+ },
112
+ },
113
+ 'production',
114
+ 'production_start_job',
115
+ { type: 'doctor' },
116
+ );
117
+
118
+ assert.equal(requestBody.method, 'tools/call');
119
+ assert.equal(requestBody.params.name, 'production_start_job');
120
+ assert.deepEqual(requestBody.params.arguments, {
121
+ type: 'doctor',
122
+ configPath: '.wikirc.yaml.openai',
123
+ });
124
+ } finally {
125
+ globalThis.fetch = originalFetch;
126
+ }
127
+ });
128
+
129
+ test('callMcpTool keeps explicit production configPath', async () => {
130
+ const originalFetch = globalThis.fetch;
131
+ let requestBody = null;
132
+ globalThis.fetch = async (_url, init) => {
133
+ requestBody = JSON.parse(init.body);
134
+ return {
135
+ ok: true,
136
+ status: 200,
137
+ headers: { get: () => null },
138
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
139
+ };
140
+ };
141
+
142
+ try {
143
+ await callMcpTool(
144
+ {
145
+ production: {
146
+ status: 'connected',
147
+ url: 'http://127.0.0.1:3000/mcp/',
148
+ token: 'token',
149
+ activeConfigPath: '.wikirc.yaml.openai',
150
+ },
151
+ },
152
+ 'production',
153
+ 'production_start_job',
154
+ { type: 'doctor', configPath: '.wikirc.yaml.claude' },
155
+ );
156
+
157
+ assert.equal(requestBody.params.arguments.configPath, '.wikirc.yaml.claude');
158
+ } finally {
159
+ globalThis.fetch = originalFetch;
160
+ }
161
+ });
162
+
163
+ test('callMcpTool sends configured endpoint headers', async () => {
164
+ const originalFetch = globalThis.fetch;
165
+ let requestHeaders = null;
166
+ globalThis.fetch = async (_url, init) => {
167
+ requestHeaders = init.headers;
168
+ return {
169
+ ok: true,
170
+ status: 200,
171
+ headers: { get: () => null },
172
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
173
+ };
174
+ };
175
+
176
+ try {
177
+ await callMcpTool(
178
+ {
179
+ external: {
180
+ status: 'connected',
181
+ url: 'http://127.0.0.1:9999/mcp/',
182
+ headers: { 'x-api-key': 'secret' },
183
+ },
184
+ },
185
+ 'external',
186
+ 'ping',
187
+ {},
188
+ );
189
+
190
+ assert.equal(requestHeaders['x-api-key'], 'secret');
191
+ } finally {
192
+ globalThis.fetch = originalFetch;
193
+ }
194
+ });
195
+
196
+ test('callMcpTool parses SSE responses after keepalive comments', async () => {
197
+ const originalFetch = globalThis.fetch;
198
+ globalThis.fetch = async () => ({
199
+ ok: true,
200
+ status: 200,
201
+ headers: { get: () => null },
202
+ text: async () => [
203
+ ': keepalive',
204
+ '',
205
+ 'event: message',
206
+ 'data: {"result":{"content":[{"type":"text","text":"{\\"ok\\":true}"}]}}',
207
+ '',
208
+ ].join('\n'),
209
+ });
210
+
211
+ try {
212
+ const result = await callMcpTool(
213
+ {
214
+ documents: {
215
+ status: 'connected',
216
+ url: 'http://127.0.0.1:3337/mcp/',
217
+ },
218
+ },
219
+ 'documents',
220
+ 'documents_convert_to_markdown',
221
+ { filePath: '/documents/input/example.pdf' },
222
+ );
223
+
224
+ assert.equal(result.content[0].text, '{"ok":true}');
225
+ } finally {
226
+ globalThis.fetch = originalFetch;
227
+ }
228
+ });