@oddessentials/agent-guild 1.21.2 → 1.22.1

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 (45) hide show
  1. package/bin/agent-guild.mjs +2 -0
  2. package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
  3. package/bin/native/win32-x64/agent-guild-report.exe +0 -0
  4. package/bin/windows/arm64/agent-guild-start.exe +0 -0
  5. package/bin/windows/x64/agent-guild-start.exe +0 -0
  6. package/config/providers.default.json +4 -0
  7. package/docs/api.md +34 -3
  8. package/examples/terminal-ai-chat.json +27 -0
  9. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  10. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  11. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  12. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  13. package/node_modules/smol-toml/LICENSE +24 -0
  14. package/node_modules/smol-toml/README.md +605 -0
  15. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  16. package/node_modules/smol-toml/dist/date.js +156 -0
  17. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  18. package/node_modules/smol-toml/dist/error.js +67 -0
  19. package/node_modules/smol-toml/dist/extract.js +293 -0
  20. package/node_modules/smol-toml/dist/index.cjs +771 -0
  21. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  22. package/node_modules/smol-toml/dist/index.js +34 -0
  23. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  24. package/node_modules/smol-toml/dist/parse.js +164 -0
  25. package/node_modules/smol-toml/dist/primitive.js +151 -0
  26. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  27. package/node_modules/smol-toml/dist/stringify.js +219 -0
  28. package/node_modules/smol-toml/dist/struct.js +156 -0
  29. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  30. package/node_modules/smol-toml/dist/util.js +57 -0
  31. package/node_modules/smol-toml/package.json +68 -0
  32. package/package.json +2 -1
  33. package/src/manager/config.mjs +10 -10
  34. package/src/manager/install-channels.mjs +3 -0
  35. package/src/manager/main.mjs +4 -0
  36. package/src/manager/mcp-connect.mjs +483 -0
  37. package/src/manager/providers.mjs +23 -4
  38. package/src/manager/server.mjs +16 -1
  39. package/src/manager/session-manager.mjs +2 -2
  40. package/web/app.js +92 -2
  41. package/web/index.html +11 -1
  42. package/web/styles.css +13 -2
  43. package/web/yard/art.json +1 -1
  44. package/web/yard/renderer.js +110 -36
  45. package/web/yard/view.js +13 -3
@@ -0,0 +1,483 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { buildSpawnSpec, resolveCommand, runSpec } from './command-resolver.mjs';
6
+ import { antigravityConfigFile } from './session-hooks.mjs';
7
+ import { parse as parseToml } from 'smol-toml';
8
+ import { stableExecPath } from './autostart.mjs';
9
+ import { PACKAGE_SCRIPT } from './launch.mjs';
10
+ import { dataDir, dataDirFor } from './config.mjs';
11
+
12
+ export const SERVER_NAME = 'agent-guild';
13
+ export const MCP_MODES = ['claude', 'codex', 'antigravity', 'grok'];
14
+ export const MCP_ACTIONS = ['on', 'off', 'replace'];
15
+ const STATE_TTL_MS = 60 * 1000;
16
+ const RUN_TIMEOUT_MS = 30000;
17
+ const PACKAGE = /^@oddessentials\/agent-guild(?:@[^\s/]+)?$/;
18
+ const HOME_FLAG = '--home=';
19
+
20
+ export function ownServer(execPath = process.execPath, home = dataDir()) {
21
+ return { command: stableExecPath(execPath), args: [PACKAGE_SCRIPT, 'mcp', `${HOME_FLAG}${path.resolve(home)}`] };
22
+ }
23
+
24
+ const homeOf = (env, platform) => (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
25
+
26
+ function canonical(value) {
27
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
28
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
29
+ return JSON.stringify(value ?? null);
30
+ }
31
+
32
+ export const fingerprint = (value) => crypto.createHash('sha256').update(canonical(value)).digest('hex').slice(0, 32);
33
+
34
+ const isStringList = (list) => Array.isArray(list) && list.every((a) => typeof a === 'string');
35
+ const stringMap = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
36
+ const quote = (arg) => (/[\s"]/.test(arg) || arg === '' ? JSON.stringify(arg) : arg);
37
+ const commandLine = (command, args) => [command, ...args].map(quote).join(' ');
38
+
39
+ function readJsonFile(file, { emptyIsNone = false } = {}) {
40
+ let text;
41
+ try {
42
+ text = fs.readFileSync(file, 'utf8');
43
+ } catch (err) {
44
+ if (err.code === 'ENOENT') return { missing: true };
45
+ return { error: `${file} cannot be read: ${err.message}` };
46
+ }
47
+ if (emptyIsNone && !text.trim()) return { missing: true };
48
+ try {
49
+ return { value: JSON.parse(text) };
50
+ } catch {
51
+ return { error: `${file} is not valid JSON` };
52
+ }
53
+ }
54
+
55
+ function statOf(file) {
56
+ try {
57
+ const s = fs.statSync(file);
58
+ return `${s.mtimeMs}:${s.size}`;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** The agent-guild entry of a JSON file's mcpServers, as each tool's own reader sees it. */
65
+ function entryInFile(file, options) {
66
+ const read = readJsonFile(file, options);
67
+ if (read.missing) return { entry: null };
68
+ if (read.error) return { error: read.error };
69
+ const servers = read.value?.mcpServers;
70
+ if (servers === undefined) return { entry: null };
71
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return { error: `${file} has mcpServers in a form Agent Guild does not recognize` };
72
+ return { entry: Object.hasOwn(servers, SERVER_NAME) ? servers[SERVER_NAME] : null };
73
+ }
74
+
75
+ function fromJson(raw, enabled) {
76
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { raw, invalid: true };
77
+ const url = raw.url ?? raw.httpUrl ?? raw.serverUrl;
78
+ const remote = (raw.type !== undefined && raw.type !== 'stdio') || (raw.command === undefined && url !== undefined);
79
+ return { raw, remote: remote ? String(url ?? raw.type) : null, command: raw.command, args: raw.args ?? [], env: raw.env ?? {}, envVars: [], cwd: raw.cwd ?? null, enabled };
80
+ }
81
+
82
+ function lastLine(err) {
83
+ return `${err.stderr || ''}\n${err.stdout || ''}`.trim().split('\n').map((l) => l.trim()).filter(Boolean).pop() || err.message;
84
+ }
85
+
86
+ // Grok Build exits 0 and lists what it could read when its config.toml does not parse; only stderr says so.
87
+ const grokConfigError = (stderr) => String(stderr || '').split('\n').map((l) => l.trim()).find((l) => /\bERROR\b.*\bconfig\b/.test(l)) ?? null;
88
+
89
+ const TOOLS = {
90
+ claude: {
91
+ // Claude Code starts MCP servers from Node, which keeps the first of Windows variables that differ only in case.
92
+ firstKeyWins: true,
93
+ file: (env, platform) => path.join(env.CLAUDE_CONFIG_DIR || homeOf(env, platform), '.claude.json'),
94
+ async read(ctx) {
95
+ const found = entryInFile(this.file(ctx.env, ctx.platform));
96
+ return found.error ? found : { entry: found.entry === null ? null : fromJson(found.entry, true) };
97
+ },
98
+ add: (own) => [['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args]],
99
+ remove: () => [['mcp', 'remove', '-s', 'user', SERVER_NAME]],
100
+ replace: (own) => [['mcp', 'remove', '-s', 'user', SERVER_NAME], ['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args]],
101
+ },
102
+ codex: {
103
+ async read(ctx) {
104
+ const { stdout } = await ctx.run(['mcp', 'list', '--json']);
105
+ const list = parseList(stdout);
106
+ if (!list) return { error: 'its MCP server list was not in a form Agent Guild recognizes' };
107
+ const item = list.find((e) => e?.name === SERVER_NAME);
108
+ if (!item) return { entry: null };
109
+ const t = item.transport ?? {};
110
+ return {
111
+ entry: {
112
+ raw: item,
113
+ remote: t.type && t.type !== 'stdio' ? String(t.url ?? t.type) : null,
114
+ command: t.command,
115
+ args: t.args ?? [],
116
+ env: t.env ?? {},
117
+ envVars: Array.isArray(t.env_vars) ? t.env_vars.map((v) => (typeof v === 'string' ? v : v?.name)).filter(Boolean) : [],
118
+ cwd: t.cwd ?? null,
119
+ enabled: item.enabled !== false,
120
+ },
121
+ };
122
+ },
123
+ // Codex hands an MCP server only some of its environment; these two are not among them.
124
+ drops: ['AGENT_GUILD_HOME', 'XDG_CONFIG_HOME'],
125
+ add: (own) => [['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args]],
126
+ // Codex has no enable: adding again replaces the entry, and with it `enabled = false`.
127
+ enable: (own) => [['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args]],
128
+ remove: () => [['mcp', 'remove', SERVER_NAME]],
129
+ replace: (own) => [['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args]],
130
+ },
131
+ antigravity: {
132
+ file: (env, platform) => path.join(path.dirname(antigravityConfigFile(env, platform)), 'mcp_config.json'),
133
+ async read(ctx) {
134
+ const found = entryInFile(this.file(ctx.env, ctx.platform), { emptyIsNone: true });
135
+ if (found.error || found.entry === null) return found;
136
+ return { entry: fromJson(found.entry, found.entry?.disabled !== true) };
137
+ },
138
+ add: (own) => [['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args]],
139
+ enable: () => [['mcp', 'enable', SERVER_NAME]],
140
+ remove: () => [['mcp', 'remove', SERVER_NAME]],
141
+ replace: (own) => [['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args]],
142
+ },
143
+ grok: {
144
+ async read(ctx) {
145
+ const listed = await ctx.run(['mcp', 'list', '--json']);
146
+ const broken = grokConfigError(listed.stderr);
147
+ if (broken) return { error: broken };
148
+ const list = parseList(listed.stdout);
149
+ if (!list) return { error: 'its MCP server list was not in a form Agent Guild recognizes' };
150
+ const item = list.find((e) => e?.name === SERVER_NAME && (e.scope === undefined || e.scope === 'user'));
151
+ if (item) return { entry: { ...fromJson(item, item.enabled !== false), raw: item } };
152
+ // Grok Build also loads the servers in Claude Code's and Cursor's settings, which its list leaves out. `inspect`
153
+ // names them without starting them; `mcp doctor` would start them.
154
+ const inspected = await ctx.run(['inspect', '--json']).catch((err) => err);
155
+ if (grokConfigError(inspected.stderr)) return { error: grokConfigError(inspected.stderr) };
156
+ if (inspected instanceof Error) return { error: `grok inspect failed: ${lastLine(inspected)}` };
157
+ let report;
158
+ try { report = JSON.parse(inspected.stdout); } catch { report = null; }
159
+ if (!Array.isArray(report?.mcpServers)) return { error: 'grok inspect gave an answer Agent Guild does not recognize' };
160
+ const server = report.mcpServers.find((s) => s?.name === SERVER_NAME);
161
+ if (!server) return { entry: null };
162
+ const from = { claude: 'Claude Code', cursor: 'Cursor' }[server.vendor];
163
+ const file = server.source?.path;
164
+ if (!from || typeof file !== 'string') return { error: `Grok Build takes agent-guild from ${typeof file === 'string' ? `${file}, which` : 'a source'} Agent Guild cannot read` };
165
+ // Listed, but not loaded: Grok Build's compatibility settings keep that tool's servers out.
166
+ if (server.disabled === true || server.compatibilityStatus === 'disabled') {
167
+ const why = server.compatibilityStatus === 'disabled' ? `, because its ${from} compatibility is off` : '';
168
+ return { entry: null, raw: { source: file, loaded: false }, note: `Grok Build does not load the agent-guild server in ${from}'s settings${why}.` };
169
+ }
170
+ const found = entryInFile(path.resolve(file));
171
+ if (found.error) return found;
172
+ if (found.entry === null) return { error: `Grok Build reports agent-guild in ${file}, but it is not there` };
173
+ const off = grokOffList(ctx.env, ctx.platform);
174
+ if (off.error) return off;
175
+ const enabled = !off.names.includes(SERVER_NAME);
176
+ return { entry: { ...fromJson(found.entry, enabled), from, raw: { source: file, entry: found.entry, enabled } } };
177
+ },
178
+ add: (own) => [['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args]],
179
+ enable: () => [['mcp', 'enable', SERVER_NAME]],
180
+ disable: () => [['mcp', 'disable', SERVER_NAME]],
181
+ remove: () => [['mcp', 'remove', '-s', 'user', SERVER_NAME]],
182
+ // A name on Grok Build's own off list stays off whatever entry holds it.
183
+ replace: (own) => [['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args], ['mcp', 'enable', SERVER_NAME]],
184
+ },
185
+ };
186
+
187
+ /** The names `grok mcp disable` turned off: `disabled_mcp_servers` at the top of Grok Build's config.toml. */
188
+ function grokOffList(env, platform) {
189
+ const file = path.join(env.GROK_HOME || path.join(homeOf(env, platform), '.grok'), 'config.toml');
190
+ let text;
191
+ try {
192
+ text = fs.readFileSync(file, 'utf8');
193
+ } catch (err) {
194
+ return err.code === 'ENOENT' ? { names: [] } : { error: `${file} cannot be read: ${err.message}` };
195
+ }
196
+ let config;
197
+ try {
198
+ config = parseToml(text);
199
+ } catch {
200
+ return { error: `${file} is not valid TOML` };
201
+ }
202
+ const names = config.disabled_mcp_servers ?? [];
203
+ if (!Array.isArray(names) || !names.every((n) => typeof n === 'string')) return { error: `${file} has disabled_mcp_servers in a form Agent Guild does not recognize` };
204
+ return { names };
205
+ }
206
+
207
+ function parseList(stdout) {
208
+ try {
209
+ const list = JSON.parse(stdout);
210
+ return Array.isArray(list) ? list : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ const samePath = (a, b, platform) => {
217
+ const p = platform === 'win32' ? path.win32 : path.posix;
218
+ const norm = (x) => p.resolve(x).replace(/[\\/]+$/, '');
219
+ return platform === 'win32' ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
220
+ };
221
+
222
+ /** How a recognized entry starts Agent Guild's MCP server: the program to find, and the flags after `mcp`. */
223
+ function launchOf(command, args, platform) {
224
+ const p = platform === 'win32' ? path.win32 : path.posix;
225
+ const base = p.basename(command).toLowerCase().replace(/\.(cmd|exe|bat|ps1)$/, '');
226
+ let rest;
227
+ let script = null;
228
+ if (base === 'agent-guild') rest = args;
229
+ else if (base === 'npx') {
230
+ let i = 0;
231
+ while (args[i] === '-y' || args[i] === '--yes') i++;
232
+ if (!PACKAGE.test(args[i] ?? '')) return null;
233
+ rest = args.slice(i + 1);
234
+ } else if (/^node(\d+)?$/.test(base)) {
235
+ script = args[0];
236
+ if (!script || p.basename(script) !== 'agent-guild.mjs' || p.basename(p.dirname(script)) !== 'bin') return null;
237
+ rest = args.slice(1);
238
+ } else return null;
239
+ if (rest[0] !== 'mcp') return null;
240
+ const flags = rest.slice(1);
241
+ if (!flags.every((f) => f === '--allow-input' || f.startsWith(HOME_FLAG))) return null;
242
+ // As bin/agent-guild.mjs reads it: the first --home=, and none when that one is empty.
243
+ const home = flags.find((f) => f.startsWith(HOME_FLAG))?.slice(HOME_FLAG.length) || null;
244
+ return { script, home, allowInput: flags.includes('--allow-input') };
245
+ }
246
+
247
+ /**
248
+ * The environment a tool gives an MCP server, `{ KEY: { value, fromEntry } }`, with Windows keys in upper case.
249
+ * On Windows, Codex CLI, Grok Build and Antigravity CLI let the entry's variable win in any case; Claude Code, on
250
+ * Node, keeps the lexicographically first of keys that differ only in case (`firstKeyWins`).
251
+ */
252
+ export function serverEnv(toolEnv, entryEnv, { platform, firstKeyWins = false, drops = [], envVars = [] }) {
253
+ const fold = (k) => (platform === 'win32' ? k.toUpperCase() : k);
254
+ const merged = new Map();
255
+ for (const [k, v] of Object.entries(toolEnv)) {
256
+ if (!drops.includes(fold(k)) || envVars.some((name) => fold(name) === fold(k))) merged.set(k, { value: v, fromEntry: false });
257
+ }
258
+ for (const [k, v] of Object.entries(entryEnv)) merged.set(k, { value: String(v), fromEntry: true });
259
+ const keys = [...merged.keys()];
260
+ if (firstKeyWins) keys.sort();
261
+ const out = {};
262
+ for (const k of keys) if (!firstKeyWins || !Object.hasOwn(out, fold(k))) out[fold(k)] = merged.get(k);
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * What an entry is to this manager. `env` is the environment the tool runs with; `drops` the variables the
268
+ * tool keeps from its MCP servers unless the entry passes them on.
269
+ */
270
+ export function classify(entry, { own, home, env, platform, drops = [], firstKeyWins = false, tool }) {
271
+ if (!entry) return { state: 'off' };
272
+ const command = typeof entry.command === 'string' ? entry.command : null;
273
+ const shown = command && isStringList(entry.args) ? commandLine(command, entry.args) : null;
274
+ const conflict = (detail) => ({ state: 'conflict', detail, command: shown });
275
+ if (entry.invalid) return conflict('Agent Guild does not recognize');
276
+ if (entry.remote) return conflict(`connects to ${entry.remote}`);
277
+ if (!shown) return conflict('Agent Guild does not recognize');
278
+ const launch = launchOf(command, entry.args, platform);
279
+ if (!launch) return conflict('runs something other than Agent Guild\'s MCP server');
280
+ const entryEnv = stringMap(entry.env);
281
+ const p = platform === 'win32' ? path.win32 : path.posix;
282
+ const vars = serverEnv(env, entryEnv, { platform, firstKeyWins, drops, envVars: entry.envVars ?? [] });
283
+ const values = Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.value]));
284
+ const relevant = [command, ...entry.args, ...['AGENT_GUILD_HOME', 'APPDATA', 'XDG_CONFIG_HOME', 'HOME', 'USERPROFILE', 'PATH'].map((k) => (vars[k]?.fromEntry ? vars[k].value : ''))];
285
+ if (relevant.some((v) => String(v).includes('${'))) return conflict('uses variables Agent Guild cannot resolve');
286
+ const program = resolveCommand(command, values, platform);
287
+ if (!program) return conflict(`runs ${command}, which is not installed`);
288
+ if (launch.script && !fs.existsSync(p.isAbsolute(launch.script) ? launch.script : p.resolve(entry.cwd || os.homedir(), launch.script))) {
289
+ return conflict(`runs ${launch.script}, which does not exist`);
290
+ }
291
+ let target;
292
+ if (launch.home !== null) {
293
+ if (!p.isAbsolute(launch.home)) return conflict('names a relative data folder');
294
+ target = launch.home;
295
+ } else if (vars.AGENT_GUILD_HOME?.value && vars.AGENT_GUILD_HOME.fromEntry) {
296
+ if (!p.isAbsolute(vars.AGENT_GUILD_HOME.value)) return conflict('names a relative data folder');
297
+ target = vars.AGENT_GUILD_HOME.value;
298
+ } else if (vars.AGENT_GUILD_HOME?.value) {
299
+ return conflict(`names no data folder, so AGENT_GUILD_HOME decides which Agent Guild it reaches wherever ${tool} starts`);
300
+ } else {
301
+ target = dataDirFor(values, platform);
302
+ }
303
+ if (!samePath(target, home, platform)) return conflict(`reaches the Agent Guild data folder ${target}, not this one, ${home}`);
304
+ const managed = !entry.from && command === own.command && entry.args.length === own.args.length && entry.args.every((a, i) => a === own.args[i])
305
+ && Object.keys(entryEnv).length === 0 && !entry.envVars?.length && !entry.cwd;
306
+ return { state: entry.enabled === false ? 'disabled' : 'on', managed, command: shown, allowInput: launch.allowInput };
307
+ }
308
+
309
+ function refusal(status, code, message) {
310
+ return Object.assign(new Error(message), { status, code });
311
+ }
312
+
313
+ export class McpConnect {
314
+ constructor({ registry, own, home = dataDir(), prepareAccount, dir = os.homedir(), ttlMs = STATE_TTL_MS }) {
315
+ this.registry = registry;
316
+ this.prepareAccount = prepareAccount;
317
+ this.own = own;
318
+ this.home = home;
319
+ this.dir = dir;
320
+ this.ttlMs = ttlMs;
321
+ this.cache = new Map();
322
+ this.reading = new Map();
323
+ this.reads = 0;
324
+ this.busy = new Set();
325
+ }
326
+
327
+ _tool(provider) {
328
+ return MCP_MODES.includes(provider.mcp) ? TOOLS[provider.mcp] : null;
329
+ }
330
+
331
+ _accounts(provider) {
332
+ return this.registry.accountsFor(provider).map((account) => ({
333
+ id: account.id, label: account.label, dir: account.dir, account, env: { ...this.registry.env, ...provider.env, ...account.env },
334
+ }));
335
+ }
336
+
337
+ _signature(provider) {
338
+ return JSON.stringify(this._accounts(provider).map((a) => [a.id, a.label, a.dir, provider.homeVar ? a.env[provider.homeVar] ?? null : null]));
339
+ }
340
+
341
+ // A settings file read directly is read again as soon as it changes.
342
+ _files(provider) {
343
+ const tool = this._tool(provider);
344
+ if (!tool.file) return null;
345
+ return JSON.stringify(this._accounts(provider).map((a) => statOf(tool.file(a.env, this.registry.platform))));
346
+ }
347
+
348
+ async _run(provider, resolved, env, args) {
349
+ return runSpec(buildSpawnSpec(resolved, args, env, this.registry.platform), { env, cwd: this.dir, timeoutMs: RUN_TIMEOUT_MS, killTree: true });
350
+ }
351
+
352
+ async readAccount(provider, resolved, account) {
353
+ const tool = this._tool(provider);
354
+ // A managed home that does not exist yet holds no settings, and some tools create it when asked.
355
+ if (account.dir && !fs.existsSync(account.dir)) return { state: 'off', fingerprint: fingerprint(null) };
356
+ const platform = this.registry.platform;
357
+ const ctx = { env: account.env, platform, run: (args) => this._run(provider, resolved, account.env, args) };
358
+ let read;
359
+ try {
360
+ read = await tool.read(ctx);
361
+ } catch (err) {
362
+ return { state: 'error', detail: `${provider.command} mcp list failed: ${lastLine(err)}`, fingerprint: null };
363
+ }
364
+ if (read.error) return { state: 'error', detail: read.error, fingerprint: null };
365
+ const result = classify(read.entry, { own: this.own, home: this.home, env: account.env, platform, drops: tool.drops, firstKeyWins: tool.firstKeyWins, tool: provider.tool });
366
+ return { ...result, ...(read.note && { detail: read.note }), from: read.entry?.from ?? null, fingerprint: fingerprint(read.entry?.raw ?? read.raw ?? null) };
367
+ }
368
+
369
+ async _readAll(provider, resolved) {
370
+ return Promise.all(this._accounts(provider).map(async (account) => ({ id: account.id, label: account.label, ...await this.readAccount(provider, resolved, account) })));
371
+ }
372
+
373
+ refresh(provider, { force = false } = {}) {
374
+ const resolved = this.registry.resolve(provider);
375
+ if (!this._tool(provider) || !resolved) return Promise.resolve(null);
376
+ const sig = this._signature(provider);
377
+ const files = this._files(provider);
378
+ const cached = this.cache.get(provider.id);
379
+ if (!force && cached && cached.resolved === resolved && cached.sig === sig && cached.files === files && Date.now() - cached.at < this.ttlMs) return Promise.resolve(cached.accounts);
380
+ // A forced read follows a change, so a read that started before it must not be the one kept.
381
+ const pending = this.reading.get(provider.id);
382
+ if (!force && pending?.sig === sig && pending.files === files) return pending.job;
383
+ const seq = ++this.reads;
384
+ const job = this._readAll(provider, resolved).then((accounts) => {
385
+ if (this.reading.get(provider.id)?.seq !== seq) return accounts;
386
+ this.reading.delete(provider.id);
387
+ // Accounts that changed during the read make its result obsolete.
388
+ const current = this.registry.get(provider.id);
389
+ if (!current || this._signature(current) !== sig) return accounts;
390
+ const before = this.cache.get(provider.id);
391
+ this.cache.set(provider.id, { resolved, sig, files, accounts, at: Date.now() });
392
+ // A page showing "checking" for a new account set needs this read even when every state is the same.
393
+ if (before?.sig !== sig || JSON.stringify(accounts) !== JSON.stringify(before.accounts)) this.registry.emit?.('updated');
394
+ return accounts;
395
+ }, (err) => {
396
+ if (this.reading.get(provider.id)?.seq === seq) this.reading.delete(provider.id);
397
+ throw err;
398
+ });
399
+ this.reading.set(provider.id, { seq, sig, files, job });
400
+ return job;
401
+ }
402
+
403
+ describe(provider) {
404
+ if (!this._tool(provider) || !this.registry.resolve(provider)) return null;
405
+ this.refresh(provider).catch(() => {});
406
+ const cached = this.cache.get(provider.id);
407
+ if (!cached || cached.sig !== this._signature(provider)) {
408
+ return { mode: provider.mcp, accounts: this._accounts(provider).map(({ id, label }) => ({ id, label, state: 'checking', managed: false, from: null, command: null, allowInput: false, detail: null, fingerprint: null })) };
409
+ }
410
+ return {
411
+ mode: provider.mcp,
412
+ accounts: cached.accounts.map(({ id, label, state, managed, from, command, allowInput, detail, fingerprint: print }) => ({
413
+ id, label, state, managed: Boolean(managed), from: from ?? null, command: command ?? null, allowInput: Boolean(allowInput), detail: detail ?? null, fingerprint: print ?? null,
414
+ })),
415
+ };
416
+ }
417
+
418
+ /** Changes one account, only if its entry is still the one the caller saw (`expect`, its fingerprint). */
419
+ async change(provider, accountId, action, expect) {
420
+ const tool = this._tool(provider);
421
+ if (!tool) throw refusal(400, 'not_applicable', `${provider.tool} has no MCP settings Agent Guild can change`);
422
+ const resolved = this.registry.resolve(provider);
423
+ if (!resolved) throw refusal(409, 'provider_unavailable', `${provider.tool} is not installed`);
424
+ const account = this._accounts(provider).find((a) => a.id === accountId);
425
+ if (!account) throw refusal(404, 'unknown_account', `${provider.tool} has no account "${accountId}"`);
426
+ if (this.busy.has(provider.id)) throw refusal(409, 'busy', `${provider.tool}'s MCP settings are already being changed`);
427
+ this.busy.add(provider.id);
428
+ try {
429
+ return await this._change(provider, resolved, tool, account, action, expect);
430
+ } finally {
431
+ this.busy.delete(provider.id);
432
+ await this.refresh(provider, { force: true }).catch(() => {});
433
+ }
434
+ }
435
+
436
+ async _change(provider, resolved, tool, account, action, expect) {
437
+ // The cache is for display only: every change acts on a fresh read of the account.
438
+ const now = await this.readAccount(provider, resolved, account);
439
+ if (now.state === 'error') throw refusal(409, 'unreadable', `Agent Guild cannot read ${provider.tool}'s MCP settings: ${now.detail}`);
440
+ if (now.fingerprint !== expect) throw refusal(409, 'changed', `${provider.tool}'s MCP settings changed since the card showed them. Nothing was changed.`);
441
+ let steps;
442
+ let want;
443
+ if (action === 'on') {
444
+ want = 'on';
445
+ if (now.state === 'on') return { outcome: 'unchanged', state: now.state };
446
+ if (now.state === 'conflict') throw refusal(409, 'conflict', `${provider.tool} has another server named agent-guild. Replace it instead.`);
447
+ if (now.state === 'off') steps = tool.add(this.own);
448
+ else if (now.from) steps = tool.enable();
449
+ else if (provider.mcp === 'codex' && !now.managed) throw refusal(409, 'needs_replace', 'Codex CLI can turn this server on only by replacing it.');
450
+ else steps = tool.enable(this.own);
451
+ } else if (action === 'off') {
452
+ if (now.state === 'off' || now.state === 'disabled') return { outcome: 'unchanged', state: now.state };
453
+ if (now.state === 'conflict') throw refusal(409, 'conflict', `${provider.tool}'s server named agent-guild is not Agent Guild's, so Agent Guild leaves it as it is.`);
454
+ want = now.from ? 'disabled' : 'off';
455
+ steps = now.from ? tool.disable() : tool.remove();
456
+ } else {
457
+ want = 'on';
458
+ if (now.state === 'off') steps = tool.add(this.own);
459
+ else if (now.managed) return now.state === 'on' ? { outcome: 'unchanged', state: now.state } : this._change(provider, resolved, tool, account, 'on', expect);
460
+ else steps = tool.replace(this.own);
461
+ }
462
+ try {
463
+ if (want === 'on' && account.dir) this.prepareAccount(provider, account.account);
464
+ for (const args of steps) await this._run(provider, resolved, account.env, args);
465
+ } catch (err) {
466
+ const after = await this.readAccount(provider, resolved, account);
467
+ return { outcome: 'failed', state: after.state, detail: `${provider.command} mcp failed: ${lastLine(err)}` };
468
+ }
469
+ let after = await this.readAccount(provider, resolved, account);
470
+ // Removing its own entry can leave the tool loading one from another tool's settings.
471
+ if (action === 'off' && after.state === 'on' && after.from && tool.disable) {
472
+ try {
473
+ for (const args of tool.disable()) await this._run(provider, resolved, account.env, args);
474
+ } catch (err) {
475
+ return { outcome: 'failed', state: after.state, detail: `${provider.command} mcp failed: ${lastLine(err)}` };
476
+ }
477
+ after = await this.readAccount(provider, resolved, account);
478
+ want = 'disabled';
479
+ }
480
+ if (after.state !== want) return { outcome: 'failed', state: after.state, detail: after.detail ?? `${provider.tool} reported success, but the server is ${after.state}` };
481
+ return { outcome: 'changed', state: after.state };
482
+ }
483
+ }
@@ -22,6 +22,7 @@ import { MultiplexerRegistry } from './multiplexers.mjs';
22
22
  import { reconcileHerdrPath } from './multiplexer-paths.mjs';
23
23
  import { refreshLaunchers } from './launchers.mjs';
24
24
  import { LaunchFlags, normalizeLaunchFlags } from './launch-flags.mjs';
25
+ import { MCP_MODES } from './mcp-connect.mjs';
25
26
 
26
27
  const here = path.dirname(fileURLToPath(import.meta.url));
27
28
  export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
@@ -87,6 +88,8 @@ function normalizeChannels(raw) {
87
88
  remove: stringList(raw.native.remove),
88
89
  links: stringList(raw.native.links),
89
90
  sharedWithNpm: raw.native.sharedWithNpm === true,
91
+ reinstall: raw.native.reinstall === true,
92
+ versionEnv: typeof raw.native.versionEnv === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(raw.native.versionEnv) ? raw.native.versionEnv : null,
90
93
  };
91
94
  }
92
95
  if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names), autoUpdates: raw.brew.autoUpdates === true };
@@ -171,6 +174,7 @@ function normalize(raw, platform, warnings) {
171
174
  memory: ['claude', 'codex', 'grok'].includes(merged.memory) ? merged.memory : null,
172
175
  instructions: ['claude', 'codex', 'grok', 'google'].includes(merged.instructions) ? merged.instructions : null,
173
176
  reporting: REPORTING_MODES.has(merged.reporting) ? merged.reporting : null,
177
+ mcp: MCP_MODES.includes(merged.mcp) ? merged.mcp : null,
174
178
  modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
175
179
  args: Array.isArray(merged.args) ? merged.args.map(String) : [],
176
180
  resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
@@ -308,6 +312,7 @@ export class ProviderRegistry extends EventEmitter {
308
312
  this._npmRegistry = null;
309
313
  this.reportingEnabled = null;
310
314
  this.reportingNote = null;
315
+ this.mcpState = null;
311
316
  this.multiplexers = new MultiplexerRegistry(this, multiplexerOptions);
312
317
  this.multiplexerState = null;
313
318
  this.reload();
@@ -585,6 +590,16 @@ export class ProviderRegistry extends EventEmitter {
585
590
  return args;
586
591
  }
587
592
 
593
+ reinstalls(provider) {
594
+ return Boolean(!provider.plugin && !provider.package && provider.install && provider.channels.native?.reinstall);
595
+ }
596
+
597
+ installEnv(provider) {
598
+ const name = this.reinstalls(provider) ? provider.channels.native.versionEnv : null;
599
+ const latest = name ? this.versions.get(provider.id)?.latest : null;
600
+ return latest ? { [name]: latest } : null;
601
+ }
602
+
588
603
  /** Whether the tool is there to run: its command on PATH, and for a plugin a copy that `docker` runs. */
589
604
  installed(provider) {
590
605
  return Boolean(provider.plugin ? this._pluginPath(provider) : this.resolve(provider));
@@ -699,7 +714,7 @@ export class ProviderRegistry extends EventEmitter {
699
714
  guidance: `Homebrew moved ${provider.tool} from a formula to a cask, so brew upgrade no longer updates this copy. Run "brew uninstall ${channel.token}", then "brew install --cask ${channel.token}".`,
700
715
  };
701
716
  }
702
- if (provider.plugin) return { file: null, args: [], command: channel.update.command, guidance: null };
717
+ if (provider.plugin || channel.update.reinstall) return { file: null, args: [], command: channel.update.command, guidance: null };
703
718
  const args = this.npmArgs(channel.update, this.versions.get(provider.id)?.latest ?? 'latest');
704
719
  const command = formatCommand(channel.update.file, args);
705
720
  if (channel.probe) {
@@ -828,8 +843,10 @@ export class ProviderRegistry extends EventEmitter {
828
843
  resumable: provider.resumeArgs.length > 0,
829
844
  launchFlags: provider.launchFlags.map(({ flag, risky }) => ({ flag, risky })),
830
845
  launchFlagsEquals: provider.launchFlagsEquals,
831
- installable: provider.plugin ? Boolean(commandPath && !resolvedPath && !pluginError && !pluginPending && provider.install) : Boolean(provider.package && this.resolveNpm()),
832
- installCommand: provider.plugin && provider.install ? provider.install : null,
846
+ installable: provider.plugin ? Boolean(commandPath && !resolvedPath && !pluginError && !pluginPending && provider.install)
847
+ : this.reinstalls(provider) || Boolean(provider.package && this.resolveNpm()),
848
+ installCommand: (provider.plugin || this.reinstalls(provider)) && provider.install ? provider.install : null,
849
+ plugin: provider.plugin,
833
850
  pluginError,
834
851
  pluginPending,
835
852
  installedVersion: installed,
@@ -853,6 +870,7 @@ export class ProviderRegistry extends EventEmitter {
853
870
  reporting: provider.reporting,
854
871
  reportingEnabled: this.reportingEnabled?.(provider) ?? null,
855
872
  reportingNote: this.reportingNote?.(provider) ?? null,
873
+ mcp: this.mcpState?.(provider) ?? null,
856
874
  accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
857
875
  accountsEditable: Boolean(provider.homeVar),
858
876
  shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path, multiplexer: Boolean(shell.multiplexer) })) ?? null,
@@ -935,7 +953,7 @@ export class ProviderRegistry extends EventEmitter {
935
953
  if (!update.command) {
936
954
  throw refusal(400, 'not_updatable', update.guidance || `Still checking how ${provider.tool} was installed. Try again in a moment.`);
937
955
  }
938
- if (provider.plugin) return { spec: this._installCommandSpec(provider), channel: channel.channel };
956
+ if (provider.plugin || channel.update.reinstall) return { spec: this._installCommandSpec(provider), channel: channel.channel };
939
957
  if (!channel.update.package) return { spec: buildSpawnSpec(update.file, update.args, this.env, this.platform), channel: channel.channel };
940
958
  const args = this.npmArgs(channel.update, await this.resolveRelease(provider, update.file));
941
959
  return { spec: npmSpawnSpec(update.file, args, this.env, this.platform), channel: 'npm' };
@@ -963,6 +981,7 @@ export class ProviderRegistry extends EventEmitter {
963
981
  if (plugins.copies.some((c) => c.active)) throw refusal(409, 'already_installed', `${provider.tool} is already installed at ${this._pluginPath(provider)}.`);
964
982
  return this._installCommandSpec(provider);
965
983
  }
984
+ if (this.reinstalls(provider)) return this._installCommandSpec(provider);
966
985
  if (!provider.package) {
967
986
  const err = new Error(`${provider.tool} has no npm package configured; install it by hand: ${provider.install || provider.docs || 'see its documentation'}`);
968
987
  err.status = 400;
@@ -19,6 +19,7 @@ import { MODES as AUTOSTART_MODES } from './autostart.mjs';
19
19
  import { ptyRestartProblem } from './pty.mjs';
20
20
  import { YardArt } from './yard-art.mjs';
21
21
  import { spokenText } from './spoken-text.mjs';
22
+ import { MCP_ACTIONS } from './mcp-connect.mjs';
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
  const API = '/api/v1';
@@ -183,6 +184,8 @@ export function createManagerServer({
183
184
  nextManagerProblem = ptyRestartProblem,
184
185
  /** The Yard's art; without a data folder, only a checkout's. */
185
186
  yardArt = new YardArt({ webDir }),
187
+ mcpConnect = null,
188
+ heartbeatMs = 30000,
186
189
  }) {
187
190
  let listedProviders = null;
188
191
  const accounts = createAccountEditor({
@@ -517,6 +520,17 @@ export function createManagerServer({
517
520
  registry.emit('updated');
518
521
  return sendJson(res, 200, { provider: registry.describe(provider) });
519
522
  }
523
+ const mcpMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/mcp$/);
524
+ if (mcpMatch && method === 'POST' && mcpConnect) {
525
+ const provider = registry.get(mcpMatch[1]);
526
+ if (!provider) throw new HttpError(404, `unknown provider "${mcpMatch[1]}"`, 'unknown_provider');
527
+ const body = await readJsonBody(req);
528
+ if (typeof body.account !== 'string') throw new HttpError(400, 'account must be an account id', 'bad_request');
529
+ if (!MCP_ACTIONS.includes(body.action)) throw new HttpError(400, `action must be one of ${MCP_ACTIONS.join(', ')}`, 'bad_request');
530
+ if (typeof body.expect !== 'string') throw new HttpError(400, 'expect must be the fingerprint the card showed', 'bad_request');
531
+ const result = await mcpConnect.change(provider, body.account, body.action, body.expect);
532
+ return sendJson(res, 200, { provider: registry.describe(provider), result });
533
+ }
520
534
  const multiplexerMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/multiplexers\/([a-z0-9_-]+)\/(install|update|uninstall)$/);
521
535
  if (multiplexerMatch && method === 'POST') {
522
536
  const body = await readJsonBody(req);
@@ -818,7 +832,8 @@ export function createManagerServer({
818
832
  ws.isAlive = false;
819
833
  try { ws.ping(); } catch { /* closing */ }
820
834
  }
821
- }, 30000);
835
+ if (mcpConnect && wss.clients.size) for (const provider of registry.providers) mcpConnect.refresh(provider).catch(() => {});
836
+ }, heartbeatMs);
822
837
  heartbeat.unref();
823
838
 
824
839
  return {
@@ -505,11 +505,11 @@ export class SessionManager extends EventEmitter {
505
505
  const { spec, channel } = await this.registry.updateSpec(provider);
506
506
  guard();
507
507
  const name = `Update ${provider.tool} (${CHANNEL_LABELS[channel]})`;
508
- return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'update' });
508
+ return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'update', extraEnv: this.registry.installEnv(provider) });
509
509
  }
510
510
  const spawnSpec = await this.registry.installSpec(provider);
511
511
  guard();
512
- return this._spawn({ provider, spawnSpec, cwd: os.homedir(), name: `Install ${provider.tool}`, task: 'install', installKind: 'install' });
512
+ return this._spawn({ provider, spawnSpec, cwd: os.homedir(), name: `Install ${provider.tool}`, task: 'install', installKind: 'install', extraEnv: this.registry.installEnv(provider) });
513
513
  } finally {
514
514
  this.installing.delete(provider.id);
515
515
  }