@dotdrelle/wiki-manager 0.15.35 → 0.15.38

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/README.md CHANGED
@@ -556,6 +556,48 @@ including under `"*"`. Multi-step work belongs to `/agent`.
556
556
  An `allowActions` key written by an older manager is folded into `allow` on
557
557
  read and removed on the next `agents up`.
558
558
 
559
+ ### Adding a connector from the served chat UI
560
+
561
+ `mcp.endpoints.json` stays hand-editable, but the Connectors panel of
562
+ `llm-wiki serve` can now write it. Connecting a card there upserts the endpoint
563
+ through the runtime (`POST /mcp/endpoints`), and the runtime immediately
564
+ re-reads the file and rediscovers tools and agents — no restart, and the new
565
+ tools are usable in the same breath by `/chat`, `/agent` and any subsequent
566
+ plan.
567
+
568
+ Because a server absent from `chatAccess` gets **zero** tools in `/chat`, the
569
+ upsert writes `"allow": "*"` for it. That is the deliberate difference between
570
+ a connector declared by hand — where you choose the tool list — and one added
571
+ from the UI, where the person adding it is the person who will use it. Narrow
572
+ it afterwards by editing the file.
573
+
574
+ Three origins are distinguished, and the UI labels each card:
575
+
576
+ | origin | shown as | who owns it |
577
+ | --- | --- | --- |
578
+ | `wiki`, `production`, `llm-wiki`, `wiki-production` | `internal` | the workspace stack. Fields read-only, no delete — the runtime rejects any change to these names |
579
+ | declared in `mcp.endpoints.json` by hand or by `agents up` | `global config` | the operator. CME, Documents, Mailer, Connectors, Exa… |
580
+ | added from the UI | `added here` | carries `"managedBy": "serve-ui"` in the file |
581
+
582
+ Removing a `global config` connector is a workspace-wide act — it leaves every
583
+ chat, agent and future plan — so the UI says so before confirming. The
584
+ container and its data are untouched; only the wiring is removed. The name is
585
+ also pushed into `disabledMcpServers`, which the scaffold honours, so a
586
+ connector you removed on purpose is not silently restored by the next
587
+ `agents up` merging the packaged example back in.
588
+
589
+ Renaming is atomic: the UI sends `previousName`, and the endpoint, its
590
+ `Authorization` header and its `chatAccess` entry move together under the new
591
+ key. A rename onto an existing name, or from a name that is not there, is
592
+ rejected rather than half-applied.
593
+
594
+ `POST /mcp/endpoints` returns **409 while a plan is running** — connector
595
+ wiring must not change under a run that already resolved its agents. The chat
596
+ UI treats that as what it is: the MCP handshake succeeded, so the card stays
597
+ connected and usable in this browser, badged `local only` with
598
+ "runtime synchronization pending", and the write is retried on the next
599
+ reconnect. A busy runtime never presents itself as a broken connector.
600
+
559
601
  MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
560
602
  They also share a per-endpoint outbound control budget (45 RPM by default,
561
603
  configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.15.35",
3
+ "version": "0.15.38",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -14,6 +14,7 @@ import { refreshRunningContainers } from '../core/wikiSetup.js';
14
14
  import { applySessionWikircProfile } from '../core/sessionConfig.js';
15
15
  import { listWikircProfiles } from '../core/wikirc.js';
16
16
  import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
17
+ import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from '../core/mcpEndpoints.js';
17
18
  import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
18
19
  import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
19
20
  import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
@@ -740,6 +741,14 @@ async function runRuntime(argv, agent) {
740
741
  let serverHandle = null;
741
742
  const contexts = new Map();
742
743
 
744
+ async function refreshAllMcpContexts() {
745
+ const resolved = await Promise.all([...new Set(contexts.values())].map((value) => Promise.resolve(value)));
746
+ await Promise.all(resolved.map(async (context) => {
747
+ await refreshMcpRuntimeStatus(context.session);
748
+ await discoverAgentsOnce(context.session);
749
+ }));
750
+ }
751
+
743
752
  async function getWorkspaceContext(workspaceName = null) {
744
753
  const requestedWorkspace = workspaceName ? String(workspaceName).trim() : null;
745
754
  const key = requestedWorkspace ?? '__default__';
@@ -1416,6 +1425,17 @@ async function runRuntime(argv, agent) {
1416
1425
  config,
1417
1426
  };
1418
1427
  },
1428
+ listMcpEndpoints: async () => ({ endpoints: listManagedMcpEndpoints() }),
1429
+ upsertMcpEndpoint: async (_context, body) => {
1430
+ const endpoint = upsertManagedMcpEndpoint(body);
1431
+ await refreshAllMcpContexts();
1432
+ return { ok: true, endpoint };
1433
+ },
1434
+ deleteMcpEndpoint: async (_context, body) => {
1435
+ const endpoint = deleteManagedMcpEndpoint(body.name);
1436
+ await refreshAllMcpContexts();
1437
+ return { ok: true, endpoint };
1438
+ },
1419
1439
  token: auth.token,
1420
1440
  });
1421
1441
  const recovery = await recoverRuntime();
@@ -338,7 +338,7 @@ function workspaceStatsColumns(stats, session) {
338
338
  };
339
339
  }
340
340
 
341
- function workspaceLoadedText(workspace, summary, session) {
341
+ function workspaceLoadedText(workspace, summary, session, mcpError = null) {
342
342
  const profiles = listWikircProfiles(workspace.workspacePath);
343
343
  const profileLines = profiles.length > 0
344
344
  ? profiles.map((profile) => {
@@ -374,6 +374,7 @@ function workspaceLoadedText(workspace, summary, session) {
374
374
  '',
375
375
  `llm: ${session.llm ? 'configured' : 'missing config'}`,
376
376
  `mcp: ${Object.values(session.mcp ?? {}).filter((value) => value.status === 'connected').length} connected`,
377
+ ...(mcpError ? ['', `MCP discovery failed: ${mcpError}`] : []),
377
378
  ].join('\n');
378
379
  }
379
380
 
@@ -941,9 +942,17 @@ export async function handleSlashCommand(line, context) {
941
942
  context.session.workspaceEnv = workspace.env;
942
943
  context.session.workspaceEnvFile = workspace.envFile;
943
944
  context.session.systemPrompt = loadWorkspaceSystemPrompt(workspace.workspacePath);
945
+ let summary;
944
946
  try {
945
947
  step(`Workspace: loading ${workspace.name} config…`);
946
- const { summary } = applySessionWikircProfile(context.session, 'default');
948
+ ({ summary } = applySessionWikircProfile(context.session, 'default'));
949
+ } catch (err) {
950
+ const message = err instanceof Error ? err.message : String(err);
951
+ return {
952
+ output: workspaceLoadedWithoutConfigText(workspace, message),
953
+ };
954
+ }
955
+ try {
947
956
  step(`Workspace: discovering ${workspace.name} MCP tools…`);
948
957
  await refreshMcpRuntimeStatus(context.session);
949
958
  return {
@@ -952,7 +961,7 @@ export async function handleSlashCommand(line, context) {
952
961
  } catch (err) {
953
962
  const message = err instanceof Error ? err.message : String(err);
954
963
  return {
955
- output: workspaceLoadedWithoutConfigText(workspace, message),
964
+ output: workspaceLoadedText(workspace, summary, context.session, message),
956
965
  };
957
966
  }
958
967
  }
@@ -345,6 +345,54 @@ test('/use loads only workspaces and /config use switches wikirc profiles', asyn
345
345
  }
346
346
  });
347
347
 
348
+ test('/use keeps a loaded wikirc when MCP discovery fails', async () => {
349
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-use-mcp-error-'));
350
+ const registryRoot = join(root, 'registry');
351
+ const workspacePath = join(root, 'workspace');
352
+ const registryPath = join(registryRoot, 'demo');
353
+ mkdirSync(registryPath, { recursive: true });
354
+ mkdirSync(workspacePath, { recursive: true });
355
+ mkdirSync(join(root, 'mcp.endpoints.json'));
356
+ writeFileSync(join(root, '.env'), '', 'utf8');
357
+ writeFileSync(join(registryPath, '.env'), [
358
+ 'WORKSPACE_NAME=demo',
359
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
360
+ '',
361
+ ].join('\n'), 'utf8');
362
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
363
+ 'language: fr',
364
+ 'llm:',
365
+ ' provider: openai-compatible',
366
+ ' engine: openai',
367
+ ' model: test-model',
368
+ ' apiKey: test-key',
369
+ '',
370
+ ].join('\n'), 'utf8');
371
+
372
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
373
+ const previousEnvFile = process.env.WIKI_MANAGER_ENV_FILE;
374
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
375
+ process.env.WIKI_MANAGER_ENV_FILE = join(root, '.env');
376
+ try {
377
+ const session = {};
378
+ const result = await handleSlashCommand('/use demo', {
379
+ packageJson: { version: 'test' },
380
+ session,
381
+ });
382
+
383
+ assert.equal(session.wikirc?.profile, 'default');
384
+ assert.equal(session.wikircConfig?.llm?.model, 'test-model');
385
+ assert.match(result.output ?? '', /profile: default/);
386
+ assert.match(result.output ?? '', /MCP discovery failed:.*EISDIR/s);
387
+ assert.doesNotMatch(result.output ?? '', /Wikirc not loaded/);
388
+ } finally {
389
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
390
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
391
+ if (previousEnvFile === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
392
+ else process.env.WIKI_MANAGER_ENV_FILE = previousEnvFile;
393
+ }
394
+ });
395
+
348
396
  test('/queue cancel refuses runtime-managed items instead of fake-cancelling locally', async () => {
349
397
  // syncRuntimeState replaces session.jobQueue with the runtime queue and tags
350
398
  // origin:'runtime' — a local cancel would be reverted by the next SSE sync,
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.35",
3
- "commit": "9bca2fd"
2
+ "version": "0.15.38",
3
+ "commit": "abf8356"
4
4
  }
package/src/core/env.js CHANGED
@@ -121,9 +121,10 @@ export function ensureManagerScaffold({ log = () => {} } = {}) {
121
121
  const missing = Object.keys(example).filter((key) => !(key in current));
122
122
  const currentServers = current.mcpServers;
123
123
  const exampleServers = example.mcpServers;
124
+ const disabledServers = new Set(Array.isArray(current.disabledMcpServers) ? current.disabledMcpServers.map(String) : []);
124
125
  const missingServers = currentServers && typeof currentServers === 'object' && !Array.isArray(currentServers)
125
126
  && exampleServers && typeof exampleServers === 'object' && !Array.isArray(exampleServers)
126
- ? Object.keys(exampleServers).filter((key) => !(key in currentServers))
127
+ ? Object.keys(exampleServers).filter((key) => !(key in currentServers) && !disabledServers.has(key))
127
128
  : [];
128
129
  if (missing.length > 0) {
129
130
  for (const key of missing) current[key] = example[key];
@@ -102,6 +102,20 @@ test('scaffold never overwrites an existing chatAccess, including explicit null'
102
102
  });
103
103
  });
104
104
 
105
+ test('scaffold does not restore a packaged MCP explicitly removed in the UI', () => {
106
+ withTempManagerDir((dir) => {
107
+ const endpointsFile = join(dir, 'mcp.endpoints.json');
108
+ writeFileSync(endpointsFile, JSON.stringify({
109
+ mcpServers: {},
110
+ disabledMcpServers: ['cme'],
111
+ }, null, 2));
112
+ ensureManagerScaffold();
113
+ const after = JSON.parse(readFileSync(endpointsFile, 'utf8'));
114
+ assert.equal(after.mcpServers.cme, undefined);
115
+ assert.ok(after.mcpServers.documents);
116
+ });
117
+ });
118
+
105
119
  test('scaffold leaves an invalid endpoints file strictly alone', () => {
106
120
  withTempManagerDir((dir) => {
107
121
  const endpointsFile = join(dir, 'mcp.endpoints.json');
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.35';
4
+ const WIKI_MANAGER_VERSION = '0.15.38';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -1,5 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import './mcpEndpoints.test.js';
3
4
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
@@ -0,0 +1,96 @@
1
+ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { managerMcpEndpointsFile } from './env.js';
3
+
4
+ const PROTECTED_SERVERS = new Set(['wiki', 'production', 'llm-wiki', 'wiki-production']);
5
+
6
+ function readDocument() {
7
+ const filePath = managerMcpEndpointsFile();
8
+ const raw = existsSync(filePath) ? JSON.parse(readFileSync(filePath, 'utf8')) : {};
9
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
10
+ throw new Error('mcp.endpoints.json must contain a JSON object.');
11
+ }
12
+ raw.mcpServers ??= {};
13
+ raw.disabledMcpServers = Array.isArray(raw.disabledMcpServers)
14
+ ? raw.disabledMcpServers.map(String).filter(Boolean)
15
+ : [];
16
+ raw.chatAccess ??= { maxToolIterations: 8, servers: {} };
17
+ raw.chatAccess.servers ??= {};
18
+ return { filePath, raw };
19
+ }
20
+
21
+ function writeDocument(filePath, raw) {
22
+ const temporary = `${filePath}.tmp.${process.pid}`;
23
+ writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
24
+ chmodSync(temporary, 0o600);
25
+ renameSync(temporary, filePath);
26
+ }
27
+
28
+ function normalizeName(value) {
29
+ const name = String(value ?? '').trim();
30
+ if (!name || name.length > 80 || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) {
31
+ throw new Error('MCP server name must use letters, numbers, dots, underscores or hyphens.');
32
+ }
33
+ if (PROTECTED_SERVERS.has(name)) throw new Error(`Built-in MCP server cannot be changed: ${name}`);
34
+ return name;
35
+ }
36
+
37
+ function normalizeUrl(value) {
38
+ const url = String(value ?? '').trim();
39
+ let parsed;
40
+ try { parsed = new URL(url); } catch { throw new Error('MCP server URL is invalid.'); }
41
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('MCP server URL must use HTTP or HTTPS.');
42
+ return url;
43
+ }
44
+
45
+ export function listManagedMcpEndpoints() {
46
+ const { raw } = readDocument();
47
+ return Object.entries(raw.mcpServers).map(([name, endpoint]) => ({
48
+ name,
49
+ url: String(endpoint?.url ?? ''),
50
+ bearer: String(endpoint?.headers?.Authorization ?? endpoint?.headers?.authorization ?? '').replace(/^Bearer\s+/i, ''),
51
+ allow: raw.chatAccess?.servers?.[name]?.allow ?? null,
52
+ }));
53
+ }
54
+
55
+ export function upsertManagedMcpEndpoint({ name: rawName, previousName: rawPreviousName = null, url: rawUrl, bearer = '' } = {}) {
56
+ const name = normalizeName(rawName);
57
+ const previousName = rawPreviousName ? normalizeName(rawPreviousName) : name;
58
+ const url = normalizeUrl(rawUrl);
59
+ const { filePath, raw } = readDocument();
60
+ if (previousName !== name && !Object.hasOwn(raw.mcpServers, previousName)) {
61
+ throw new Error(`MCP server to rename was not found: ${previousName}`);
62
+ }
63
+ if (previousName !== name && Object.hasOwn(raw.mcpServers, name)) {
64
+ throw new Error(`MCP server already exists: ${name}`);
65
+ }
66
+ const previous = raw.mcpServers[previousName] && typeof raw.mcpServers[previousName] === 'object'
67
+ ? raw.mcpServers[previousName]
68
+ : {};
69
+ const headers = { ...(previous.headers ?? {}) };
70
+ delete headers.authorization;
71
+ delete headers.Authorization;
72
+ if (String(bearer).trim()) headers.Authorization = `Bearer ${String(bearer).trim()}`;
73
+ const managedBy = previous.managedBy === 'serve-ui' ? 'serve-ui' : (Object.keys(previous).length ? null : 'serve-ui');
74
+ raw.mcpServers[name] = { ...previous, url, ...(managedBy ? { managedBy } : {}), ...(Object.keys(headers).length ? { headers } : {}) };
75
+ if (!Object.keys(headers).length) delete raw.mcpServers[name].headers;
76
+ if (previousName !== name) {
77
+ delete raw.mcpServers[previousName];
78
+ delete raw.chatAccess.servers[previousName];
79
+ if (!raw.disabledMcpServers.includes(previousName)) raw.disabledMcpServers.push(previousName);
80
+ }
81
+ raw.chatAccess.servers[name] = { allow: '*' };
82
+ raw.disabledMcpServers = raw.disabledMcpServers.filter((item) => item !== name);
83
+ writeDocument(filePath, raw);
84
+ return { name, previousName, url, allow: '*', hasBearer: Boolean(String(bearer).trim()), origin: managedBy ? 'ui' : 'global' };
85
+ }
86
+
87
+ export function deleteManagedMcpEndpoint(rawName) {
88
+ const name = normalizeName(rawName);
89
+ const { filePath, raw } = readDocument();
90
+ const existed = Object.hasOwn(raw.mcpServers, name);
91
+ delete raw.mcpServers[name];
92
+ delete raw.chatAccess.servers[name];
93
+ if (!raw.disabledMcpServers.includes(name)) raw.disabledMcpServers.push(name);
94
+ writeDocument(filePath, raw);
95
+ return { name, deleted: existed };
96
+ }
@@ -0,0 +1,126 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from './mcpEndpoints.js';
8
+ import { buildMcpStatus, discoverMcpTools, resetMcpSessionsForTests } from './mcp.js';
9
+ import { chatAllowedTools } from '../shell/repl.js';
10
+ import { buildAgentSystemPrompt } from '../agent/graph.js';
11
+
12
+ async function withEndpoints(fn) {
13
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-mcp-endpoints-'));
14
+ const envFile = join(root, '.env');
15
+ writeFileSync(envFile, '', 'utf8');
16
+ writeFileSync(join(root, 'mcp.endpoints.json'), JSON.stringify({
17
+ mcpServers: { cme: { url: 'http://localhost:3336/mcp/' } },
18
+ chatAccess: { maxToolIterations: 8, servers: { cme: { allow: ['cme_status'] } } },
19
+ }), 'utf8');
20
+ const previous = process.env.WIKI_MANAGER_ENV_FILE;
21
+ process.env.WIKI_MANAGER_ENV_FILE = envFile;
22
+ try { await fn(root); } finally {
23
+ if (previous === undefined) delete process.env.WIKI_MANAGER_ENV_FILE;
24
+ else process.env.WIKI_MANAGER_ENV_FILE = previous;
25
+ }
26
+ }
27
+
28
+ test('UI-managed MCP endpoints persist with bearer auth and chat wildcard access', async () => {
29
+ await withEndpoints(async (root) => {
30
+ upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'secret' });
31
+ const raw = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
32
+ assert.equal(raw.mcpServers.exa.url, 'https://mcp.exa.ai/mcp');
33
+ assert.equal(raw.mcpServers.exa.headers.Authorization, 'Bearer secret');
34
+ assert.equal(raw.chatAccess.servers.exa.allow, '*');
35
+ assert.deepEqual(listManagedMcpEndpoints().find((item) => item.name === 'exa'), {
36
+ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'secret', allow: '*',
37
+ });
38
+ });
39
+ });
40
+
41
+ test('deleting an external MCP removes endpoint and chat access without touching others', async () => {
42
+ await withEndpoints(async (root) => {
43
+ upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp' });
44
+ assert.equal(deleteManagedMcpEndpoint('cme').deleted, true);
45
+ const raw = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
46
+ assert.equal(raw.mcpServers.cme, undefined);
47
+ assert.equal(raw.chatAccess.servers.cme, undefined);
48
+ assert.deepEqual(raw.disabledMcpServers, ['cme']);
49
+ assert.equal(raw.mcpServers.exa.url, 'https://mcp.exa.ai/mcp');
50
+ upsertManagedMcpEndpoint({ name: 'cme', url: 'http://localhost:3336/mcp/' });
51
+ const restored = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
52
+ assert.deepEqual(restored.disabledMcpServers, []);
53
+ });
54
+ });
55
+
56
+ test('built-in workspace MCP endpoints cannot be changed from the connectors UI', async () => {
57
+ await withEndpoints(async () => {
58
+ assert.throws(() => deleteManagedMcpEndpoint('llm-wiki'), /cannot be changed/);
59
+ assert.throws(
60
+ () => upsertManagedMcpEndpoint({ name: 'wiki-production', url: 'https://example.test/mcp' }),
61
+ /cannot be changed/,
62
+ );
63
+ });
64
+ });
65
+
66
+ test('renaming a persisted MCP moves endpoint, chat access and deletion identity atomically', async () => {
67
+ await withEndpoints(async (root) => {
68
+ upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'first' });
69
+ const renamed = upsertManagedMcpEndpoint({
70
+ name: 'exa-search', previousName: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'second',
71
+ });
72
+ assert.equal(renamed.previousName, 'exa');
73
+ const raw = JSON.parse(readFileSync(join(root, 'mcp.endpoints.json'), 'utf8'));
74
+ assert.equal(raw.mcpServers.exa, undefined);
75
+ assert.equal(raw.chatAccess.servers.exa, undefined);
76
+ assert.equal(raw.mcpServers['exa-search'].headers.Authorization, 'Bearer second');
77
+ assert.equal(raw.chatAccess.servers['exa-search'].allow, '*');
78
+ assert.ok(raw.disabledMcpServers.includes('exa'));
79
+ });
80
+ });
81
+
82
+ test('a UI-added MCP is rediscovered for both chat wildcard and direct agent tools', async () => {
83
+ await withEndpoints(async () => {
84
+ upsertManagedMcpEndpoint({ name: 'exa', url: 'https://mcp.exa.ai/mcp', bearer: 'secret' });
85
+ const originalFetch = globalThis.fetch;
86
+ const seenAuthorization = [];
87
+ resetMcpSessionsForTests();
88
+ globalThis.fetch = async (_url, init) => {
89
+ const body = JSON.parse(init.body);
90
+ seenAuthorization.push(init.headers.authorization);
91
+ if (body.method === 'initialize') {
92
+ return {
93
+ ok: true, status: 200,
94
+ headers: { get: (name) => name === 'mcp-session-id' ? 'exa-session' : null },
95
+ text: async () => '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18"}}',
96
+ };
97
+ }
98
+ if (body.method === 'notifications/initialized') {
99
+ return { ok: true, status: 202, headers: { get: () => null }, text: async () => '' };
100
+ }
101
+ return {
102
+ ok: true, status: 200, headers: { get: () => null },
103
+ text: async () => JSON.stringify({
104
+ jsonrpc: '2.0', id: 2,
105
+ result: { tools: [
106
+ { name: 'web_search_exa', inputSchema: { type: 'object', properties: {} } },
107
+ { name: 'get_code_context_exa', inputSchema: { type: 'object', properties: {} } },
108
+ ] },
109
+ }),
110
+ };
111
+ };
112
+ try {
113
+ const session = { workspace: 'docs', workspaceEnv: {}, wikircConfig: {}, commands: [] };
114
+ session.mcp = await discoverMcpTools(buildMcpStatus(session));
115
+ const chatTools = chatAllowedTools(session).map((tool) => tool.function.name).sort();
116
+ assert.deepEqual(chatTools, ['exa__get_code_context_exa', 'exa__web_search_exa']);
117
+ const agentPrompt = buildAgentSystemPrompt({ session });
118
+ assert.match(agentPrompt, /exa__web_search_exa/);
119
+ assert.match(agentPrompt, /exa__get_code_context_exa/);
120
+ assert.ok(seenAuthorization.includes('Bearer secret'));
121
+ } finally {
122
+ globalThis.fetch = originalFetch;
123
+ resetMcpSessionsForTests();
124
+ }
125
+ });
126
+ });
@@ -88,6 +88,13 @@ test('workspace creation keeps mutable manager files outside the installed packa
88
88
  assert.match(source, /WIKI_MANAGER_ENDPOINTS_FILE: managerMcpEndpointsFile\(\)/);
89
89
  });
90
90
 
91
+ test('wiki-workspace rejects an MCP endpoints directory instead of copying into it', async () => {
92
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
93
+
94
+ assert.match(script, /\[\[ -e "\$MANAGER_ENDPOINTS_FILE" && ! -f "\$MANAGER_ENDPOINTS_FILE" \]\]/);
95
+ assert.match(script, /MCP endpoints path is not a file/);
96
+ });
97
+
91
98
  test('container refresh pulls and renews only services that are already running', async () => {
92
99
  const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
93
100
 
@@ -15,10 +15,22 @@ export function createAgentRegistry({
15
15
  return {
16
16
  async discover(session, { signal = null } = {}) {
17
17
  const discovered = [];
18
- for (const [serverName, endpoint] of Object.entries(session?.mcp ?? {})) {
18
+ const endpoints = Object.entries(session?.mcp ?? {});
19
+ const activeServers = new Set(endpoints.map(([serverName]) => serverName));
20
+ for (const [serverName, endpoint] of endpoints) {
19
21
  const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
20
22
  discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
21
23
  }
24
+ for (const [serverName, instanceId] of instanceByServer) {
25
+ if (activeServers.has(serverName)) continue;
26
+ const previous = agentsByInstance.get(instanceId);
27
+ instanceByServer.delete(serverName);
28
+ agentsByInstance.delete(instanceId);
29
+ if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
30
+ agentInstanceId: instanceId,
31
+ serverName,
32
+ });
33
+ }
22
34
  session.agentRegistry = this;
23
35
  session.agentRegistrySnapshot = this.snapshot();
24
36
  return discovered;
@@ -85,6 +85,26 @@ test('agentRegistry records legacy visible agents when no contract tool exists',
85
85
  assert.equal(agent.health, 'available');
86
86
  });
87
87
 
88
+ test('agentRegistry removes a server that disappeared during MCP refresh', async () => {
89
+ const events = [];
90
+ const session = {
91
+ mcp: {
92
+ cme: { status: 'connected', tools: [{ name: 'cme_status' }] },
93
+ exa: { status: 'connected', tools: [{ name: 'web_search_exa' }] },
94
+ },
95
+ _onAgentEvent: (event) => events.push(event),
96
+ };
97
+ const registry = createAgentRegistry({ callTool: async () => assert.fail('no contract tool expected') });
98
+ await registry.discover(session);
99
+ assert.deepEqual(registry.snapshot().map((agent) => agent.serverName), ['cme', 'exa']);
100
+
101
+ delete session.mcp.cme;
102
+ await registry.discover(session);
103
+
104
+ assert.deepEqual(registry.snapshot().map((agent) => agent.serverName), ['exa']);
105
+ assert.ok(events.some((event) => event.type === 'agent.unregistered' && event.payload.serverName === 'cme'));
106
+ });
107
+
88
108
  test('agentRegistry marks unavailable boot agents and emits health changes on re-scan', async () => {
89
109
  const events = [];
90
110
  let health = 'unavailable';
@@ -24,6 +24,9 @@ export function startRuntimeServer({
24
24
  approve,
25
25
  configProfiles,
26
26
  useConfigProfile,
27
+ listMcpEndpoints,
28
+ upsertMcpEndpoint,
29
+ deleteMcpEndpoint,
27
30
  listActiveRuns = null,
28
31
  exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
29
32
  } = {}) {
@@ -230,6 +233,31 @@ export function startRuntimeServer({
230
233
  sendJson(response, 200, result);
231
234
  return;
232
235
  }
236
+ if (request.method === 'GET' && url.pathname === '/mcp/endpoints') {
237
+ const workspace = workspaceFromUrl(url);
238
+ const context = await resolveContext({ workspace });
239
+ const result = await listMcpEndpoints?.(context);
240
+ sendJson(response, 200, result ?? { endpoints: [] });
241
+ return;
242
+ }
243
+ if (request.method === 'POST' && url.pathname === '/mcp/endpoints') {
244
+ const activeRuns = typeof listActiveRuns === 'function' ? listActiveRuns() : [];
245
+ if (activeRuns.length > 0) {
246
+ sendJson(response, 409, { error: 'MCP connectors cannot be changed while a plan is running.' });
247
+ return;
248
+ }
249
+ const { body, context } = await resolveBodyContext(request, url);
250
+ const action = String(body.action ?? 'upsert').trim().toLowerCase();
251
+ const result = action === 'delete'
252
+ ? await deleteMcpEndpoint?.(context, body)
253
+ : await upsertMcpEndpoint?.(context, body);
254
+ if (!result) {
255
+ sendJson(response, 501, { error: 'MCP endpoint management is not supported.' });
256
+ return;
257
+ }
258
+ sendJson(response, 200, result);
259
+ return;
260
+ }
233
261
  if (request.method === 'GET' && url.pathname === '/events/stream') {
234
262
  const workspace = workspaceFromUrl(url);
235
263
  const context = workspace ? await resolveContext({ workspace }) : null;
@@ -139,20 +139,52 @@ function WelcomeHelpPanels(props: { width: number }) {
139
139
  );
140
140
  }
141
141
 
142
- const COPY_BTN = ' [ copy ]';
142
+ const COPY_BTN = ' copy';
143
+
144
+ /**
145
+ * Barre de gouttière, préfixée à chaque ligne d'un message.
146
+ *
147
+ * Remplace le filet horizontal pleine largeur qui ouvrait chaque message.
148
+ * Celui-ci était l'élément le plus encré de l'écran, répété à chaque tour, et
149
+ * il séparait là où il fallait grouper : rien n'indiquait où un bloc finissait,
150
+ * seulement où le suivant commençait. Une barre colorée porte la même
151
+ * information — à qui est le tour, où le bloc commence ET où il s'arrête — pour
152
+ * un caractère par ligne au lieu de toute la largeur.
153
+ */
154
+ const GUTTER = '▌ ';
155
+
156
+ function clockLabel(at?: number): string {
157
+ if (!Number.isFinite(at)) return '';
158
+ const date = new Date(at as number);
159
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
160
+ }
143
161
 
144
- function messageHeaderSegments(role: string, columns: number): Segment[] {
145
- const label = `[${roleLabel(role)}]`;
146
- const left = '── ';
147
- const actionsLength = COPY_BTN.length;
148
- const rightLength = Math.max(2, columns - left.length - label.length - 1 - actionsLength);
162
+ /**
163
+ * En-tête d'un message : gouttière, locuteur, heure, et `copy` à droite.
164
+ *
165
+ * Le bouton de copie était collé au libellé, précédé d'un filet de soixante-dix
166
+ * tirets. Poussé au bord droit et dégrisé, il reste atteignable sans peser sur
167
+ * la ligne. L'heure était reléguée dans une colonne latérale, détachée de ce
168
+ * qu'elle datait ; elle coûte cinq caractères ici et devient lisible.
169
+ */
170
+ function messageHeaderSegments(role: string, columns: number, at?: number): Segment[] {
171
+ const time = clockLabel(at);
172
+ const label = roleLabel(role);
173
+ const used = GUTTER.length + label.length + (time ? time.length + 2 : 0);
174
+ const pad = Math.max(1, columns - used - COPY_BTN.length);
149
175
  return [
150
- { text: left, color: '#4B5563' },
176
+ { text: GUTTER, color: roleColor(role) },
151
177
  { text: label, color: roleColor(role) },
152
- { text: ' ' + '─'.repeat(rightLength), color: '#4B5563' },
178
+ ...(time ? [{ text: ` ${time}`, color: '#4B5563' }] : []),
179
+ { text: ' '.repeat(pad), color: '#4B5563' },
153
180
  ];
154
181
  }
155
182
 
183
+ /** Préfixe la gouttière à une ligne de corps de message. */
184
+ function withGutter(line: RenderedLine, role: string): RenderedLine {
185
+ return { ...line, segments: [{ text: GUTTER, color: roleColor(role) }, ...line.segments] };
186
+ }
187
+
156
188
  // Split " /cmd [<arg>...] description" into two segments.
157
189
  // Uses non-greedy to stop at the first double-space separator.
158
190
  function splitCmdDesc(line: string): [string, string] | null {
@@ -413,21 +445,24 @@ function headerGroupKey(role: string) {
413
445
  return isDonnaRole(role) ? 'donna' : roleLabel(role);
414
446
  }
415
447
 
416
- function conversationLines(messages: Array<{ role: string; content: string }>, columns: number): RenderedLine[] {
448
+ function conversationLines(messages: Array<{ role: string; content: string; at?: number }>, columns: number): RenderedLine[] {
417
449
  return messages.flatMap((message, index) => {
418
450
  const raw = String(message.content || '');
419
451
  const previous = index > 0 ? messages[index - 1] : null;
420
452
  const showHeader = !previous || headerGroupKey(previous.role) !== headerGroupKey(message.role);
453
+ // Plus de ligne vide sous l'en-tête : le filet et son rembourrage
454
+ // coûtaient deux lignes par message, en plus de la ligne de séparation.
455
+ // La gouttière rattache visuellement l'en-tête à son corps, la respiration
456
+ // entre tours suffit.
421
457
  const headerLines: RenderedLine[] = showHeader
422
- ? [
423
- {
424
- segments: messageHeaderSegments(message.role, columns),
425
- copyContent: raw,
426
- },
427
- { segments: [{ text: ' ', color: '#D6DEE8' }] },
428
- ]
458
+ ? [{ segments: messageHeaderSegments(message.role, columns, message.at), copyContent: raw }]
429
459
  : [];
430
- const contentColumns = message.role === 'user' ? Math.max(12, Math.floor(columns * 0.6) - 2) : columns;
460
+ // Les messages utilisateur étaient repliés à 60 % de la largeur pour tenir
461
+ // dans une bulle alignée à droite. Cela coupait les phrases en plein milieu
462
+ // et faisait repartir l'œil de deux colonnes différentes selon le locuteur.
463
+ // La couleur de gouttière distingue les tours ; la colonne de lecture reste
464
+ // unique. On retire aussi la largeur de la gouttière du texte disponible.
465
+ const contentColumns = Math.max(12, columns - GUTTER.length);
431
466
  if (isStatusOutput(message)) {
432
467
  const statusLines: RenderedLine[] = [
433
468
  ...headerLines,
@@ -454,15 +489,9 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
454
489
  lines.push({ text: line, isCode: inFence });
455
490
  }
456
491
  const renderedBody = renderMarkdownLines(lines, message.role, contentColumns);
457
- const userBubbleWidth = message.role === 'user'
458
- ? Math.min(
459
- Math.max(1, ...renderedBody.map((line) => line.segments.reduce((sum, segment) => sum + segment.text.length, 0))) + 2,
460
- Math.max(12, Math.floor(columns * 0.6)),
461
- )
462
- : undefined;
463
492
  const bodyLines: RenderedLine[] = [
464
493
  ...headerLines,
465
- ...renderedBody.map((line) => ({ ...line, userBubbleWidth })),
494
+ ...renderedBody.map((line) => withGutter(line, message.role)),
466
495
  { segments: [{ text: ' ', color: '#D6DEE8' }] },
467
496
  ];
468
497
  return bodyLines.map((line) => ({ ...line, role: message.role }));
@@ -470,7 +499,7 @@ function conversationLines(messages: Array<{ role: string; content: string }>, c
470
499
  }
471
500
 
472
501
  export function ConversationView(props: {
473
- messages: Array<{ role: string; content: string }>;
502
+ messages: Array<{ role: string; content: string; at?: number }>;
474
503
  rows: number;
475
504
  columns: number;
476
505
  scroll: number;
@@ -536,21 +565,6 @@ export function ConversationView(props: {
536
565
  </For>
537
566
  <text fg="#4B5563" content={COPY_BTN} onMouseUp={() => props.onCopy?.(line.copyContent!)} />
538
567
  </box>
539
- ) : line.role === 'user' && line.userBubbleWidth ? (
540
- <box height={1} flexDirection="row" overflow="hidden">
541
- <box flexGrow={1} />
542
- <box
543
- width={line.userBubbleWidth}
544
- height={1}
545
- flexDirection="row"
546
- flexShrink={1}
547
- justifyContent="flex-start"
548
- paddingX={1}
549
- overflow="hidden"
550
- >
551
- <text>{styledSegments(line.segments)}</text>
552
- </box>
553
- </box>
554
568
  ) : line.status ? (
555
569
  <box height={1} flexDirection="row" gap={2} overflow="hidden">
556
570
  <box
@@ -786,7 +800,7 @@ export function LeftPane(props: {
786
800
  statusLine: string;
787
801
  hintLine?: string | null;
788
802
  showWelcome: boolean;
789
- messages: Array<{ role: string; content: string }>;
803
+ messages: Array<{ role: string; content: string; at?: number }>;
790
804
  prompt: string;
791
805
  input: string;
792
806
  busy: boolean;
package/src/shell/repl.js CHANGED
@@ -169,11 +169,34 @@ export function conversationKey(session) {
169
169
  return session.workspace || GLOBAL_CONVERSATION_KEY;
170
170
  }
171
171
 
172
+ /**
173
+ * Horodate un message au moment où il entre dans la conversation.
174
+ *
175
+ * Posé sur le `push` du tableau plutôt qu'aux trente-deux endroits qui créent
176
+ * un message : un seul de ces endroits oublié, et la ligne perdrait son heure
177
+ * sans que rien ne le signale. Un message qui porte déjà `at` — rejoué depuis
178
+ * l'historique, par exemple — garde le sien.
179
+ */
180
+ function stampConversation(messages) {
181
+ if (messages.__stamped) return messages;
182
+ Object.defineProperty(messages, '__stamped', { value: true });
183
+ const push = messages.push.bind(messages);
184
+ Object.defineProperty(messages, 'push', {
185
+ value: (...entries) =>
186
+ push(...entries.map((entry) =>
187
+ entry && typeof entry === 'object' && entry.at === undefined
188
+ ? Object.assign(entry, { at: Date.now() })
189
+ : entry,
190
+ )),
191
+ });
192
+ return messages;
193
+ }
194
+
172
195
  export function conversationMessages(session) {
173
196
  const key = conversationKey(session);
174
197
  session.conversations ??= { [GLOBAL_CONVERSATION_KEY]: [] };
175
198
  session.conversations[key] ??= [];
176
- return session.conversations[key];
199
+ return stampConversation(session.conversations[key]);
177
200
  }
178
201
 
179
202
  // Ordered from verifiable to hopeful. Mirrors tui.tsx's clipboardCommands():
@@ -31,28 +31,50 @@ test('ShellUI inserts StyledText as a child instead of stringifying it through c
31
31
  assert.match(source, /<text[^>]*>\{styledSegments\(line\.segments\)\}<\/text>/);
32
32
  });
33
33
 
34
- test('ShellUI renders the user header full-width before constraining only its body', async () => {
34
+ test('every message line carries its speaker gutter, and no full-width rule', async () => {
35
35
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
36
- const headerBranch = source.indexOf("line.copyContent !== undefined ? (");
37
- const userBodyBranch = source.indexOf("line.role === 'user' && line.userBubbleWidth ? (", headerBranch);
38
- assert.ok(headerBranch >= 0 && userBodyBranch > headerBranch);
39
- assert.match(source, /const userBubbleWidth = message\.role === 'user'/);
40
- assert.match(source, /Math\.floor\(columns \* 0\.6\)/);
41
- assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.5\)/);
42
- assert.match(source.slice(userBodyBranch), /width=\{line\.userBubbleWidth\}/);
43
- assert.match(source.slice(userBodyBranch), /justifyContent="flex-start"/);
44
- assert.doesNotMatch(source.slice(userBodyBranch, source.indexOf(') : line.status ?', userBodyBranch)), /#12263A/);
45
- assert.doesNotMatch(source, /messageHeaderSegments\(message\.role, contentColumns\)/);
46
- assert.match(source, /messageHeaderSegments\(message\.role, columns\)/);
36
+ // Le filet pleine largeur ouvrait chaque message : l'élément le plus encré de
37
+ // l'écran, répété à chaque tour, et qui séparait au lieu de grouper. La
38
+ // gouttière dit la même chose — à qui est le tour, où le bloc commence et où
39
+ // il finit — pour un caractère par ligne.
40
+ assert.match(source, /const GUTTER = '▌ ';/);
41
+ assert.match(source, /function withGutter\(/);
42
+ assert.match(source, /renderedBody\.map\(\(line\) => withGutter\(line, message\.role\)\)/);
43
+ assert.doesNotMatch(source, /'─'\.repeat\(rightLength\)/);
44
+
45
+ // La bulle alignée à droite repliait les messages utilisateur à 60 % de la
46
+ // largeur : phrases coupées en plein milieu, et deux colonnes de lecture
47
+ // selon le locuteur.
48
+ assert.doesNotMatch(source, /userBubbleWidth\s*=/);
49
+ assert.doesNotMatch(source, /Math\.floor\(columns \* 0\.6\)/);
50
+ assert.match(source, /const contentColumns = Math\.max\(12, columns - GUTTER\.length\)/);
47
51
  });
48
52
 
49
- test('ShellUI exposes copy without a reply or redo action', async () => {
53
+ test('the header carries the clock and the copy action, pushed to the right edge', async () => {
50
54
  const source = await readFile(new URL('./LeftPane.tsx', import.meta.url), 'utf8');
51
- assert.match(source, /const COPY_BTN = ' \[ copy \]';/);
55
+ assert.match(source, /const COPY_BTN = ' copy';/);
56
+ assert.match(source, /messageHeaderSegments\(message\.role, columns, message\.at\)/);
57
+ // Le remplissage pousse `copy` au bord droit au lieu de le coller au libellé.
58
+ assert.match(source, /const pad = Math\.max\(1, columns - used - COPY_BTN\.length\)/);
52
59
  assert.doesNotMatch(source, /\[\s*(?:reply|redo)\s*\]/i);
53
60
  assert.doesNotMatch(source, /onRedo|redoContent|redoIndex|REDO_BTN/);
54
61
  });
55
62
 
63
+ test('a message is stamped once, when it enters the conversation', async () => {
64
+ const { conversationMessages } = await import('./repl.js');
65
+ const session = { workspace: null };
66
+ const messages = conversationMessages(session);
67
+ // Posé sur le `push` du tableau : trente-deux endroits créent un message, et
68
+ // un seul oublié perdrait son heure sans que rien ne le signale.
69
+ messages.push({ role: 'user', content: 'bonjour' });
70
+ assert.ok(Number.isFinite(messages[0].at));
71
+ // Un message qui porte déjà son heure garde la sienne.
72
+ messages.push({ role: 'donna', content: 'rejoué', at: 42 });
73
+ assert.equal(messages[1].at, 42);
74
+ // Et le tableau n'est instrumenté qu'une fois.
75
+ assert.equal(conversationMessages(session), messages);
76
+ });
77
+
56
78
  test('redo translates the local thread index into a runtime conversation index', async () => {
57
79
  const session = await readFile(new URL('./useSession.ts', import.meta.url), 'utf8');
58
80
  const redo = session.slice(
@@ -559,7 +581,10 @@ test('/agent <question> submits one runtime request and remains in chat mode', a
559
581
  assert.equal(session.chatMode, true);
560
582
  assert.equal(result.oneShotAgent, true);
561
583
  assert.equal(result.runtimeOutcome.kind, 'accepted');
562
- assert.deepEqual(conversationMessages(session), [{ role: 'user', content: 'lance ingestion', _pending: true }]);
584
+ assert.deepEqual(
585
+ conversationMessages(session).map(({ at, ...rest }) => rest),
586
+ [{ role: 'user', content: 'lance ingestion', _pending: true }],
587
+ );
563
588
  } finally {
564
589
  restore();
565
590
  }
@@ -658,10 +683,14 @@ test('agent mode without runtime records a visible error instead of falling back
658
683
  const message = recordRuntimeUnavailableAgentInput(session, 'salut', { error: 'port 7788 already in use' });
659
684
 
660
685
  assert.equal(message, '⚠ Runtime indisponible : port 7788 already in use — /agent désactivé, /chat reste possible');
661
- assert.deepEqual(conversationMessages(session), [
662
- { role: 'user', content: 'salut' },
663
- { role: 'command', content: message },
664
- ]);
686
+ // `at` est posé à l'insertion : on compare le reste.
687
+ assert.deepEqual(
688
+ conversationMessages(session).map(({ at, ...rest }) => rest),
689
+ [
690
+ { role: 'user', content: 'salut' },
691
+ { role: 'command', content: message },
692
+ ],
693
+ );
665
694
  });
666
695
 
667
696
  test('runtime status exposes the disconnected reason', () => {
package/wiki-workspace CHANGED
@@ -534,7 +534,11 @@ ensure_endpoints_file() {
534
534
  # Without mcp.endpoints.json the shell never connects to the agents it
535
535
  # just started (and `agents status` refuses to run). Seed it from the
536
536
  # packaged example; the \${VAR} placeholders resolve against the .env.
537
+ if [[ -e "$MANAGER_ENDPOINTS_FILE" && ! -f "$MANAGER_ENDPOINTS_FILE" ]]; then
538
+ die "MCP endpoints path is not a file: $MANAGER_ENDPOINTS_FILE"
539
+ fi
537
540
  if [[ ! -f "$MANAGER_ENDPOINTS_FILE" && -f "$ROOT_DIR/mcp.endpoints.example.json" ]]; then
541
+ mkdir -p "$(dirname "$MANAGER_ENDPOINTS_FILE")"
538
542
  cp "$ROOT_DIR/mcp.endpoints.example.json" "$MANAGER_ENDPOINTS_FILE"
539
543
  printf 'Created %s from packaged example\n' "$MANAGER_ENDPOINTS_FILE"
540
544
  fi
@@ -918,6 +922,9 @@ ensure_compose_override() {
918
922
  }
919
923
 
920
924
  ensure_manager_endpoints_file() {
925
+ if [[ -e "$MANAGER_ENDPOINTS_FILE" && ! -f "$MANAGER_ENDPOINTS_FILE" ]]; then
926
+ die "MCP endpoints path is not a file: $MANAGER_ENDPOINTS_FILE"
927
+ fi
921
928
  if [[ ! -f "$MANAGER_ENDPOINTS_FILE" && -f "$ROOT_DIR/mcp.endpoints.example.json" ]]; then
922
929
  mkdir -p "$(dirname "$MANAGER_ENDPOINTS_FILE")"
923
930
  cp "$ROOT_DIR/mcp.endpoints.example.json" "$MANAGER_ENDPOINTS_FILE"