@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.
- package/bin/agent-guild.mjs +2 -0
- package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
- package/bin/native/win32-x64/agent-guild-report.exe +0 -0
- package/bin/windows/arm64/agent-guild-start.exe +0 -0
- package/bin/windows/x64/agent-guild-start.exe +0 -0
- package/config/providers.default.json +4 -0
- package/docs/api.md +34 -3
- package/examples/terminal-ai-chat.json +27 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +293 -0
- package/node_modules/smol-toml/dist/index.cjs +771 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +2 -1
- package/src/manager/config.mjs +10 -10
- package/src/manager/install-channels.mjs +3 -0
- package/src/manager/main.mjs +4 -0
- package/src/manager/mcp-connect.mjs +483 -0
- package/src/manager/providers.mjs +23 -4
- package/src/manager/server.mjs +16 -1
- package/src/manager/session-manager.mjs +2 -2
- package/web/app.js +92 -2
- package/web/index.html +11 -1
- package/web/styles.css +13 -2
- package/web/yard/art.json +1 -1
- package/web/yard/renderer.js +110 -36
- 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)
|
|
832
|
-
|
|
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;
|
package/src/manager/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|