@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.
- package/.env.example +8 -5
- package/README.md +42 -0
- package/agents.docker-compose.yml +11 -16
- package/docker-compose.yml +9 -8
- package/package.json +2 -2
- package/src/agent/graph.js +20 -0
- package/src/cli/wiki-manager.js +20 -0
- package/src/commands/slash.js +85 -13
- package/src/commands/slash.test.js +85 -3
- package/src/core/agentsCompose.js +86 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/compose.js +24 -2
- package/src/core/dockerCompose.test.js +18 -0
- package/src/core/env.js +2 -1
- package/src/core/env.test.js +14 -0
- package/src/core/googleGrants.js +38 -0
- package/src/core/googleGrants.test.js +59 -0
- package/src/core/mcp.js +1 -1
- package/src/core/mcp.test.js +1 -0
- package/src/core/mcpEndpoints.js +96 -0
- package/src/core/mcpEndpoints.test.js +126 -0
- package/src/core/modelFetch.js +241 -27
- package/src/core/modelFetch.test.js +213 -0
- package/src/core/profileServiceStatus.test.js +146 -0
- package/src/core/wikiSetup.js +11 -2
- package/src/core/wikiWorkspace.test.js +16 -3
- package/src/orchestrator/agentRegistry.js +13 -1
- package/src/orchestrator/agentRegistry.test.js +20 -0
- package/src/runtime/lifecycle.js +105 -3
- package/src/runtime/lifecycle.test.js +68 -0
- package/src/runtime/server.js +28 -0
- package/src/shell/LeftPane.tsx +55 -41
- package/src/shell/SetupWizard.tsx +416 -116
- package/src/shell/repl.js +61 -12
- package/src/shell/repl.test.js +48 -19
- package/src/shell/setupWizardDiscovery.test.js +48 -0
- package/src/shell/setupWizardPlaceholders.test.js +15 -2
- package/src/shell/setupWizardSuggestions.test.js +36 -2
- package/src/shell/wrapText.js +57 -0
- package/src/shell/wrapText.test.js +48 -0
- package/wiki-workspace +122 -32
package/src/core/buildInfo.json
CHANGED
package/src/core/compose.js
CHANGED
|
@@ -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];
|
package/src/core/env.test.js
CHANGED
|
@@ -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.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.38';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/core/mcp.test.js
CHANGED
|
@@ -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
|
+
});
|