@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/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.29';
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 buildHeaders = () => {
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 (endpoint._sessionId) h['mcp-session-id'] = endpoint._sessionId;
352
+ if (sessionId) h['mcp-session-id'] = sessionId;
280
353
  return h;
281
354
  };
282
355
 
283
- const doRequest = async (m, p) => {
356
+ const doRequest = async (sessionId) => {
284
357
  const response = await fetch(endpoint.url, {
285
358
  method: 'POST',
286
359
  signal: requestSignal,
287
- headers: buildHeaders(),
288
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: m, params: p }),
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) endpoint._sessionId = sid;
365
+ if (sid && sid !== sessionId) mcpSessions.set(key, Promise.resolve(sid));
292
366
  return response;
293
367
  };
294
368
 
295
369
  try {
296
- let response = await doRequest(method, params);
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
- if (response.status === 400 && /session(?:\s+ID)?/i.test(text)) {
300
- endpoint._sessionId = null;
301
- const initResponse = await fetch(endpoint.url, {
302
- method: 'POST',
303
- signal: requestSignal,
304
- headers: buildHeaders(),
305
- body: JSON.stringify({
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
 
@@ -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
- starts.push(Date.now());
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
- test('discoverMcpTools initializes SDK servers that report no valid session', async () => {
527
- const originalFetch = globalThis.fetch;
528
- const methods = [];
529
- globalThis.fetch = async (_url, init) => {
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
- methods.push(body.method);
532
- if (body.method === 'tools/list' && !init.headers['mcp-session-id']) {
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 () => '{"jsonrpc":"2.0","error":{"code":-32000,"message":"No valid session"},"id":null}',
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: 202,
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":"connectors_google_status"}]}}',
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
- connectors: {
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.connectors.status, 'connected');
574
- assert.deepEqual(status.connectors.tools.map((tool) => tool.name), ['connectors_google_status']);
575
- assert.deepEqual(methods, ['tools/list', 'initialize', 'notifications/initialized', 'tools/list']);
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
+ });