@dotdrelle/wiki-manager 0.15.34 → 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.
Files changed (41) hide show
  1. package/.env.example +8 -5
  2. package/README.md +42 -0
  3. package/agents.docker-compose.yml +11 -16
  4. package/docker-compose.yml +9 -8
  5. package/package.json +2 -2
  6. package/src/agent/graph.js +20 -0
  7. package/src/cli/wiki-manager.js +20 -0
  8. package/src/commands/slash.js +85 -13
  9. package/src/commands/slash.test.js +85 -3
  10. package/src/core/agentsCompose.js +86 -1
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/compose.js +24 -2
  13. package/src/core/dockerCompose.test.js +18 -0
  14. package/src/core/env.js +2 -1
  15. package/src/core/env.test.js +14 -0
  16. package/src/core/googleGrants.js +38 -0
  17. package/src/core/googleGrants.test.js +59 -0
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/mcp.test.js +1 -0
  20. package/src/core/mcpEndpoints.js +96 -0
  21. package/src/core/mcpEndpoints.test.js +126 -0
  22. package/src/core/modelFetch.js +241 -27
  23. package/src/core/modelFetch.test.js +213 -0
  24. package/src/core/profileServiceStatus.test.js +146 -0
  25. package/src/core/wikiSetup.js +11 -2
  26. package/src/core/wikiWorkspace.test.js +16 -3
  27. package/src/orchestrator/agentRegistry.js +13 -1
  28. package/src/orchestrator/agentRegistry.test.js +20 -0
  29. package/src/runtime/lifecycle.js +105 -3
  30. package/src/runtime/lifecycle.test.js +68 -0
  31. package/src/runtime/server.js +28 -0
  32. package/src/shell/LeftPane.tsx +55 -41
  33. package/src/shell/SetupWizard.tsx +416 -116
  34. package/src/shell/repl.js +61 -12
  35. package/src/shell/repl.test.js +48 -19
  36. package/src/shell/setupWizardDiscovery.test.js +48 -0
  37. package/src/shell/setupWizardPlaceholders.test.js +15 -2
  38. package/src/shell/setupWizardSuggestions.test.js +36 -2
  39. package/src/shell/wrapText.js +57 -0
  40. package/src/shell/wrapText.test.js +48 -0
  41. package/wiki-workspace +122 -32
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.34",
3
- "commit": "8b6b1aa"
2
+ "version": "0.15.38",
3
+ "commit": "abf8356"
4
4
  }
@@ -19,11 +19,17 @@ const execFileAsync = promisify(execFile);
19
19
  export const COMPOSE_SERVICES = ['serve', 'mcp-http', 'production-mcp'];
20
20
  const SERVICE_DESCRIPTION_LABEL = 'wiki-manager.description';
21
21
 
22
+ // Deux alias seulement, pour les deux services qu'un opérateur pilote vraiment
23
+ // à part. `mcp-http` n'en a plus : il fait partie du socle démarré par `all` et
24
+ // `services`, et son alias `mcp` ajoutait une ligne à lire pour un service que
25
+ // personne ne démarre seul. Il reste adressable sous son nom Compose.
26
+ //
27
+ // Pas d'alias `wiki` non plus : c'est déjà le nom du service Compose one-shot
28
+ // derrière `/wiki run`. En faire un alias de `mcp-http` le masquait, et
29
+ // `/start wiki` démarrait silencieusement un autre service que celui nommé.
22
30
  const DEFAULT_SERVICE_ALIASES = {
23
31
  all: COMPOSE_SERVICES,
24
32
  ui: ['serve'],
25
- wiki: ['mcp-http'],
26
- mcp: ['mcp-http'],
27
33
  production: ['production-mcp'],
28
34
  };
29
35
 
@@ -92,6 +98,22 @@ export function serviceNames() {
92
98
  return [...new Set([...COMPOSE_SERVICES, ...Object.keys(serviceAliases())])].sort();
93
99
  }
94
100
 
101
+ /**
102
+ * Noms proposés à la complétion de `/start`, `/stop` et `/logs`.
103
+ *
104
+ * La liste complète mélangeait les alias et les services Compose qu'ils
105
+ * désignent — `mcp` et `mcp-http`, `ui` et `serve`, `production` et
106
+ * `production-mcp` — et affichait `all` deux fois, une fois comme mot-clé du
107
+ * shell et une fois comme alias Compose. Dix entrées pour cinq actions
108
+ * réelles. On ne propose donc que le vocabulaire destiné à l'opérateur ; les
109
+ * noms Compose bruts restent acceptés si on les tape.
110
+ */
111
+ export function serviceChoices() {
112
+ const aliases = Object.keys(serviceAliases());
113
+ // `all` est ajouté par l'appelant, en tête : c'est le choix par défaut.
114
+ return aliases.filter((name) => name !== 'all').sort();
115
+ }
116
+
95
117
  export function serviceDescription(name) {
96
118
  return serviceDescriptions()[name] ?? null;
97
119
  }
@@ -19,11 +19,29 @@ test('workspace compose does not start a per-workspace agent runtime', async ()
19
19
  );
20
20
  });
21
21
 
22
+ test('shipped compose files never carry a build context', async () => {
23
+ // Ces deux fichiers partent dans le paquet npm, où les dépôts frères
24
+ // (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
25
+ // Compose irrésolvable chez l'utilisateur. Les images sont construites et
26
+ // publiées par build-and-push.sh, jamais par le manager.
27
+ for (const file of ['../../docker-compose.yml', '../../agents.docker-compose.yml']) {
28
+ const compose = YAML.parse(await readFile(new URL(file, import.meta.url), 'utf8'));
29
+ for (const [name, service] of Object.entries(compose.services ?? {})) {
30
+ assert.equal(service.build, undefined, `${file} ${name}: shipped files must reference an image, never build it`);
31
+ assert.ok(service.image, `${file} ${name}: must declare an image`);
32
+ }
33
+ }
34
+ });
35
+
22
36
  test('no compose service relies on a bare environment passthrough', async () => {
23
37
  // `- VAR` makes Compose print `The "VAR" variable is not set. Defaulting to a
24
38
  // blank string.` for every key the operator left as a commented placeholder —
25
39
  // CONNECTORS_MCP_PORT once connectors were enabled. That warning reached the
26
40
  // ShellUI looking like a failure. Every entry must carry its own default.
41
+ //
42
+ // Une valeur vide est ici sans danger : l'application OAuth embarquée dans
43
+ // l'image du connecteur est lue depuis un fichier, plus depuis un ENV que la
44
+ // chaîne vide écraserait.
27
45
  for (const file of ['../../docker-compose.yml', '../../agents.docker-compose.yml']) {
28
46
  const compose = YAML.parse(await readFile(new URL(file, import.meta.url), 'utf8'));
29
47
  for (const [name, service] of Object.entries(compose.services ?? {})) {
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');
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Droits Gmail demandés à Google, et ce qu'ils ouvrent réellement.
3
+ *
4
+ * Les identifiants (`read`, `send`, `modify`) sont ceux de Google — `modify`
5
+ * vient du scope `gmail.modify` — et ceux de `GOOGLE_GRANTS` dans
6
+ * agent-connectors. Leur inventer un synonyme côté manager créerait une
7
+ * troisième orthographe à tenir à jour, exactement le travers qui avait donné
8
+ * une seconde paire de variables OAuth préfixées. On décrit, on ne renomme pas.
9
+ *
10
+ * Cette table est la source unique : la valeur par défaut de
11
+ * `/connector auth`, l'aide de la complétion et le rendu de `/connector list`
12
+ * en découlent tous, donc ils ne peuvent pas diverger.
13
+ */
14
+ export const GOOGLE_GRANT_LABELS = Object.freeze({
15
+ read: 'read messages and collect them into the workspace',
16
+ send: 'send email from your account (subject to the recipient allow-list)',
17
+ modify: 'mark read/unread, archive, label, star, trash (never permanent deletion)',
18
+ });
19
+
20
+ export const GOOGLE_GRANTS = Object.freeze(Object.keys(GOOGLE_GRANT_LABELS));
21
+
22
+ /**
23
+ * Droits demandés quand l'opérateur n'en nomme aucun.
24
+ *
25
+ * Tout ce que l'agent sait faire. Un défaut plus étroit promet des actions que
26
+ * l'autorisation ne couvre pas : `/connector auth google` ne demandait que
27
+ * `read`, alors que l'agent expose l'envoi et la gestion de boîte — Donna
28
+ * proposait « marquer comme lu », et l'action échouait après coup. Comme
29
+ * l'autorisation Google est incrémentale, un droit ajouté plus tard coûte un
30
+ * aller-retour de consentement supplémentaire, pas moins d'accès.
31
+ */
32
+ export function defaultGoogleGrants() {
33
+ return [...GOOGLE_GRANTS];
34
+ }
35
+
36
+ export function describeGoogleGrant(grant) {
37
+ return GOOGLE_GRANT_LABELS[grant] ?? null;
38
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from './googleGrants.js';
6
+
7
+ const connectorsSrc = (file) =>
8
+ readFileSync(fileURLToPath(new URL(`../../../agent-external/agent-connectors/src/${file}`, import.meta.url)), 'utf8');
9
+
10
+ test('the grant names mirror the agent, spelling included', () => {
11
+ // `modify` est le nom de Google (scope gmail.modify) et celui de l'agent.
12
+ // Un synonyme côté manager créerait une troisième orthographe à tenir à jour
13
+ // — le travers qui avait déjà donné une seconde paire de variables OAuth.
14
+ const tokens = connectorsSrc('googleTokens.ts');
15
+ const declared = tokens.match(/GOOGLE_GRANTS: readonly GoogleGrant\[\] = \[([^\]]+)\]/)?.[1] ?? '';
16
+ const agentGrants = [...declared.matchAll(/'([a-z]+)'/g)].map(([, grant]) => grant);
17
+
18
+ assert.deepEqual([...GOOGLE_GRANTS].sort(), agentGrants.sort());
19
+ });
20
+
21
+ test('every grant is described in plain words, never left as a bare token', () => {
22
+ for (const grant of GOOGLE_GRANTS) {
23
+ const label = GOOGLE_GRANT_LABELS[grant];
24
+ assert.ok(label && label.length > 10, `${grant} needs a human description`);
25
+ assert.notEqual(label, grant);
26
+ }
27
+ });
28
+
29
+ test('the default asks for everything the agent can actually do', () => {
30
+ // Un défaut plus étroit promet des actions que l'autorisation ne couvre pas :
31
+ // `/connector auth google` ne demandait que `read`, et l'envoi comme le
32
+ // marquage échouaient après coup, en ressemblant à des fonctions absentes.
33
+ assert.deepEqual(defaultGoogleGrants().sort(), [...GOOGLE_GRANTS].sort());
34
+
35
+ // Chaque droit du défaut doit ouvrir quelque chose de réellement exposé.
36
+ const server = connectorsSrc('server.ts');
37
+ const contract = connectorsSrc('contract.ts');
38
+ assert.match(contract, /CAPABILITY_ID = 'external-source\.collect'/, 'read feeds the collect capability');
39
+ assert.match(contract, /SEND_CAPABILITY_ID = 'communication\.send-email'/, 'send has a capability');
40
+ assert.match(server, /'connectors_gmail_modify'/, 'modify has a tool');
41
+ });
42
+
43
+ test('the default grants are not mutated by the caller', () => {
44
+ const first = defaultGoogleGrants();
45
+ first.push('bogus');
46
+ assert.deepEqual(defaultGoogleGrants().sort(), [...GOOGLE_GRANTS].sort());
47
+ });
48
+
49
+ test('the destructive Gmail tool needs approval and stays out of chat mode', () => {
50
+ const script = readFileSync(fileURLToPath(new URL('../../wiki-workspace', import.meta.url)), 'utf8');
51
+ const block = script.slice(script.indexOf('config.mcpServers.connectors ??='), script.indexOf('delete connectorAccess.allowActions'));
52
+ const approval = block.slice(block.indexOf('requireApproval'), block.indexOf('chatAccess'));
53
+ const chatAllow = block.slice(block.indexOf('connectorAccess.allow ='));
54
+
55
+ assert.match(approval, /connectors_gmail_modify/, 'a destructive tool must be approval-gated');
56
+ // /chat est en lecture seule : une mutation n'y a pas sa place, elle passe
57
+ // par /agent où l'allow-list ne s'applique pas.
58
+ assert.ok(!chatAllow.includes('connectors_gmail_modify'), 'chat mode must stay read-only');
59
+ });
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.34';
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
+ });