@monster-orange/den 0.2.0 → 0.2.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/CHANGELOG.md +67 -0
- package/bin/den +3 -2
- package/docs/distributions.md +37 -9
- package/package.json +1 -1
- package/src/shared/environment-manager/service-runner.ts +4 -2
- package/src/shared/manifest/placement.ts +15 -3
- package/src/ui/cli/index.ts +55 -12
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/mcp/api.ts +2 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +6 -2
- package/src/ui/mcp/server.ts +2 -0
- package/src/ui/mcp/tools.ts +89 -10
- package/src/ui/server/audit.ts +69 -3
- package/src/ui/server/index.ts +83 -2
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/public/graph.css +4 -0
- package/src/ui/server/public/panel.js +6 -3
- package/src/ui/server/readonly-routes.ts +11 -2
- package/src/ui/server/workspace-routes.ts +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> mcp connect | disconnect | clients | config`: register this
|
|
3
|
+
* distribution's MCP server with a developer's agents. Planning lives in
|
|
4
|
+
* ../mcp/connect.ts; this file does the I/O.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import {
|
|
12
|
+
MCP_CLIENTS,
|
|
13
|
+
findClient,
|
|
14
|
+
genericSnippet,
|
|
15
|
+
jsonHasEntry,
|
|
16
|
+
mergeJsonConfig,
|
|
17
|
+
serverSpec,
|
|
18
|
+
type ConnectAction,
|
|
19
|
+
type ConnectEnv,
|
|
20
|
+
type McpClient,
|
|
21
|
+
type Scope,
|
|
22
|
+
type ServerSpec,
|
|
23
|
+
} from "../mcp/connect.js";
|
|
24
|
+
import { product } from "../../shared/product.js";
|
|
25
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
26
|
+
import { readWebPort } from "../../shared/edge/config.js";
|
|
27
|
+
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
28
|
+
import { readMcpConfig } from "../../shared/den-config.js";
|
|
29
|
+
|
|
30
|
+
export const MCP_CONNECT_SUBCOMMANDS = [
|
|
31
|
+
"connect",
|
|
32
|
+
"disconnect",
|
|
33
|
+
"clients",
|
|
34
|
+
"config",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
function onPath(bin: string): boolean {
|
|
38
|
+
const exts =
|
|
39
|
+
process.platform === "win32"
|
|
40
|
+
? (process.env.PATHEXT ?? ".EXE;.CMD").split(";")
|
|
41
|
+
: [""];
|
|
42
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
43
|
+
if (!dir) continue;
|
|
44
|
+
for (const ext of exts) {
|
|
45
|
+
try {
|
|
46
|
+
fs.accessSync(path.join(dir, bin + ext), fs.constants.X_OK);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
/* next */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function connectEnv(projectDir: string): ConnectEnv {
|
|
57
|
+
return {
|
|
58
|
+
home: os.homedir(),
|
|
59
|
+
platform: process.platform,
|
|
60
|
+
appData: process.env.APPDATA,
|
|
61
|
+
projectDir,
|
|
62
|
+
which: onPath,
|
|
63
|
+
exists: (p) => fs.existsSync(p),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Flags {
|
|
68
|
+
clients: string[];
|
|
69
|
+
all: boolean;
|
|
70
|
+
project: string | null;
|
|
71
|
+
port?: number;
|
|
72
|
+
dryRun: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseFlags(argv: string[]): Flags {
|
|
76
|
+
const f: Flags = { clients: [], all: false, project: null, dryRun: false };
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
const a = argv[i];
|
|
79
|
+
if (a === "--all" || a === "-a") f.all = true;
|
|
80
|
+
else if (a === "--dry-run" || a === "-n") f.dryRun = true;
|
|
81
|
+
else if (a === "--project" || a === "-p") {
|
|
82
|
+
const next = argv[i + 1];
|
|
83
|
+
if (next && !next.startsWith("-") && !findClient(next)) {
|
|
84
|
+
f.project = path.resolve(next);
|
|
85
|
+
i++;
|
|
86
|
+
} else f.project = process.env.DEN_INVOKE_CWD ?? process.cwd();
|
|
87
|
+
} else if (a === "--port") {
|
|
88
|
+
const n = Number(argv[++i]);
|
|
89
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
90
|
+
throw new Error(`--port must be a port number`);
|
|
91
|
+
f.port = n;
|
|
92
|
+
} else if (a.startsWith("-")) throw new Error(`unknown flag ${a}`);
|
|
93
|
+
else f.clients.push(a);
|
|
94
|
+
}
|
|
95
|
+
return f;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function spec(port?: number): ServerSpec {
|
|
99
|
+
const cli = product().cli;
|
|
100
|
+
const s = serverSpec({
|
|
101
|
+
cli,
|
|
102
|
+
distRoot: getDistRoot(),
|
|
103
|
+
nodeBinDir: path.dirname(process.execPath),
|
|
104
|
+
platform: process.platform,
|
|
105
|
+
port,
|
|
106
|
+
});
|
|
107
|
+
if (!fs.existsSync(s.command))
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${s.command} does not exist: run this from a distribution with a bin/${cli} shim`,
|
|
110
|
+
);
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const show = (argv: string[]): string =>
|
|
115
|
+
argv.map((a) => (/^[\w./:=@-]+$/.test(a) ? a : `'${a}'`)).join(" ");
|
|
116
|
+
|
|
117
|
+
function run(argv: string[]): { code: number; out: string } {
|
|
118
|
+
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf-8" });
|
|
119
|
+
return {
|
|
120
|
+
code: r.status ?? 1,
|
|
121
|
+
out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readText(file: string): string | null {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readFileSync(file, "utf-8");
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Write `text` to `file` atomically, keeping the previous copy as .bak. */
|
|
134
|
+
function writeConfig(file: string, text: string): void {
|
|
135
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
136
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
|
|
137
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
138
|
+
fs.writeFileSync(tmp, text);
|
|
139
|
+
fs.renameSync(tmp, file);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function apply(
|
|
143
|
+
a: ConnectAction,
|
|
144
|
+
remove: boolean,
|
|
145
|
+
dryRun: boolean,
|
|
146
|
+
): { ok: boolean; said: string } {
|
|
147
|
+
if (a.kind === "json") {
|
|
148
|
+
const before = readText(a.file);
|
|
149
|
+
const after = mergeJsonConfig(
|
|
150
|
+
before,
|
|
151
|
+
a.key,
|
|
152
|
+
a.name,
|
|
153
|
+
remove ? null : a.entry,
|
|
154
|
+
);
|
|
155
|
+
if (!dryRun) writeConfig(a.file, after);
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
said: `${dryRun ? "would write" : remove ? "removed from" : "wrote"} ${a.file}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (remove) {
|
|
162
|
+
if (!a.remove)
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
said: "remove it from the client's own MCP settings",
|
|
166
|
+
};
|
|
167
|
+
if (dryRun) return { ok: true, said: `would run ${show(a.remove)}` };
|
|
168
|
+
const r = run(a.remove);
|
|
169
|
+
return { ok: r.code === 0, said: r.out || show(a.remove) };
|
|
170
|
+
}
|
|
171
|
+
if (dryRun) return { ok: true, said: `would run ${show(a.add)}` };
|
|
172
|
+
if (a.remove) run(a.remove);
|
|
173
|
+
const r = run(a.add);
|
|
174
|
+
return {
|
|
175
|
+
ok: r.code === 0,
|
|
176
|
+
said:
|
|
177
|
+
r.code === 0 ? `ran ${a.add[0]} ${a.add.slice(1, 3).join(" ")}` : r.out,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function status(
|
|
182
|
+
c: McpClient,
|
|
183
|
+
s: ServerSpec,
|
|
184
|
+
scope: Scope,
|
|
185
|
+
env: ConnectEnv,
|
|
186
|
+
): string {
|
|
187
|
+
const a = c.plan(s, scope, env);
|
|
188
|
+
if (a.kind === "json") {
|
|
189
|
+
const st = jsonHasEntry(readText(a.file), a.key, a.name, a.entry);
|
|
190
|
+
return st === "connected"
|
|
191
|
+
? chalk.green("connected")
|
|
192
|
+
: st === "different"
|
|
193
|
+
? chalk.yellow("connected (different command: reconnect)")
|
|
194
|
+
: chalk.gray("not connected");
|
|
195
|
+
}
|
|
196
|
+
if (!a.check) return chalk.gray("unknown (check the client's MCP list)");
|
|
197
|
+
return run(a.check).code === 0
|
|
198
|
+
? chalk.green("connected")
|
|
199
|
+
: chalk.gray("not connected");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function webServerLine(port?: number): Promise<string> {
|
|
203
|
+
let p = port;
|
|
204
|
+
if (p === undefined) {
|
|
205
|
+
try {
|
|
206
|
+
p = readWebPort();
|
|
207
|
+
} catch {
|
|
208
|
+
p = DEFAULT_PORT;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const url = `http://localhost:${p}`;
|
|
212
|
+
try {
|
|
213
|
+
const r = await fetch(`${url}/api/server-info`, {
|
|
214
|
+
signal: AbortSignal.timeout(1500),
|
|
215
|
+
});
|
|
216
|
+
const info = (await r.json()) as { version?: string };
|
|
217
|
+
return (
|
|
218
|
+
chalk.green(`web server up at ${url}`) +
|
|
219
|
+
(info.version ? chalk.gray(` (Den ${info.version})`) : "")
|
|
220
|
+
);
|
|
221
|
+
} catch {
|
|
222
|
+
return chalk.yellow(
|
|
223
|
+
`web server not running at ${url}: agents get read errors until \`${product().cli} web up\``,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function usage(): string {
|
|
229
|
+
const cli = product().cli;
|
|
230
|
+
return [
|
|
231
|
+
`Usage:`,
|
|
232
|
+
` ${cli} mcp clients which agents are installed and connected`,
|
|
233
|
+
` ${cli} mcp connect [<client>...|--all] register ${cli}'s MCP server (user scope)`,
|
|
234
|
+
` [--project [dir]] in a project's config instead (default: here)`,
|
|
235
|
+
` [--port <n>] [--dry-run]`,
|
|
236
|
+
` ${cli} mcp disconnect <client>... [--project [dir]]`,
|
|
237
|
+
` ${cli} mcp config [--port <n>] JSON for any other MCP client`,
|
|
238
|
+
``,
|
|
239
|
+
`Clients: ${MCP_CLIENTS.map((c) => c.id).join(", ")}`,
|
|
240
|
+
].join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function runMcpConnect(argv: string[]): Promise<number> {
|
|
244
|
+
const [sub, ...rest] = argv;
|
|
245
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
246
|
+
console.log(usage());
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
let flags: Flags;
|
|
250
|
+
try {
|
|
251
|
+
flags = parseFlags(rest);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
console.error(chalk.red(`${(e as Error).message}\n`) + usage());
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
const s = spec(flags.port);
|
|
257
|
+
const scope: Scope = flags.project ? "project" : "user";
|
|
258
|
+
const env = connectEnv(
|
|
259
|
+
flags.project ?? process.env.DEN_INVOKE_CWD ?? process.cwd(),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (sub === "config") {
|
|
263
|
+
console.log(genericSnippet(s));
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (sub === "clients") {
|
|
268
|
+
console.log(
|
|
269
|
+
chalk.bold(
|
|
270
|
+
`\n ${product().title} MCP server: ${s.command} ${s.args.join(" ")}`,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
console.log(` ${await webServerLine(flags.port)}`);
|
|
274
|
+
const actions = readMcpConfig().actions;
|
|
275
|
+
console.log(
|
|
276
|
+
chalk.gray(
|
|
277
|
+
` tools: read${actions.length ? ` + actions ${actions.join(", ")}` : " only (mcp.actions is empty)"}\n`,
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
for (const c of MCP_CLIENTS) {
|
|
281
|
+
const here = c.detect(env);
|
|
282
|
+
const st =
|
|
283
|
+
here && c.scopes.includes(scope)
|
|
284
|
+
? status(c, s, scope, env)
|
|
285
|
+
: chalk.gray(here ? `no ${scope} scope` : "not installed");
|
|
286
|
+
console.log(
|
|
287
|
+
` ${here ? "●" : "○"} ${c.id.padEnd(15)} ${c.title.padEnd(24)} ${st}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
console.log(
|
|
291
|
+
chalk.gray(
|
|
292
|
+
`\n Connect: ${product().cli} mcp connect <client> (or --all for every installed one)\n`,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sub !== "connect" && sub !== "disconnect") {
|
|
299
|
+
console.log(usage());
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
const remove = sub === "disconnect";
|
|
303
|
+
let targets: McpClient[];
|
|
304
|
+
if (flags.all)
|
|
305
|
+
targets = MCP_CLIENTS.filter(
|
|
306
|
+
(c) => c.detect(env) && c.scopes.includes(scope),
|
|
307
|
+
);
|
|
308
|
+
else {
|
|
309
|
+
const unknown = flags.clients.filter((id) => !findClient(id));
|
|
310
|
+
if (unknown.length) {
|
|
311
|
+
console.error(
|
|
312
|
+
chalk.red(`unknown client: ${unknown.join(", ")}\n`) + usage(),
|
|
313
|
+
);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
targets = flags.clients.map((id) => findClient(id)!);
|
|
317
|
+
}
|
|
318
|
+
if (targets.length === 0) {
|
|
319
|
+
console.log(usage());
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let failed = 0;
|
|
324
|
+
for (const c of targets) {
|
|
325
|
+
if (!c.scopes.includes(scope)) {
|
|
326
|
+
console.log(` ${chalk.yellow("•")} ${c.title}: no ${scope} scope`);
|
|
327
|
+
failed++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const r = apply(c.plan(s, scope, env), remove, flags.dryRun);
|
|
332
|
+
console.log(
|
|
333
|
+
` ${r.ok ? chalk.green("✓") : chalk.red("✗")} ${c.title}: ${r.said}`,
|
|
334
|
+
);
|
|
335
|
+
if (!r.ok) failed++;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.log(` ${chalk.red("✗")} ${c.title}: ${(e as Error).message}`);
|
|
338
|
+
failed++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!remove && !flags.dryRun && failed < targets.length) {
|
|
342
|
+
console.log(
|
|
343
|
+
chalk.gray(
|
|
344
|
+
`\n Restart the agent (or start a new session) to load "${s.name}".`,
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
console.log(` ${await webServerLine(flags.port)}\n`);
|
|
348
|
+
}
|
|
349
|
+
return failed ? 1 : 0;
|
|
350
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `address` ("repo" or "repo/service") is something the user named on
|
|
3
|
+
* the command line: the address itself, or the repo it belongs to.
|
|
4
|
+
*/
|
|
5
|
+
export function isNamedTarget(
|
|
6
|
+
address: string,
|
|
7
|
+
named: readonly string[],
|
|
8
|
+
): boolean {
|
|
9
|
+
const repo = address.split("/")[0];
|
|
10
|
+
return named.some((n) => n === address || n === repo);
|
|
11
|
+
}
|
package/src/ui/mcp/api.ts
CHANGED
|
@@ -58,6 +58,8 @@ const READ_ROUTES = new Set([
|
|
|
58
58
|
|
|
59
59
|
/** Action routes and their method. Anything else is never reached. */
|
|
60
60
|
const ACTION_ROUTES: Record<string, string> = {
|
|
61
|
+
// A refusal report: appends one entry to the activity trail, nothing more.
|
|
62
|
+
"/api/activity": "POST",
|
|
61
63
|
"/api/service/exec": "GET",
|
|
62
64
|
"/api/run": "GET",
|
|
63
65
|
"/api/workspace/sync": "POST",
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> mcp connect`: register this distribution's MCP server with the
|
|
3
|
+
* agents a developer uses, so nobody hand-edits an MCP config.
|
|
4
|
+
*
|
|
5
|
+
* Each client is a strategy: where its config lives (or which of its own
|
|
6
|
+
* CLIs registers a server) and in which shape. Planning is pure (tests drive
|
|
7
|
+
* it with a fake home and PATH); applying it writes one JSON key or runs the
|
|
8
|
+
* client's own `mcp add`. Nothing else in a client's config is touched.
|
|
9
|
+
*
|
|
10
|
+
* The registered command is the distribution's bin shim by absolute path,
|
|
11
|
+
* with PATH pinned so that GUI clients (which start without the login shell's
|
|
12
|
+
* PATH, so without nvm's node) can launch it.
|
|
13
|
+
*/
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
export type Scope = "user" | "project";
|
|
17
|
+
|
|
18
|
+
/** What gets registered: one stdio server. */
|
|
19
|
+
export interface ServerSpec {
|
|
20
|
+
name: string;
|
|
21
|
+
command: string;
|
|
22
|
+
args: string[];
|
|
23
|
+
env: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConnectEnv {
|
|
27
|
+
home: string;
|
|
28
|
+
platform: NodeJS.Platform;
|
|
29
|
+
/** %APPDATA% on Windows. */
|
|
30
|
+
appData?: string;
|
|
31
|
+
/** The project directory for project scope (where the agent runs). */
|
|
32
|
+
projectDir: string;
|
|
33
|
+
/** Whether an executable is on PATH. */
|
|
34
|
+
which: (bin: string) => boolean;
|
|
35
|
+
/** Whether a file or directory exists. */
|
|
36
|
+
exists: (p: string) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One change to make. */
|
|
40
|
+
export type ConnectAction =
|
|
41
|
+
| {
|
|
42
|
+
kind: "json";
|
|
43
|
+
file: string;
|
|
44
|
+
/** Top-level key holding the servers: mcpServers, or servers (VS Code). */
|
|
45
|
+
key: "mcpServers" | "servers";
|
|
46
|
+
name: string;
|
|
47
|
+
entry: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
kind: "exec";
|
|
51
|
+
/** Run first, failure ignored: makes the add idempotent. */
|
|
52
|
+
remove?: string[];
|
|
53
|
+
add: string[];
|
|
54
|
+
/** Exit 0 when the server is registered (for status). */
|
|
55
|
+
check?: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface McpClient {
|
|
59
|
+
id: string;
|
|
60
|
+
title: string;
|
|
61
|
+
scopes: Scope[];
|
|
62
|
+
/** Whether the client looks installed here. */
|
|
63
|
+
detect(env: ConnectEnv): boolean;
|
|
64
|
+
plan(spec: ServerSpec, scope: Scope, env: ConnectEnv): ConnectAction;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const stdioEntry = (s: ServerSpec): Record<string, unknown> => ({
|
|
68
|
+
command: s.command,
|
|
69
|
+
args: s.args,
|
|
70
|
+
...(Object.keys(s.env).length ? { env: s.env } : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const envFlags = (flag: string, env: Record<string, string>): string[] =>
|
|
74
|
+
Object.entries(env).flatMap(([k, v]) => [flag, `${k}=${v}`]);
|
|
75
|
+
|
|
76
|
+
function appSupport(env: ConnectEnv, app: string): string {
|
|
77
|
+
if (env.platform === "darwin")
|
|
78
|
+
return path.join(env.home, "Library", "Application Support", app);
|
|
79
|
+
if (env.platform === "win32")
|
|
80
|
+
return path.join(
|
|
81
|
+
env.appData ?? path.join(env.home, "AppData", "Roaming"),
|
|
82
|
+
app,
|
|
83
|
+
);
|
|
84
|
+
return path.join(env.home, ".config", app);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const json = (
|
|
88
|
+
file: string,
|
|
89
|
+
spec: ServerSpec,
|
|
90
|
+
key: "mcpServers" | "servers" = "mcpServers",
|
|
91
|
+
entry: Record<string, unknown> = stdioEntry(spec),
|
|
92
|
+
): ConnectAction => ({ kind: "json", file, key, name: spec.name, entry });
|
|
93
|
+
|
|
94
|
+
export const MCP_CLIENTS: readonly McpClient[] = [
|
|
95
|
+
{
|
|
96
|
+
id: "claude-code",
|
|
97
|
+
title: "Claude Code",
|
|
98
|
+
scopes: ["user", "project"],
|
|
99
|
+
detect: (e) => e.which("claude"),
|
|
100
|
+
plan: (s, scope, e) =>
|
|
101
|
+
scope === "project"
|
|
102
|
+
? json(path.join(e.projectDir, ".mcp.json"), s)
|
|
103
|
+
: {
|
|
104
|
+
kind: "exec",
|
|
105
|
+
remove: ["claude", "mcp", "remove", "--scope", "user", s.name],
|
|
106
|
+
add: [
|
|
107
|
+
"claude",
|
|
108
|
+
"mcp",
|
|
109
|
+
"add",
|
|
110
|
+
"--scope",
|
|
111
|
+
"user",
|
|
112
|
+
...envFlags("-e", s.env),
|
|
113
|
+
s.name,
|
|
114
|
+
"--",
|
|
115
|
+
s.command,
|
|
116
|
+
...s.args,
|
|
117
|
+
],
|
|
118
|
+
check: ["claude", "mcp", "get", s.name],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "codex",
|
|
123
|
+
title: "Codex CLI",
|
|
124
|
+
scopes: ["user"],
|
|
125
|
+
detect: (e) => e.which("codex"),
|
|
126
|
+
plan: (s) => ({
|
|
127
|
+
kind: "exec",
|
|
128
|
+
remove: ["codex", "mcp", "remove", s.name],
|
|
129
|
+
add: [
|
|
130
|
+
"codex",
|
|
131
|
+
"mcp",
|
|
132
|
+
"add",
|
|
133
|
+
...envFlags("--env", s.env),
|
|
134
|
+
s.name,
|
|
135
|
+
"--",
|
|
136
|
+
s.command,
|
|
137
|
+
...s.args,
|
|
138
|
+
],
|
|
139
|
+
check: ["codex", "mcp", "get", s.name],
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "cursor",
|
|
144
|
+
title: "Cursor",
|
|
145
|
+
scopes: ["user", "project"],
|
|
146
|
+
detect: (e) => e.which("cursor") || e.exists(path.join(e.home, ".cursor")),
|
|
147
|
+
plan: (s, scope, e) =>
|
|
148
|
+
json(
|
|
149
|
+
path.join(
|
|
150
|
+
scope === "project" ? e.projectDir : e.home,
|
|
151
|
+
".cursor",
|
|
152
|
+
"mcp.json",
|
|
153
|
+
),
|
|
154
|
+
s,
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "vscode",
|
|
159
|
+
title: "VS Code (Copilot agent)",
|
|
160
|
+
scopes: ["user", "project"],
|
|
161
|
+
detect: (e) => e.which("code"),
|
|
162
|
+
plan: (s, scope, e) =>
|
|
163
|
+
scope === "project"
|
|
164
|
+
? json(path.join(e.projectDir, ".vscode", "mcp.json"), s, "servers", {
|
|
165
|
+
type: "stdio",
|
|
166
|
+
...stdioEntry(s),
|
|
167
|
+
})
|
|
168
|
+
: {
|
|
169
|
+
kind: "exec",
|
|
170
|
+
add: [
|
|
171
|
+
"code",
|
|
172
|
+
"--add-mcp",
|
|
173
|
+
JSON.stringify({ name: s.name, type: "stdio", ...stdioEntry(s) }),
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "claude-desktop",
|
|
179
|
+
title: "Claude Desktop",
|
|
180
|
+
scopes: ["user"],
|
|
181
|
+
detect: (e) => e.exists(appSupport(e, "Claude")),
|
|
182
|
+
plan: (s, _scope, e) =>
|
|
183
|
+
json(path.join(appSupport(e, "Claude"), "claude_desktop_config.json"), s),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "windsurf",
|
|
187
|
+
title: "Windsurf",
|
|
188
|
+
scopes: ["user"],
|
|
189
|
+
detect: (e) =>
|
|
190
|
+
e.which("windsurf") ||
|
|
191
|
+
e.exists(path.join(e.home, ".codeium", "windsurf")),
|
|
192
|
+
plan: (s, _scope, e) =>
|
|
193
|
+
json(path.join(e.home, ".codeium", "windsurf", "mcp_config.json"), s),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
id: "gemini",
|
|
197
|
+
title: "Gemini CLI",
|
|
198
|
+
scopes: ["user", "project"],
|
|
199
|
+
detect: (e) => e.which("gemini"),
|
|
200
|
+
plan: (s, scope, e) =>
|
|
201
|
+
json(
|
|
202
|
+
path.join(
|
|
203
|
+
scope === "project" ? e.projectDir : e.home,
|
|
204
|
+
".gemini",
|
|
205
|
+
"settings.json",
|
|
206
|
+
),
|
|
207
|
+
s,
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
export function findClient(id: string): McpClient | undefined {
|
|
213
|
+
return MCP_CLIENTS.find((c) => c.id === id);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The PATH a launched server needs: node's own directory first. */
|
|
217
|
+
export function serverPath(nodeBinDir: string, platform: NodeJS.Platform) {
|
|
218
|
+
const base =
|
|
219
|
+
platform === "win32"
|
|
220
|
+
? []
|
|
221
|
+
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
|
222
|
+
return [nodeBinDir, ...base.filter((d) => d !== nodeBinDir)].join(
|
|
223
|
+
platform === "win32" ? ";" : ":",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The server this distribution registers. */
|
|
228
|
+
export function serverSpec(o: {
|
|
229
|
+
cli: string;
|
|
230
|
+
distRoot: string;
|
|
231
|
+
nodeBinDir: string;
|
|
232
|
+
platform: NodeJS.Platform;
|
|
233
|
+
port?: number;
|
|
234
|
+
}): ServerSpec {
|
|
235
|
+
return {
|
|
236
|
+
name: o.cli,
|
|
237
|
+
command: path.join(o.distRoot, "bin", o.cli),
|
|
238
|
+
args: ["mcp", ...(o.port !== undefined ? ["--port", String(o.port)] : [])],
|
|
239
|
+
env: { PATH: serverPath(o.nodeBinDir, o.platform) },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── JSON configs ────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `text` (a client's config file, or null when absent) with `name` set under
|
|
247
|
+
* `key`. Refuses to rewrite a file it cannot parse: a client's config is the
|
|
248
|
+
* developer's, and clobbering it loses their other servers.
|
|
249
|
+
*/
|
|
250
|
+
export function mergeJsonConfig(
|
|
251
|
+
text: string | null,
|
|
252
|
+
key: string,
|
|
253
|
+
name: string,
|
|
254
|
+
entry: Record<string, unknown> | null,
|
|
255
|
+
): string {
|
|
256
|
+
let doc: Record<string, unknown> = {};
|
|
257
|
+
if (text !== null && text.trim()) {
|
|
258
|
+
try {
|
|
259
|
+
doc = JSON.parse(text) as Record<string, unknown>;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`not valid JSON (${(e as Error).message}); fix it or add the server by hand`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
266
|
+
throw new Error("not a JSON object; add the server by hand");
|
|
267
|
+
}
|
|
268
|
+
const servers =
|
|
269
|
+
doc[key] && typeof doc[key] === "object" && !Array.isArray(doc[key])
|
|
270
|
+
? { ...(doc[key] as Record<string, unknown>) }
|
|
271
|
+
: {};
|
|
272
|
+
if (entry === null) delete servers[name];
|
|
273
|
+
else servers[name] = entry;
|
|
274
|
+
return `${JSON.stringify({ ...doc, [key]: servers }, null, 2)}\n`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Whether a JSON config already registers `name` with this entry. */
|
|
278
|
+
export function jsonHasEntry(
|
|
279
|
+
text: string | null,
|
|
280
|
+
key: string,
|
|
281
|
+
name: string,
|
|
282
|
+
entry: Record<string, unknown>,
|
|
283
|
+
): "connected" | "different" | "absent" {
|
|
284
|
+
if (!text) return "absent";
|
|
285
|
+
try {
|
|
286
|
+
const doc = JSON.parse(text) as Record<string, Record<string, unknown>>;
|
|
287
|
+
const have = doc?.[key]?.[name];
|
|
288
|
+
if (have === undefined) return "absent";
|
|
289
|
+
return JSON.stringify(have) === JSON.stringify(entry)
|
|
290
|
+
? "connected"
|
|
291
|
+
: "different";
|
|
292
|
+
} catch {
|
|
293
|
+
return "absent";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A generic snippet for any other MCP client. */
|
|
298
|
+
export function genericSnippet(spec: ServerSpec): string {
|
|
299
|
+
return JSON.stringify(
|
|
300
|
+
{ mcpServers: { [spec.name]: stdioEntry(spec) } },
|
|
301
|
+
null,
|
|
302
|
+
2,
|
|
303
|
+
);
|
|
304
|
+
}
|
package/src/ui/mcp/guards.ts
CHANGED
|
@@ -61,7 +61,10 @@ export function runsLocally(entry: ServiceEntryLike): boolean {
|
|
|
61
61
|
return entry.commands.includes("up");
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export function assertRunsLocally(
|
|
64
|
+
export function assertRunsLocally(
|
|
65
|
+
entry: ServiceEntryLike,
|
|
66
|
+
command: string = "up",
|
|
67
|
+
): void {
|
|
65
68
|
const address = `${entry.repo ?? "?"}/${entry.service ?? "?"}`;
|
|
66
69
|
if (entry.runs === "remote")
|
|
67
70
|
throw new Refusal(
|
|
@@ -69,7 +72,8 @@ export function assertRunsLocally(entry: ServiceEntryLike): void {
|
|
|
69
72
|
address,
|
|
70
73
|
{ runs: "remote", target: entry.target ?? null },
|
|
71
74
|
(e) => isProductionEnv(e),
|
|
72
|
-
|
|
75
|
+
command,
|
|
76
|
+
) ?? `${address} runs remotely; nothing to ${command} here`,
|
|
73
77
|
);
|
|
74
78
|
if (!runsLocally(entry))
|
|
75
79
|
throw new Refusal(
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -119,6 +119,8 @@ export const MCP_USAGE = (cli: string): string =>
|
|
|
119
119
|
`An MCP server over stdio for agents, a client of the running web server`,
|
|
120
120
|
`(\`${cli} web up\`; default port: config web.port). Read tools always;`,
|
|
121
121
|
"actions only as named in the org config's mcp.actions. See docs/distributions.md.",
|
|
122
|
+
"",
|
|
123
|
+
`Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
|
|
122
124
|
].join("\n");
|
|
123
125
|
|
|
124
126
|
/** Run `<cli> mcp` until stdin closes. */
|