@oddessentials/agent-guild 1.22.0 → 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 (36) hide show
  1. package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
  2. package/bin/native/win32-x64/agent-guild-report.exe +0 -0
  3. package/bin/windows/arm64/agent-guild-start.exe +0 -0
  4. package/bin/windows/x64/agent-guild-start.exe +0 -0
  5. package/docs/api.md +23 -12
  6. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  7. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  8. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  9. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  10. package/node_modules/smol-toml/LICENSE +24 -0
  11. package/node_modules/smol-toml/README.md +605 -0
  12. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  13. package/node_modules/smol-toml/dist/date.js +156 -0
  14. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  15. package/node_modules/smol-toml/dist/error.js +67 -0
  16. package/node_modules/smol-toml/dist/extract.js +293 -0
  17. package/node_modules/smol-toml/dist/index.cjs +771 -0
  18. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  19. package/node_modules/smol-toml/dist/index.js +34 -0
  20. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  21. package/node_modules/smol-toml/dist/parse.js +164 -0
  22. package/node_modules/smol-toml/dist/primitive.js +151 -0
  23. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  24. package/node_modules/smol-toml/dist/stringify.js +219 -0
  25. package/node_modules/smol-toml/dist/struct.js +156 -0
  26. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  27. package/node_modules/smol-toml/dist/util.js +57 -0
  28. package/node_modules/smol-toml/package.json +68 -0
  29. package/package.json +2 -1
  30. package/src/manager/config.mjs +10 -10
  31. package/src/manager/mcp-connect.mjs +332 -98
  32. package/src/manager/server.mjs +6 -3
  33. package/web/app.js +82 -41
  34. package/web/index.html +10 -6
  35. package/web/styles.css +13 -4
  36. package/web/yard/art.json +1 -1
@@ -1,36 +1,42 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
- import { buildSpawnSpec, runSpec } from './command-resolver.mjs';
5
+ import { buildSpawnSpec, resolveCommand, runSpec } from './command-resolver.mjs';
5
6
  import { antigravityConfigFile } from './session-hooks.mjs';
7
+ import { parse as parseToml } from 'smol-toml';
6
8
  import { stableExecPath } from './autostart.mjs';
7
9
  import { PACKAGE_SCRIPT } from './launch.mjs';
8
- import { dataDir } from './config.mjs';
10
+ import { dataDir, dataDirFor } from './config.mjs';
9
11
 
10
12
  export const SERVER_NAME = 'agent-guild';
11
13
  export const MCP_MODES = ['claude', 'codex', 'antigravity', 'grok'];
14
+ export const MCP_ACTIONS = ['on', 'off', 'replace'];
12
15
  const STATE_TTL_MS = 60 * 1000;
13
16
  const RUN_TIMEOUT_MS = 30000;
17
+ const PACKAGE = /^@oddessentials\/agent-guild(?:@[^\s/]+)?$/;
18
+ const HOME_FLAG = '--home=';
14
19
 
15
20
  export function ownServer(execPath = process.execPath, home = dataDir()) {
16
- return { command: stableExecPath(execPath), args: [PACKAGE_SCRIPT, 'mcp', `--home=${path.resolve(home)}`] };
21
+ return { command: stableExecPath(execPath), args: [PACKAGE_SCRIPT, 'mcp', `${HOME_FLAG}${path.resolve(home)}`] };
17
22
  }
18
23
 
19
24
  const homeOf = (env, platform) => (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
20
25
 
21
- export function classify(entry, own, { enabled = true, command, args, remote = null } = {}) {
22
- if (entry === undefined || entry === null) return { state: 'off' };
23
- if (remote) return { state: 'other', detail: `agent-guild is already set to ${remote}` };
24
- if (typeof command !== 'string' || !Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
25
- return { state: 'unknown', detail: 'its MCP settings name the agent-guild server in a form Agent Guild does not recognize' };
26
- }
27
- if (command !== own.command || args.length !== own.args.length || args.some((a, i) => a !== own.args[i])) {
28
- return { state: 'other', detail: `agent-guild is already set to run ${[command, ...args].join(' ')}` };
29
- }
30
- return { state: enabled === false ? 'disabled' : 'on' };
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);
31
30
  }
32
31
 
33
- function readJsonFile(file) {
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 } = {}) {
34
40
  let text;
35
41
  try {
36
42
  text = fs.readFileSync(file, 'utf8');
@@ -38,6 +44,7 @@ function readJsonFile(file) {
38
44
  if (err.code === 'ENOENT') return { missing: true };
39
45
  return { error: `${file} cannot be read: ${err.message}` };
40
46
  }
47
+ if (emptyIsNone && !text.trim()) return { missing: true };
41
48
  try {
42
49
  return { value: JSON.parse(text) };
43
50
  } catch {
@@ -45,90 +52,270 @@ function readJsonFile(file) {
45
52
  }
46
53
  }
47
54
 
48
- function fromServersFile(file, own, enabledOf) {
49
- const read = readJsonFile(file);
50
- if (read.missing) return { state: 'off' };
51
- if (read.error) return { state: 'unknown', detail: read.error };
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 };
52
69
  const servers = read.value?.mcpServers;
53
- if (servers === undefined) return { state: 'off' };
54
- if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return { state: 'unknown', detail: `${file} has mcpServers in a form Agent Guild does not recognize` };
55
- const entry = Object.hasOwn(servers, SERVER_NAME) ? servers[SERVER_NAME] : undefined;
56
- if (entry === undefined) return { state: 'off' };
57
- if (!entry || typeof entry !== 'object') return classify(entry, own);
58
- const remote = entry.type !== undefined && entry.type !== 'stdio' ? String(entry.url ?? entry.type) : null;
59
- return classify(entry, own, { enabled: enabledOf(entry), command: entry.command, args: entry.args ?? [], remote });
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 };
60
73
  }
61
74
 
62
- function fromList(stdout, own, pick) {
63
- let list;
64
- try { list = JSON.parse(stdout); } catch { list = null; }
65
- if (!Array.isArray(list)) return { state: 'unknown', detail: 'its MCP server list was not in a form Agent Guild recognizes' };
66
- const entry = list.find((item) => item?.name === SERVER_NAME && pick.match(item));
67
- if (!entry) return { state: 'off' };
68
- return classify(entry, own, pick.fields(entry));
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;
69
84
  }
70
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
+
71
89
  const TOOLS = {
72
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,
73
93
  file: (env, platform) => path.join(env.CLAUDE_CONFIG_DIR || homeOf(env, platform), '.claude.json'),
74
- read(ctx) { return fromServersFile(this.file(ctx.env, ctx.platform), ctx.own, () => true); },
75
- add: (own) => ['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args],
76
- remove: () => ['mcp', 'remove', '-s', 'user', SERVER_NAME],
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]],
77
101
  },
78
102
  codex: {
79
- list: ['mcp', 'list', '--json'],
80
- pick: {
81
- match: () => true,
82
- fields: (e) => ({ enabled: e.enabled, command: e.transport?.command, args: e.transport?.args ?? [], remote: e.transport?.type && e.transport.type !== 'stdio' ? String(e.transport.url ?? e.transport.type) : null }),
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
+ };
83
122
  },
84
- add: (own) => ['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args],
85
- // Adding again replaces the entry, and with it the `enabled = false` that turned it off.
86
- enable: (own) => ['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args],
87
- remove: () => ['mcp', 'remove', SERVER_NAME],
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]],
88
130
  },
89
131
  antigravity: {
90
132
  file: (env, platform) => path.join(path.dirname(antigravityConfigFile(env, platform)), 'mcp_config.json'),
91
- read(ctx) { return fromServersFile(this.file(ctx.env, ctx.platform), ctx.own, (entry) => entry.disabled !== true); },
92
- add: (own) => ['mcp', 'add', SERVER_NAME, '--', own.command, ...own.args],
93
- enable: () => ['mcp', 'enable', SERVER_NAME],
94
- remove: () => ['mcp', 'remove', SERVER_NAME],
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]],
95
142
  },
96
143
  grok: {
97
- list: ['mcp', 'list', '--json'],
98
- pick: { match: (e) => e.scope === undefined || e.scope === 'user', fields: (e) => ({ enabled: e.enabled, command: e.command, args: e.args ?? [], remote: e.url ? String(e.url) : null }) },
99
- add: (own) => ['mcp', 'add', '-s', 'user', SERVER_NAME, '--', own.command, ...own.args],
100
- enable: () => ['mcp', 'enable', SERVER_NAME],
101
- remove: () => ['mcp', 'remove', '-s', 'user', SERVER_NAME],
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]],
102
184
  },
103
185
  };
104
186
 
105
- function lastLine(err) {
106
- return `${err.stderr || ''}\n${err.stdout || ''}`.trim().split('\n').map((l) => l.trim()).filter(Boolean).pop() || err.message;
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 };
107
205
  }
108
206
 
109
- function refusal(status, code, message) {
110
- return Object.assign(new Error(message), { status, code });
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 };
111
307
  }
112
308
 
113
- export function summarize(accounts) {
114
- const count = (state) => accounts.filter((a) => a.state === state).length;
115
- const total = accounts.length;
116
- const on = count('on');
117
- const state = on === total ? 'on'
118
- : count('off') === total ? 'off'
119
- : on > 0 ? 'partial'
120
- : count('unknown') ? 'unknown'
121
- : count('other') ? 'other'
122
- : count('disabled') ? 'disabled'
123
- : 'off';
124
- return { state, on, total };
309
+ function refusal(status, code, message) {
310
+ return Object.assign(new Error(message), { status, code });
125
311
  }
126
312
 
127
313
  export class McpConnect {
128
- constructor({ registry, own, prepareAccount, dir = os.homedir(), ttlMs = STATE_TTL_MS }) {
314
+ constructor({ registry, own, home = dataDir(), prepareAccount, dir = os.homedir(), ttlMs = STATE_TTL_MS }) {
129
315
  this.registry = registry;
130
316
  this.prepareAccount = prepareAccount;
131
317
  this.own = own;
318
+ this.home = home;
132
319
  this.dir = dir;
133
320
  this.ttlMs = ttlMs;
134
321
  this.cache = new Map();
@@ -151,6 +338,13 @@ export class McpConnect {
151
338
  return JSON.stringify(this._accounts(provider).map((a) => [a.id, a.label, a.dir, provider.homeVar ? a.env[provider.homeVar] ?? null : null]));
152
339
  }
153
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
+
154
348
  async _run(provider, resolved, env, args) {
155
349
  return runSpec(buildSpawnSpec(resolved, args, env, this.registry.platform), { env, cwd: this.dir, timeoutMs: RUN_TIMEOUT_MS, killTree: true });
156
350
  }
@@ -158,15 +352,18 @@ export class McpConnect {
158
352
  async readAccount(provider, resolved, account) {
159
353
  const tool = this._tool(provider);
160
354
  // A managed home that does not exist yet holds no settings, and some tools create it when asked.
161
- if (account.dir && !fs.existsSync(account.dir)) return { state: 'off' };
162
- const ctx = { env: account.env, platform: this.registry.platform, own: this.own };
163
- if (tool.read) return tool.read(ctx);
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;
164
359
  try {
165
- const { stdout } = await this._run(provider, resolved, account.env, tool.list);
166
- return fromList(stdout, this.own, tool.pick);
360
+ read = await tool.read(ctx);
167
361
  } catch (err) {
168
- return { state: 'unknown', detail: `${provider.command} ${tool.list.join(' ')} failed: ${lastLine(err)}` };
362
+ return { state: 'error', detail: `${provider.command} mcp list failed: ${lastLine(err)}`, fingerprint: null };
169
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) };
170
367
  }
171
368
 
172
369
  async _readAll(provider, resolved) {
@@ -177,11 +374,12 @@ export class McpConnect {
177
374
  const resolved = this.registry.resolve(provider);
178
375
  if (!this._tool(provider) || !resolved) return Promise.resolve(null);
179
376
  const sig = this._signature(provider);
377
+ const files = this._files(provider);
180
378
  const cached = this.cache.get(provider.id);
181
- if (!force && cached && cached.resolved === resolved && cached.sig === sig && Date.now() - cached.at < this.ttlMs) return Promise.resolve(cached.accounts);
379
+ if (!force && cached && cached.resolved === resolved && cached.sig === sig && cached.files === files && Date.now() - cached.at < this.ttlMs) return Promise.resolve(cached.accounts);
182
380
  // A forced read follows a change, so a read that started before it must not be the one kept.
183
381
  const pending = this.reading.get(provider.id);
184
- if (!force && pending?.sig === sig) return pending.job;
382
+ if (!force && pending?.sig === sig && pending.files === files) return pending.job;
185
383
  const seq = ++this.reads;
186
384
  const job = this._readAll(provider, resolved).then((accounts) => {
187
385
  if (this.reading.get(provider.id)?.seq !== seq) return accounts;
@@ -190,7 +388,7 @@ export class McpConnect {
190
388
  const current = this.registry.get(provider.id);
191
389
  if (!current || this._signature(current) !== sig) return accounts;
192
390
  const before = this.cache.get(provider.id);
193
- this.cache.set(provider.id, { resolved, sig, accounts, at: Date.now() });
391
+ this.cache.set(provider.id, { resolved, sig, files, accounts, at: Date.now() });
194
392
  // A page showing "checking" for a new account set needs this read even when every state is the same.
195
393
  if (before?.sig !== sig || JSON.stringify(accounts) !== JSON.stringify(before.accounts)) this.registry.emit?.('updated');
196
394
  return accounts;
@@ -198,7 +396,7 @@ export class McpConnect {
198
396
  if (this.reading.get(provider.id)?.seq === seq) this.reading.delete(provider.id);
199
397
  throw err;
200
398
  });
201
- this.reading.set(provider.id, { seq, sig, job });
399
+ this.reading.set(provider.id, { seq, sig, files, job });
202
400
  return job;
203
401
  }
204
402
 
@@ -206,44 +404,80 @@ export class McpConnect {
206
404
  if (!this._tool(provider) || !this.registry.resolve(provider)) return null;
207
405
  this.refresh(provider).catch(() => {});
208
406
  const cached = this.cache.get(provider.id);
209
- if (!cached || cached.sig !== this._signature(provider)) return { state: 'checking', on: 0, total: provider.accounts.length, accounts: [] };
210
- return { ...summarize(cached.accounts), accounts: cached.accounts.map(({ id, label, state, detail }) => ({ id, label, state, detail: detail ?? null })) };
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
+ };
211
416
  }
212
417
 
213
- async setEnabled(provider, enabled) {
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) {
214
420
  const tool = this._tool(provider);
215
421
  if (!tool) throw refusal(400, 'not_applicable', `${provider.tool} has no MCP settings Agent Guild can change`);
216
422
  const resolved = this.registry.resolve(provider);
217
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}"`);
218
426
  if (this.busy.has(provider.id)) throw refusal(409, 'busy', `${provider.tool}'s MCP settings are already being changed`);
219
427
  this.busy.add(provider.id);
220
428
  try {
221
- const results = [];
222
- for (const account of this._accounts(provider)) {
223
- results.push({ id: account.id, label: account.label, ...await this._change(provider, resolved, tool, account, enabled) });
224
- }
225
- await this.refresh(provider, { force: true });
226
- return results;
429
+ return await this._change(provider, resolved, tool, account, action, expect);
227
430
  } finally {
228
431
  this.busy.delete(provider.id);
432
+ await this.refresh(provider, { force: true }).catch(() => {});
229
433
  }
230
434
  }
231
435
 
232
- async _change(provider, resolved, tool, account, enabled) {
436
+ async _change(provider, resolved, tool, account, action, expect) {
233
437
  // The cache is for display only: every change acts on a fresh read of the account.
234
438
  const now = await this.readAccount(provider, resolved, account);
235
- const want = enabled ? 'on' : 'off';
236
- if (now.state === want) return { outcome: 'unchanged', state: now.state };
237
- if (now.state === 'unknown' || now.state === 'other') return { outcome: 'skipped', state: now.state, detail: now.detail };
238
- const args = !enabled ? tool.remove() : now.state === 'disabled' ? tool.enable(this.own) : tool.add(this.own);
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
+ }
239
462
  try {
240
- if (enabled && account.dir) this.prepareAccount(provider, account.account);
241
- await this._run(provider, resolved, account.env, args);
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);
242
465
  } catch (err) {
243
- return { outcome: 'failed', state: now.state, detail: `${provider.command} ${args.slice(0, 2).join(' ')} failed: ${lastLine(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';
244
479
  }
245
- const after = await this.readAccount(provider, resolved, account);
246
480
  if (after.state !== want) return { outcome: 'failed', state: after.state, detail: after.detail ?? `${provider.tool} reported success, but the server is ${after.state}` };
247
- return { outcome: enabled ? 'added' : 'removed', state: after.state };
481
+ return { outcome: 'changed', state: after.state };
248
482
  }
249
483
  }
@@ -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';
@@ -524,9 +525,11 @@ export function createManagerServer({
524
525
  const provider = registry.get(mcpMatch[1]);
525
526
  if (!provider) throw new HttpError(404, `unknown provider "${mcpMatch[1]}"`, 'unknown_provider');
526
527
  const body = await readJsonBody(req);
527
- if (typeof body.enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
528
- const results = await mcpConnect.setEnabled(provider, body.enabled);
529
- return sendJson(res, 200, { provider: registry.describe(provider), results });
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 });
530
533
  }
531
534
  const multiplexerMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/multiplexers\/([a-z0-9_-]+)\/(install|update|uninstall)$/);
532
535
  if (multiplexerMatch && method === 'POST') {