@dotdrelle/wiki-manager 0.15.29 → 0.15.34
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.
- package/.env.example +5 -2
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +1 -0
- package/package.json +2 -2
- package/src/agent/graph.test.js +17 -4
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +79 -13
- package/src/cli/wiki-manager.test.js +157 -0
- package/src/commands/slash.js +9 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/modelFetch.js +229 -41
- package/src/core/modelFetch.test.js +77 -2
- package/src/core/wikiSetup.js +32 -2
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/core/wikirc.test.js +59 -5
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/SetupWizard.tsx +282 -31
- package/src/shell/setupWizardPlaceholders.test.js +1 -1
- package/src/shell/setupWizardSuggestions.test.js +24 -0
- package/src/shell/tui.tsx +10 -18
- package/wiki-workspace +70 -0
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.34';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -263,69 +263,122 @@ async function listMcpTools(endpoint) {
|
|
|
263
263
|
return payload?.result?.tools ?? [];
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
// Streamable HTTP sessions are server-memory state that outlives the endpoint
|
|
267
|
+
// objects: buildMcpStatus() rebuilds those from the endpoints file on every
|
|
268
|
+
// refresh, so a session cached on the object itself would be thrown away and
|
|
269
|
+
// re-negotiated on each call. Key the cache by transport identity (URL + the
|
|
270
|
+
// credentials actually presented) so a token rotation or a URL change never
|
|
271
|
+
// reuses a session negotiated under the previous identity.
|
|
272
|
+
// Values are promises, not ids: several tool calls to the same agent can race
|
|
273
|
+
// on a cold endpoint, and one handshake must serve them all instead of opening
|
|
274
|
+
// (and leaking) a session per caller.
|
|
275
|
+
const mcpSessions = new Map();
|
|
276
|
+
|
|
277
|
+
function sessionKey(endpoint) {
|
|
278
|
+
return JSON.stringify([
|
|
279
|
+
endpoint.url,
|
|
280
|
+
endpoint.token ?? null,
|
|
281
|
+
Object.entries(endpoint.headers ?? {}).sort(),
|
|
282
|
+
]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The MCP Streamable HTTP spec makes `initialize` mandatory before any other
|
|
286
|
+
// request, and a stateful server is entitled to reject a cold `tools/list`.
|
|
287
|
+
// Servers word that rejection however their SDK likes — "No valid session"
|
|
288
|
+
// (Node SDK), "Missing session ID" (Python SDK), "Session not found" — so we
|
|
289
|
+
// never parse the prose. We negotiate the session up front and, if the server
|
|
290
|
+
// later revokes it, we re-negotiate on any 400/404 and replay once.
|
|
291
|
+
function openMcpSession(endpoint, key, requestSignal, headersFor) {
|
|
292
|
+
const pending = negotiateMcpSession(endpoint, requestSignal, headersFor);
|
|
293
|
+
// Cache the promise immediately so a concurrent caller joins this handshake.
|
|
294
|
+
// Drop it on failure, otherwise every later call would replay the rejection.
|
|
295
|
+
mcpSessions.set(key, pending);
|
|
296
|
+
pending.catch(() => {
|
|
297
|
+
if (mcpSessions.get(key) === pending) mcpSessions.delete(key);
|
|
298
|
+
});
|
|
299
|
+
return pending;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function negotiateMcpSession(endpoint, requestSignal, headersFor) {
|
|
303
|
+
const response = await fetch(endpoint.url, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
signal: requestSignal,
|
|
306
|
+
headers: headersFor(null),
|
|
307
|
+
body: JSON.stringify({
|
|
308
|
+
jsonrpc: '2.0',
|
|
309
|
+
id: 0,
|
|
310
|
+
method: 'initialize',
|
|
311
|
+
params: {
|
|
312
|
+
protocolVersion: '2025-06-18',
|
|
313
|
+
capabilities: {},
|
|
314
|
+
clientInfo: { name: 'wiki-manager', version: WIKI_MANAGER_VERSION },
|
|
315
|
+
},
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
const text = await response.text();
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
throw new Error(`initialize failed: ${response.status} ${text.slice(0, 160)}`.trim());
|
|
321
|
+
}
|
|
322
|
+
// A stateless server completes `initialize` without issuing a session id and
|
|
323
|
+
// then serves every later request unsessioned. That is valid: remember the
|
|
324
|
+
// absence so we do not re-handshake before each call.
|
|
325
|
+
const sessionId = response.headers.get('mcp-session-id') ?? null;
|
|
326
|
+
if (sessionId) {
|
|
327
|
+
// Notification, not a request: the server owes no response, and blocking
|
|
328
|
+
// the caller on it would add a round-trip to every cold start.
|
|
329
|
+
fetch(endpoint.url, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: headersFor(sessionId),
|
|
332
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
|
|
333
|
+
}).catch(() => {});
|
|
334
|
+
}
|
|
335
|
+
return sessionId;
|
|
336
|
+
}
|
|
337
|
+
|
|
266
338
|
async function mcpRequest(endpoint, method, params, signal, options = {}) {
|
|
267
339
|
if (!endpoint.url) throw new Error('missing endpoint URL');
|
|
268
340
|
const controller = new AbortController();
|
|
269
341
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000);
|
|
270
342
|
const requestSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
343
|
+
const key = sessionKey(endpoint);
|
|
271
344
|
|
|
272
|
-
const
|
|
345
|
+
const headersFor = (sessionId) => {
|
|
273
346
|
const h = {
|
|
274
347
|
accept: 'application/json, text/event-stream',
|
|
275
348
|
'content-type': 'application/json',
|
|
276
349
|
...(endpoint.headers ?? {}),
|
|
277
350
|
};
|
|
278
351
|
if (endpoint.token) h.authorization = `Bearer ${endpoint.token}`;
|
|
279
|
-
if (
|
|
352
|
+
if (sessionId) h['mcp-session-id'] = sessionId;
|
|
280
353
|
return h;
|
|
281
354
|
};
|
|
282
355
|
|
|
283
|
-
const doRequest = async (
|
|
356
|
+
const doRequest = async (sessionId) => {
|
|
284
357
|
const response = await fetch(endpoint.url, {
|
|
285
358
|
method: 'POST',
|
|
286
359
|
signal: requestSignal,
|
|
287
|
-
headers:
|
|
288
|
-
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method
|
|
360
|
+
headers: headersFor(sessionId),
|
|
361
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
289
362
|
});
|
|
363
|
+
// Some servers rotate the session id mid-stream; always take the latest.
|
|
290
364
|
const sid = response.headers.get('mcp-session-id');
|
|
291
|
-
if (sid)
|
|
365
|
+
if (sid && sid !== sessionId) mcpSessions.set(key, Promise.resolve(sid));
|
|
292
366
|
return response;
|
|
293
367
|
};
|
|
294
368
|
|
|
295
369
|
try {
|
|
296
|
-
|
|
370
|
+
const session = mcpSessions.get(key)
|
|
371
|
+
?? openMcpSession(endpoint, key, requestSignal, headersFor);
|
|
372
|
+
let response = await doRequest(await session);
|
|
297
373
|
let text = await response.text();
|
|
298
374
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
jsonrpc: '2.0',
|
|
307
|
-
id: 0,
|
|
308
|
-
method: 'initialize',
|
|
309
|
-
params: {
|
|
310
|
-
protocolVersion: '2025-06-18',
|
|
311
|
-
capabilities: {},
|
|
312
|
-
clientInfo: { name: 'wiki-manager', version: WIKI_MANAGER_VERSION },
|
|
313
|
-
},
|
|
314
|
-
}),
|
|
315
|
-
});
|
|
316
|
-
await initResponse.text();
|
|
317
|
-
const sessionId = initResponse.headers.get('mcp-session-id');
|
|
318
|
-
if (!initResponse.ok || !sessionId) {
|
|
319
|
-
throw new Error(`initialize failed: ${initResponse.status}`);
|
|
320
|
-
}
|
|
321
|
-
endpoint._sessionId = sessionId;
|
|
322
|
-
// Fire-and-forget: complete the handshake without blocking the retry
|
|
323
|
-
fetch(endpoint.url, {
|
|
324
|
-
method: 'POST',
|
|
325
|
-
headers: buildHeaders(),
|
|
326
|
-
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }),
|
|
327
|
-
}).catch(() => {});
|
|
328
|
-
response = await doRequest(method, params);
|
|
375
|
+
// The session is server-memory state: an agent restart drops it, and the
|
|
376
|
+
// server answers 400 or 404 depending on its SDK. Re-negotiate and replay
|
|
377
|
+
// once, without inspecting the message.
|
|
378
|
+
if (response.status === 400 || response.status === 404) {
|
|
379
|
+
mcpSessions.delete(key);
|
|
380
|
+
const sessionId = await openMcpSession(endpoint, key, requestSignal, headersFor);
|
|
381
|
+
response = await doRequest(sessionId);
|
|
329
382
|
text = await response.text();
|
|
330
383
|
}
|
|
331
384
|
|
|
@@ -407,6 +460,10 @@ export function resetMcpThrottleForTests() {
|
|
|
407
460
|
mcpThrottleStarts.clear();
|
|
408
461
|
}
|
|
409
462
|
|
|
463
|
+
export function resetMcpSessionsForTests() {
|
|
464
|
+
mcpSessions.clear();
|
|
465
|
+
}
|
|
466
|
+
|
|
410
467
|
export function formatMcpToolResult(result) {
|
|
411
468
|
if (!result) return 'No result.';
|
|
412
469
|
const content = result.content;
|
|
@@ -619,6 +676,24 @@ export function formatMcpToolsForAgent(mcpStatus, { include } = {}) {
|
|
|
619
676
|
// tool names here is what teaches the model to emit unqualified calls.
|
|
620
677
|
sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
|
|
621
678
|
}
|
|
679
|
+
// An agent that failed its probe is otherwise invisible here, and the model
|
|
680
|
+
// fills the silence by inferring the agent was never set up — it then asks
|
|
681
|
+
// the user for credentials that already exist. Name the agent, name the
|
|
682
|
+
// transport failure, and state explicitly that this says nothing about its
|
|
683
|
+
// configuration.
|
|
684
|
+
const unreachable = Object.entries(mcpStatus ?? {})
|
|
685
|
+
.filter(([, value]) => value.status !== 'connected' && value.toolError)
|
|
686
|
+
.map(([name, value]) => `${name} (${compactDescription(value.toolError)})`);
|
|
687
|
+
if (unreachable.length > 0) {
|
|
688
|
+
sections.push(
|
|
689
|
+
'',
|
|
690
|
+
`Unreachable right now: ${unreachable.join(', ')}.`,
|
|
691
|
+
'These agents are declared and may well be configured and running — the manager'
|
|
692
|
+
+ ' simply failed to reach them. Report the connection failure as such; never'
|
|
693
|
+
+ ' infer that such an agent is unconfigured, and never ask the user for its'
|
|
694
|
+
+ ' credentials or URL on that basis.',
|
|
695
|
+
);
|
|
696
|
+
}
|
|
622
697
|
return sections.length > 0 ? sections.join('\n') : 'No connected MCP tools discovered yet.';
|
|
623
698
|
}
|
|
624
699
|
|
package/src/core/mcp.test.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
callMcpTool,
|
|
9
9
|
discoverMcpTools,
|
|
10
10
|
formatMcpToolsForAgent,
|
|
11
|
+
resetMcpSessionsForTests,
|
|
11
12
|
resetMcpThrottleForTests,
|
|
12
13
|
resolveRetryPolicy,
|
|
13
14
|
resolveToolCallName,
|
|
@@ -366,8 +367,9 @@ test('callMcpTool throttles MCP traffic independently per endpoint', async () =>
|
|
|
366
367
|
const originalFetch = globalThis.fetch;
|
|
367
368
|
const originalWindow = process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS;
|
|
368
369
|
const starts = [];
|
|
369
|
-
globalThis.fetch = async () => {
|
|
370
|
-
|
|
370
|
+
globalThis.fetch = async (_url, init) => {
|
|
371
|
+
// Only tool traffic is throttled; the one-off session handshake is not.
|
|
372
|
+
if (JSON.parse(init.body).method === 'tools/call') starts.push(Date.now());
|
|
371
373
|
return {
|
|
372
374
|
ok: true,
|
|
373
375
|
status: 200,
|
|
@@ -377,6 +379,7 @@ test('callMcpTool throttles MCP traffic independently per endpoint', async () =>
|
|
|
377
379
|
};
|
|
378
380
|
process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS = '30';
|
|
379
381
|
resetMcpThrottleForTests();
|
|
382
|
+
resetMcpSessionsForTests();
|
|
380
383
|
|
|
381
384
|
try {
|
|
382
385
|
const status = {
|
|
@@ -523,18 +526,24 @@ test('discoverMcpTools downgrades connected endpoint when tool discovery fails',
|
|
|
523
526
|
}
|
|
524
527
|
});
|
|
525
528
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
529
|
+
// A stateful server may reject any cold request, and every SDK words that
|
|
530
|
+
// rejection differently ("No valid session" for the Node SDK, "Missing session
|
|
531
|
+
// ID" for the Python one CME runs). The client must never read that prose: it
|
|
532
|
+
// handshakes first, so the rejection is simply never provoked.
|
|
533
|
+
function statefulMcpServer(rejectionMessage, { onMethod } = {}) {
|
|
534
|
+
return async (_url, init) => {
|
|
530
535
|
const body = JSON.parse(init.body);
|
|
531
|
-
|
|
532
|
-
if (body.method
|
|
536
|
+
onMethod?.(body.method, init.headers['mcp-session-id'] ?? null);
|
|
537
|
+
if (body.method !== 'initialize' && !init.headers['mcp-session-id']) {
|
|
533
538
|
return {
|
|
534
539
|
ok: false,
|
|
535
540
|
status: 400,
|
|
536
541
|
headers: { get: () => null },
|
|
537
|
-
text: async () =>
|
|
542
|
+
text: async () => JSON.stringify({
|
|
543
|
+
jsonrpc: '2.0',
|
|
544
|
+
error: { code: -32000, message: rejectionMessage },
|
|
545
|
+
id: null,
|
|
546
|
+
}),
|
|
538
547
|
};
|
|
539
548
|
}
|
|
540
549
|
if (body.method === 'initialize') {
|
|
@@ -546,35 +555,166 @@ test('discoverMcpTools initializes SDK servers that report no valid session', as
|
|
|
546
555
|
};
|
|
547
556
|
}
|
|
548
557
|
if (body.method === 'notifications/initialized') {
|
|
558
|
+
return { ok: true, status: 202, headers: { get: () => null }, text: async () => '' };
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
ok: true,
|
|
562
|
+
status: 200,
|
|
563
|
+
headers: { get: () => null },
|
|
564
|
+
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"cme_status"}]}}',
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
for (const rejection of ['No valid session', 'Missing session ID', 'Session not found']) {
|
|
570
|
+
test(`discoverMcpTools handshakes before the first call — server says "${rejection}"`, async () => {
|
|
571
|
+
const originalFetch = globalThis.fetch;
|
|
572
|
+
const methods = [];
|
|
573
|
+
resetMcpSessionsForTests();
|
|
574
|
+
globalThis.fetch = statefulMcpServer(rejection, { onMethod: (m) => methods.push(m) });
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
const status = await discoverMcpTools({
|
|
578
|
+
cme: {
|
|
579
|
+
status: 'configured',
|
|
580
|
+
url: 'http://127.0.0.1:3336/mcp/',
|
|
581
|
+
headers: { authorization: 'Bearer token' },
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
assert.equal(status.cme.status, 'connected', status.cme.toolError ?? '');
|
|
586
|
+
assert.deepEqual(status.cme.tools.map((tool) => tool.name), ['cme_status']);
|
|
587
|
+
// initialize comes first: the cold rejection is never provoked, so its
|
|
588
|
+
// wording cannot matter.
|
|
589
|
+
assert.deepEqual(methods, ['initialize', 'notifications/initialized', 'tools/list']);
|
|
590
|
+
} finally {
|
|
591
|
+
globalThis.fetch = originalFetch;
|
|
592
|
+
resetMcpSessionsForTests();
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
test('discoverMcpTools reuses one handshake across concurrent endpoints', async () => {
|
|
598
|
+
const originalFetch = globalThis.fetch;
|
|
599
|
+
const methods = [];
|
|
600
|
+
resetMcpSessionsForTests();
|
|
601
|
+
globalThis.fetch = statefulMcpServer('Missing session ID', { onMethod: (m) => methods.push(m) });
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const endpoint = {
|
|
605
|
+
status: 'configured',
|
|
606
|
+
url: 'http://127.0.0.1:3336/mcp/',
|
|
607
|
+
headers: { authorization: 'Bearer token' },
|
|
608
|
+
};
|
|
609
|
+
await discoverMcpTools({ cme: endpoint });
|
|
610
|
+
await discoverMcpTools({ cme: { ...endpoint } });
|
|
611
|
+
|
|
612
|
+
// Second pass rebuilds the endpoint object from the endpoints file, yet the
|
|
613
|
+
// session is keyed by transport identity — no second handshake.
|
|
614
|
+
assert.deepEqual(
|
|
615
|
+
methods.filter((m) => m === 'initialize').length,
|
|
616
|
+
1,
|
|
617
|
+
);
|
|
618
|
+
} finally {
|
|
619
|
+
globalThis.fetch = originalFetch;
|
|
620
|
+
resetMcpSessionsForTests();
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('discoverMcpTools accepts a stateless server that issues no session id', async () => {
|
|
625
|
+
const originalFetch = globalThis.fetch;
|
|
626
|
+
const methods = [];
|
|
627
|
+
resetMcpSessionsForTests();
|
|
628
|
+
globalThis.fetch = async (_url, init) => {
|
|
629
|
+
const body = JSON.parse(init.body);
|
|
630
|
+
methods.push(body.method);
|
|
631
|
+
if (body.method === 'initialize') {
|
|
549
632
|
return {
|
|
550
633
|
ok: true,
|
|
551
|
-
status:
|
|
634
|
+
status: 200,
|
|
552
635
|
headers: { get: () => null },
|
|
553
|
-
text: async () => '',
|
|
636
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
554
637
|
};
|
|
555
638
|
}
|
|
556
639
|
return {
|
|
557
640
|
ok: true,
|
|
558
641
|
status: 200,
|
|
559
642
|
headers: { get: () => null },
|
|
560
|
-
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"
|
|
643
|
+
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"documents_convert_to_markdown"}]}}',
|
|
561
644
|
};
|
|
562
645
|
};
|
|
563
646
|
|
|
564
647
|
try {
|
|
565
648
|
const status = await discoverMcpTools({
|
|
566
|
-
|
|
567
|
-
status: 'configured',
|
|
568
|
-
url: 'http://127.0.0.1:3338/mcp/',
|
|
569
|
-
headers: { authorization: 'Bearer token' },
|
|
570
|
-
},
|
|
649
|
+
documents: { status: 'configured', url: 'http://127.0.0.1:3337/mcp/' },
|
|
571
650
|
});
|
|
572
651
|
|
|
573
|
-
assert.equal(status.
|
|
574
|
-
|
|
575
|
-
assert.deepEqual(methods, ['
|
|
652
|
+
assert.equal(status.documents.status, 'connected', status.documents.toolError ?? '');
|
|
653
|
+
// No session id issued: no notifications/initialized, and no re-handshake.
|
|
654
|
+
assert.deepEqual(methods, ['initialize', 'tools/list']);
|
|
576
655
|
} finally {
|
|
577
656
|
globalThis.fetch = originalFetch;
|
|
657
|
+
resetMcpSessionsForTests();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test('callMcpTool re-negotiates and replays once when the agent drops the session', async () => {
|
|
662
|
+
const originalFetch = globalThis.fetch;
|
|
663
|
+
const requests = [];
|
|
664
|
+
const sessions = ['stale-session', 'fresh-session'];
|
|
665
|
+
resetMcpSessionsForTests();
|
|
666
|
+
globalThis.fetch = async (_url, init) => {
|
|
667
|
+
const body = JSON.parse(init.body);
|
|
668
|
+
requests.push({
|
|
669
|
+
method: body.method,
|
|
670
|
+
sessionId: init.headers['mcp-session-id'] ?? null,
|
|
671
|
+
});
|
|
672
|
+
if (body.method === 'initialize') {
|
|
673
|
+
const issued = sessions.shift();
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
status: 200,
|
|
677
|
+
headers: { get: (name) => name === 'mcp-session-id' ? issued : null },
|
|
678
|
+
text: async () => '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-06-18"}}',
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (body.method === 'notifications/initialized') {
|
|
682
|
+
return { ok: true, status: 202, headers: { get: () => null }, text: async () => '' };
|
|
683
|
+
}
|
|
684
|
+
// The agent restarted: the session it issued is gone. The body is
|
|
685
|
+
// deliberately opaque — recovery keys on the status code alone.
|
|
686
|
+
if (init.headers['mcp-session-id'] === 'stale-session') {
|
|
687
|
+
return {
|
|
688
|
+
ok: false,
|
|
689
|
+
status: 404,
|
|
690
|
+
headers: { get: () => null },
|
|
691
|
+
text: async () => 'Not Found',
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return {
|
|
695
|
+
ok: true,
|
|
696
|
+
status: 200,
|
|
697
|
+
headers: { get: () => null },
|
|
698
|
+
text: async () => '{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"status: configured"}]}}',
|
|
699
|
+
};
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
const endpoint = { status: 'connected', url: 'http://127.0.0.1:3336/mcp/' };
|
|
704
|
+
const result = await callMcpTool({ cme: endpoint }, 'cme', 'cme_status', { workspace: 'juno' });
|
|
705
|
+
|
|
706
|
+
assert.equal(result.content[0].text, 'status: configured');
|
|
707
|
+
assert.deepEqual(requests, [
|
|
708
|
+
{ method: 'initialize', sessionId: null },
|
|
709
|
+
{ method: 'notifications/initialized', sessionId: 'stale-session' },
|
|
710
|
+
{ method: 'tools/call', sessionId: 'stale-session' },
|
|
711
|
+
{ method: 'initialize', sessionId: null },
|
|
712
|
+
{ method: 'notifications/initialized', sessionId: 'fresh-session' },
|
|
713
|
+
{ method: 'tools/call', sessionId: 'fresh-session' },
|
|
714
|
+
]);
|
|
715
|
+
} finally {
|
|
716
|
+
globalThis.fetch = originalFetch;
|
|
717
|
+
resetMcpSessionsForTests();
|
|
578
718
|
}
|
|
579
719
|
});
|
|
580
720
|
|
|
@@ -622,3 +762,24 @@ test('truncateToolResult keeps short results intact and bounds long ones head+ta
|
|
|
622
762
|
assert.match(bounded, /-END$/);
|
|
623
763
|
assert.match(bounded, /caractères tronqués/);
|
|
624
764
|
});
|
|
765
|
+
|
|
766
|
+
test('formatMcpToolsForAgent names unreachable agents instead of hiding them', () => {
|
|
767
|
+
const listing = formatMcpToolsForAgent({
|
|
768
|
+
documents: { status: 'connected', tools: [{ name: 'documents_convert_to_markdown' }] },
|
|
769
|
+
cme: { status: 'configured', toolError: '400 Bad Request: Missing session ID' },
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
assert.match(listing, /documents__documents_convert_to_markdown/);
|
|
773
|
+
assert.match(listing, /Unreachable right now: cme \(400 Bad Request: Missing session ID\)/);
|
|
774
|
+
// The model must not be able to read this as "not configured".
|
|
775
|
+
assert.match(listing, /never\s+infer that such an agent is unconfigured/);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('formatMcpToolsForAgent stays silent about endpoints that simply are not running', () => {
|
|
779
|
+
const listing = formatMcpToolsForAgent({
|
|
780
|
+
wiki: { status: 'configured', runtime: 'not running' },
|
|
781
|
+
documents: { status: 'connected', tools: [{ name: 'documents_convert_to_markdown' }] },
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
assert.doesNotMatch(listing, /Unreachable/);
|
|
785
|
+
});
|