@monster-orange/den 0.1.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 +204 -0
- package/README.md +1 -1
- package/bin/den +8 -0
- package/docs/distributions.md +283 -12
- package/package.json +4 -2
- package/src/pillars/journeys/sandbox.ts +2 -5
- package/src/pillars/security/discover.ts +17 -1
- package/src/pillars/security/run.ts +31 -3
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/den-config.ts +122 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/environment-manager/health.ts +54 -8
- package/src/shared/environment-manager/load-applications.ts +31 -1
- package/src/shared/environment-manager/log-tail.ts +5 -4
- package/src/shared/environment-manager/opctl.ts +2 -1
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-runner.ts +19 -2
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +25 -1
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/placement.ts +333 -0
- package/src/shared/manifest/read-manifest.ts +156 -38
- package/src/shared/manifest/schema.ts +14 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +66 -6
- package/src/shared/paths.ts +12 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/stacks/manifest-commands.ts +30 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/types/index.ts +23 -0
- package/src/ui/cli/applications-command.ts +34 -2
- package/src/ui/cli/edge-command.ts +9 -19
- package/src/ui/cli/graph-command.ts +50 -4
- package/src/ui/cli/index.ts +184 -55
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/stack-command.ts +13 -40
- package/src/ui/cli/test-runner.ts +8 -1
- package/src/ui/mcp/api.ts +286 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +145 -0
- package/src/ui/mcp/server.ts +149 -0
- package/src/ui/mcp/tools.ts +644 -0
- package/src/ui/server/audit.ts +137 -0
- package/src/ui/server/changelog-parser.ts +11 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/index.ts +256 -61
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/log-history.ts +4 -3
- package/src/ui/server/public/environments.html +4 -2
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +980 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +7 -5
- package/src/ui/server/public/local-dev.html +130 -2191
- package/src/ui/server/public/logs.html +11 -4
- package/src/ui/server/public/panel.js +790 -0
- package/src/ui/server/public/services.html +6 -1
- package/src/ui/server/readonly-routes.ts +209 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +39 -8
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server's client for the running web server's HTTP API. The MCP
|
|
3
|
+
* server holds no state of its own: agents and the web UI share the server's
|
|
4
|
+
* state and its guards. This client can reach only the routes below, and it
|
|
5
|
+
* sends the agent's audit headers (see shared/agent-actor.ts) on every call.
|
|
6
|
+
*/
|
|
7
|
+
import { product } from "../../shared/product.js";
|
|
8
|
+
import { LIFECYCLE_CMDS, Refusal } from "./guards.js";
|
|
9
|
+
|
|
10
|
+
export type Query = Record<string, string | string[] | undefined>;
|
|
11
|
+
|
|
12
|
+
/** The web server isn't answering; the message names the start command. */
|
|
13
|
+
export class DenWebDown extends Error {
|
|
14
|
+
constructor(baseUrl: string, cli: string) {
|
|
15
|
+
super(
|
|
16
|
+
`The ${product().title} web server is not running at ${baseUrl}. ` +
|
|
17
|
+
`Start it with \`${cli} web up\` (the MCP server never starts it for you).`,
|
|
18
|
+
);
|
|
19
|
+
this.name = "DenWebDown";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The web server answered with an error status. */
|
|
24
|
+
export class DenHttpError extends Error {
|
|
25
|
+
constructor(
|
|
26
|
+
readonly method: string,
|
|
27
|
+
readonly path: string,
|
|
28
|
+
readonly status: number,
|
|
29
|
+
readonly body: string,
|
|
30
|
+
) {
|
|
31
|
+
super(`${method} ${path} → ${status}: ${body}`);
|
|
32
|
+
this.name = "DenHttpError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read routes: GET only. */
|
|
37
|
+
const READ_ROUTES = new Set([
|
|
38
|
+
"/api/health",
|
|
39
|
+
"/api/services",
|
|
40
|
+
"/api/applications",
|
|
41
|
+
"/api/graph",
|
|
42
|
+
"/api/workspaces",
|
|
43
|
+
"/api/environments",
|
|
44
|
+
"/api/logs/search",
|
|
45
|
+
"/api/service/taillog",
|
|
46
|
+
"/api/service/logs",
|
|
47
|
+
"/api/modules",
|
|
48
|
+
"/api/test-sources",
|
|
49
|
+
"/api/security/latest",
|
|
50
|
+
"/api/security/plan",
|
|
51
|
+
"/api/release/status",
|
|
52
|
+
"/api/release/runs",
|
|
53
|
+
"/api/manifest/validate",
|
|
54
|
+
"/api/stacks",
|
|
55
|
+
"/api/edge",
|
|
56
|
+
"/api/server-info",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Action routes and their method. Anything else is never reached. */
|
|
60
|
+
const ACTION_ROUTES: Record<string, string> = {
|
|
61
|
+
// A refusal report: appends one entry to the activity trail, nothing more.
|
|
62
|
+
"/api/activity": "POST",
|
|
63
|
+
"/api/service/exec": "GET",
|
|
64
|
+
"/api/run": "GET",
|
|
65
|
+
"/api/workspace/sync": "POST",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Throws a Refusal unless (method, path) is an allowed route. Never reached:
|
|
70
|
+
* sudo-password, aws/login, terminal/open, editor/open, workspace/exec,
|
|
71
|
+
* settings writes, workspace create/rename/mode, server-log, config, deploys.
|
|
72
|
+
* service/exec is allowed only for up/down/restart with env=local.
|
|
73
|
+
*/
|
|
74
|
+
export function assertAllowedRequest(
|
|
75
|
+
method: string,
|
|
76
|
+
path: string,
|
|
77
|
+
query: Query,
|
|
78
|
+
): void {
|
|
79
|
+
const ok =
|
|
80
|
+
(READ_ROUTES.has(path) &&
|
|
81
|
+
(method === "GET" ||
|
|
82
|
+
(method === "POST" && path === "/api/manifest/validate"))) ||
|
|
83
|
+
ACTION_ROUTES[path] === method;
|
|
84
|
+
if (!ok) throw new Refusal(`${method} ${path} is not exposed to agents.`);
|
|
85
|
+
if (path === "/api/service/exec") {
|
|
86
|
+
const cmd = String(query.cmd ?? "");
|
|
87
|
+
if (!(LIFECYCLE_CMDS as readonly string[]).includes(cmd))
|
|
88
|
+
throw new Refusal(
|
|
89
|
+
`service/exec "${cmd}" refused: only ${LIFECYCLE_CMDS.join(", ")}.`,
|
|
90
|
+
);
|
|
91
|
+
if (query.env !== "local")
|
|
92
|
+
throw new Refusal("service/exec refused: env=local only.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SseEvent {
|
|
97
|
+
type?: string;
|
|
98
|
+
text?: string;
|
|
99
|
+
exitCode?: number;
|
|
100
|
+
[k: string]: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface StreamOpts {
|
|
104
|
+
method?: "GET" | "POST";
|
|
105
|
+
/** Stop after this many output lines (a follow stream never ends itself). */
|
|
106
|
+
maxLines: number;
|
|
107
|
+
/** Stop after this long, whatever is happening. */
|
|
108
|
+
timeoutMs: number;
|
|
109
|
+
/** Stop once nothing has arrived for this long (for log tails). */
|
|
110
|
+
idleMs?: number;
|
|
111
|
+
/** Stop when this event arrives (e.g. a detached `up` handing off). */
|
|
112
|
+
stopOn?: (e: SseEvent) => boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface StreamResult {
|
|
116
|
+
events: SseEvent[];
|
|
117
|
+
lines: string[];
|
|
118
|
+
ended: "done" | "closed" | "limit" | "timeout" | "idle" | "stopped";
|
|
119
|
+
exitCode?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DenApi {
|
|
123
|
+
baseUrl: string;
|
|
124
|
+
/** A JSON route. */
|
|
125
|
+
get(
|
|
126
|
+
path: string,
|
|
127
|
+
query?: Query,
|
|
128
|
+
method?: "GET" | "POST",
|
|
129
|
+
body?: unknown,
|
|
130
|
+
): Promise<unknown>;
|
|
131
|
+
/** An SSE route, read to a bound; the connection is closed after. */
|
|
132
|
+
stream(path: string, query: Query, opts: StreamOpts): Promise<StreamResult>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildUrl(base: string, path: string, query: Query = {}): string {
|
|
136
|
+
const u = new URL(path, base);
|
|
137
|
+
for (const [k, v] of Object.entries(query)) {
|
|
138
|
+
if (v === undefined) continue;
|
|
139
|
+
for (const one of Array.isArray(v) ? v : [v]) u.searchParams.append(k, one);
|
|
140
|
+
}
|
|
141
|
+
return u.toString();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isConnectionError(e: unknown): boolean {
|
|
145
|
+
const err = e as { name?: string; cause?: { code?: string } };
|
|
146
|
+
if (err?.name === "AbortError") return false;
|
|
147
|
+
const code = err?.cause?.code ?? "";
|
|
148
|
+
return (
|
|
149
|
+
err?.name === "TypeError" ||
|
|
150
|
+
["ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "EHOSTUNREACH"].includes(code)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const OUTPUT_TYPES = new Set(["line", "cmd", "error"]);
|
|
155
|
+
|
|
156
|
+
export function createDenApi(opts: {
|
|
157
|
+
baseUrl: string;
|
|
158
|
+
cli: string;
|
|
159
|
+
headers: () => Record<string, string>;
|
|
160
|
+
}): DenApi {
|
|
161
|
+
const { baseUrl, cli } = opts;
|
|
162
|
+
|
|
163
|
+
async function request(
|
|
164
|
+
method: string,
|
|
165
|
+
path: string,
|
|
166
|
+
query: Query,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
body?: unknown,
|
|
169
|
+
): Promise<Response> {
|
|
170
|
+
assertAllowedRequest(method, path, query);
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetch(buildUrl(baseUrl, path, query), {
|
|
173
|
+
method,
|
|
174
|
+
headers: {
|
|
175
|
+
...opts.headers(),
|
|
176
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
177
|
+
},
|
|
178
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
179
|
+
signal,
|
|
180
|
+
});
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
const text = (await res.text()).slice(0, 2000);
|
|
183
|
+
throw new DenHttpError(method, path, res.status, text);
|
|
184
|
+
}
|
|
185
|
+
return res;
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (isConnectionError(e)) throw new DenWebDown(baseUrl, cli);
|
|
188
|
+
throw e;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
baseUrl,
|
|
194
|
+
|
|
195
|
+
async get(path, query = {}, method = "GET", body) {
|
|
196
|
+
const res = await request(method, path, query, undefined, body);
|
|
197
|
+
const text = await res.text();
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(text) as unknown;
|
|
200
|
+
} catch {
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async stream(path, query, s) {
|
|
206
|
+
const ac = new AbortController();
|
|
207
|
+
const res = await request(s.method ?? "GET", path, query, ac.signal);
|
|
208
|
+
const events: SseEvent[] = [];
|
|
209
|
+
const lines: string[] = [];
|
|
210
|
+
let exitCode: number | undefined;
|
|
211
|
+
let ended: StreamResult["ended"] = "closed";
|
|
212
|
+
let stop = false;
|
|
213
|
+
|
|
214
|
+
const finish = (why: StreamResult["ended"]): void => {
|
|
215
|
+
if (stop) return;
|
|
216
|
+
stop = true;
|
|
217
|
+
ended = why;
|
|
218
|
+
ac.abort();
|
|
219
|
+
};
|
|
220
|
+
const overall = setTimeout(() => finish("timeout"), s.timeoutMs);
|
|
221
|
+
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
222
|
+
const bumpIdle = (): void => {
|
|
223
|
+
if (!s.idleMs) return;
|
|
224
|
+
if (idle) clearTimeout(idle);
|
|
225
|
+
idle = setTimeout(() => finish("idle"), s.idleMs);
|
|
226
|
+
};
|
|
227
|
+
bumpIdle();
|
|
228
|
+
|
|
229
|
+
const onEvent = (e: SseEvent): void => {
|
|
230
|
+
events.push(e);
|
|
231
|
+
if (OUTPUT_TYPES.has(String(e.type)) && typeof e.text === "string") {
|
|
232
|
+
for (const l of e.text.replace(/\n+$/, "").split("\n")) {
|
|
233
|
+
if (lines.length >= s.maxLines) return finish("limit");
|
|
234
|
+
lines.push(l);
|
|
235
|
+
}
|
|
236
|
+
if (lines.length >= s.maxLines) return finish("limit");
|
|
237
|
+
}
|
|
238
|
+
if (e.type === "done") {
|
|
239
|
+
if (typeof e.exitCode === "number") exitCode = e.exitCode;
|
|
240
|
+
return finish("done");
|
|
241
|
+
}
|
|
242
|
+
if (s.stopOn?.(e)) finish("stopped");
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const reader = res.body?.getReader();
|
|
247
|
+
const decoder = new TextDecoder();
|
|
248
|
+
let buf = "";
|
|
249
|
+
while (reader && !stop) {
|
|
250
|
+
const { value, done } = await reader.read();
|
|
251
|
+
if (done) break;
|
|
252
|
+
bumpIdle();
|
|
253
|
+
buf += decoder.decode(value, { stream: true });
|
|
254
|
+
let i: number;
|
|
255
|
+
while (!stop && (i = buf.indexOf("\n\n")) !== -1) {
|
|
256
|
+
const chunk = buf.slice(0, i);
|
|
257
|
+
buf = buf.slice(i + 2);
|
|
258
|
+
const data = chunk
|
|
259
|
+
.split("\n")
|
|
260
|
+
.filter((l) => l.startsWith("data:"))
|
|
261
|
+
.map((l) => l.slice(5).trimStart())
|
|
262
|
+
.join("\n");
|
|
263
|
+
if (!data) continue;
|
|
264
|
+
try {
|
|
265
|
+
onEvent(JSON.parse(data) as SseEvent);
|
|
266
|
+
} catch {
|
|
267
|
+
onEvent({ type: "line", text: data });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {
|
|
272
|
+
if (!stop) throw e;
|
|
273
|
+
} finally {
|
|
274
|
+
clearTimeout(overall);
|
|
275
|
+
if (idle) clearTimeout(idle);
|
|
276
|
+
if (!stop) ac.abort();
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
events,
|
|
280
|
+
lines,
|
|
281
|
+
ended,
|
|
282
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -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
|
+
}
|