@monster-orange/den 0.2.0 → 0.2.2
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 +156 -0
- package/README.md +1 -1
- package/bin/den +3 -2
- package/docs/distributions.md +101 -9
- package/package.json +1 -1
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +218 -4
- package/src/shared/manifest/placement.ts +15 -3
- package/src/shared/manifest/validate-layers.ts +40 -1
- package/src/shared/stacks/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +20 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +238 -45
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +9 -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 +84 -1
- 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,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
|
@@ -115,13 +115,78 @@ function parsePort(argv: string[]): number | undefined {
|
|
|
115
115
|
export const MCP_USAGE = (cli: string): string =>
|
|
116
116
|
[
|
|
117
117
|
`Usage: ${cli} mcp [--port <web port>]`,
|
|
118
|
+
` ${cli} mcp --list`,
|
|
119
|
+
` ${cli} mcp --call <tool> ['<json args>']`,
|
|
118
120
|
"",
|
|
119
121
|
`An MCP server over stdio for agents, a client of the running web server`,
|
|
120
122
|
`(\`${cli} web up\`; default port: config web.port). Read tools always;`,
|
|
121
123
|
"actions only as named in the org config's mcp.actions. See docs/distributions.md.",
|
|
124
|
+
"",
|
|
125
|
+
"--list prints the tool names; --call runs one tool through the same guards,",
|
|
126
|
+
"prints its text result and exits 1 if the tool reports an error.",
|
|
127
|
+
"",
|
|
128
|
+
`Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
|
|
122
129
|
].join("\n");
|
|
123
130
|
|
|
124
|
-
/**
|
|
131
|
+
/** A one-shot invocation: `--list`, or `--call <tool> [json]`. */
|
|
132
|
+
export type OneShot =
|
|
133
|
+
| { kind: "list" }
|
|
134
|
+
| { kind: "call"; tool: string; args: Record<string, unknown> };
|
|
135
|
+
|
|
136
|
+
/** The one-shot `argv` asks for, or null for the stdio server. */
|
|
137
|
+
export function parseOneShot(argv: string[]): OneShot | null {
|
|
138
|
+
const i = argv.indexOf("--call");
|
|
139
|
+
if (i !== -1) {
|
|
140
|
+
const tool = argv[i + 1];
|
|
141
|
+
if (!tool || tool.startsWith("-"))
|
|
142
|
+
throw new Error("--call needs a tool name: --call <tool> ['<json>']");
|
|
143
|
+
const raw = argv[i + 2];
|
|
144
|
+
if (raw === undefined || raw.startsWith("--"))
|
|
145
|
+
return { kind: "call", tool, args: {} };
|
|
146
|
+
let args: unknown;
|
|
147
|
+
try {
|
|
148
|
+
args = JSON.parse(raw);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`--call ${tool}: arguments are not JSON (${(e as Error).message})`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
155
|
+
throw new Error(`--call ${tool}: arguments must be a JSON object`);
|
|
156
|
+
return { kind: "call", tool, args: args as Record<string, unknown> };
|
|
157
|
+
}
|
|
158
|
+
return argv.includes("--list") ? { kind: "list" } : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface OneShotOptions {
|
|
162
|
+
cfg: ResolvedMcpConfig;
|
|
163
|
+
cli: string;
|
|
164
|
+
api: DenApi;
|
|
165
|
+
out?: (text: string) => void;
|
|
166
|
+
err?: (text: string) => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run a one-shot through the tool layer the stdio server uses (callTool:
|
|
171
|
+
* mcp.actions, every guard, redaction). Returns the exit code.
|
|
172
|
+
*/
|
|
173
|
+
export async function runOneShot(
|
|
174
|
+
shot: OneShot,
|
|
175
|
+
opts: OneShotOptions,
|
|
176
|
+
): Promise<number> {
|
|
177
|
+
const out = opts.out ?? ((t: string) => process.stdout.write(t));
|
|
178
|
+
if (shot.kind === "list") {
|
|
179
|
+
const names = toolsFor(opts.cfg).map((t) => t.name);
|
|
180
|
+
out(names.length ? `${names.join("\n")}\n` : "");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
|
|
184
|
+
const r = await callTool(shot.tool, shot.args, ctx);
|
|
185
|
+
out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
|
|
186
|
+
return r.isError ? 1 : 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
|
|
125
190
|
export async function runMcp(argv: string[]): Promise<void> {
|
|
126
191
|
const cli = product().cli;
|
|
127
192
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -138,6 +203,24 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
138
203
|
}
|
|
139
204
|
const cfg = readMcpConfig();
|
|
140
205
|
const baseUrl = `http://localhost:${port}`;
|
|
206
|
+
const shot = parseOneShot(argv);
|
|
207
|
+
if (shot) {
|
|
208
|
+
// Recorded like any MCP action: actor agent, this process, one session.
|
|
209
|
+
const session = randomUUID();
|
|
210
|
+
const api = createDenApi({
|
|
211
|
+
baseUrl,
|
|
212
|
+
cli,
|
|
213
|
+
headers: () =>
|
|
214
|
+
agentAuditHeaders({
|
|
215
|
+
client: `${cli} mcp --call`,
|
|
216
|
+
clientVersion: engineVersion() ?? "unknown",
|
|
217
|
+
mcpPid: process.pid,
|
|
218
|
+
session,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
process.exitCode = await runOneShot(shot, { cfg, cli, api });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
141
224
|
const server = createDenMcpServer({ cfg, baseUrl, cli });
|
|
142
225
|
await server.connect(new StdioServerTransport());
|
|
143
226
|
process.stderr.write(
|
package/src/ui/mcp/tools.ts
CHANGED
|
@@ -85,15 +85,61 @@ function tail(r: StreamResult, n: number): string[] {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/** One line per stream: how it ended and its exit code. */
|
|
88
|
+
/**
|
|
89
|
+
* Whether a streamed action failed: a non-zero exit, a detached service whose
|
|
90
|
+
* launcher exited non-zero, or a run that never finished (timeout).
|
|
91
|
+
*/
|
|
92
|
+
export function streamFailed(r: StreamResult): boolean {
|
|
93
|
+
if (r.ended === "timeout") return true;
|
|
94
|
+
if (r.exitCode !== undefined && r.exitCode !== 0) return true;
|
|
95
|
+
return r.events.some(
|
|
96
|
+
(e) =>
|
|
97
|
+
e.type === "service-exited" &&
|
|
98
|
+
typeof e.exitCode === "number" &&
|
|
99
|
+
e.exitCode !== 0,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** An action that ran and failed: its output goes back with isError set. */
|
|
104
|
+
export class ActionFailed extends Error {
|
|
105
|
+
constructor(readonly out: Record<string, unknown>) {
|
|
106
|
+
super("action failed");
|
|
107
|
+
this.name = "ActionFailed";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How the call ended, in words an agent reads about the service: a stream
|
|
113
|
+
* stopped at `ready` is "ready", at a launcher's exit "handed-off".
|
|
114
|
+
*/
|
|
115
|
+
function endedLabel(r: StreamResult): string {
|
|
116
|
+
if (r.ended !== "stopped") return r.ended;
|
|
117
|
+
const last = r.events.at(-1)?.type;
|
|
118
|
+
if (last === "ready") return "ready";
|
|
119
|
+
if (last === "service-exited") return "handed-off";
|
|
120
|
+
return r.ended;
|
|
121
|
+
}
|
|
122
|
+
|
|
88
123
|
function streamSummary(r: StreamResult, keep: number) {
|
|
89
124
|
return {
|
|
90
|
-
ended: r
|
|
125
|
+
ended: endedLabel(r),
|
|
91
126
|
...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}),
|
|
92
127
|
lines: r.lines.length,
|
|
93
128
|
output: tail(r, keep),
|
|
94
129
|
};
|
|
95
130
|
}
|
|
96
131
|
|
|
132
|
+
/** The action's result; thrown as ActionFailed when the stream failed. */
|
|
133
|
+
function actionResult(
|
|
134
|
+
r: StreamResult,
|
|
135
|
+
keep: number,
|
|
136
|
+
head: Record<string, unknown> = {},
|
|
137
|
+
): Record<string, unknown> {
|
|
138
|
+
const out = { ...head, ...streamSummary(r, keep) };
|
|
139
|
+
if (streamFailed(r)) throw new ActionFailed({ failed: true, ...out });
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
// ── Read tools ──────────────────────────────────────────────────────────────
|
|
98
144
|
|
|
99
145
|
const read = (
|
|
@@ -354,7 +400,7 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
|
|
|
354
400
|
str(a.service),
|
|
355
401
|
workspace,
|
|
356
402
|
);
|
|
357
|
-
assertRunsLocally(entry);
|
|
403
|
+
assertRunsLocally(entry, cmd);
|
|
358
404
|
const r = await api.stream(
|
|
359
405
|
"/api/service/exec",
|
|
360
406
|
{
|
|
@@ -368,16 +414,20 @@ function lifecycleTool(cmd: LifecycleCmd): DenTool {
|
|
|
368
414
|
method: "GET",
|
|
369
415
|
maxLines: 2000,
|
|
370
416
|
timeoutMs: 180_000,
|
|
371
|
-
// A detached
|
|
372
|
-
//
|
|
373
|
-
|
|
417
|
+
// A detached start keeps streaming the service's log. It is done
|
|
418
|
+
// when the server says it is ready (health green, or still running
|
|
419
|
+
// with no check), or when its launch script exits (the hand-off).
|
|
420
|
+
stopOn: (e) => e.type === "ready" || e.type === "service-exited",
|
|
374
421
|
},
|
|
375
422
|
);
|
|
376
|
-
|
|
423
|
+
const ready = r.events.find((e) => e.type === "ready");
|
|
424
|
+
return actionResult(r, 200, {
|
|
377
425
|
service: `${entry.repo}/${entry.service}`,
|
|
378
426
|
cmd,
|
|
379
|
-
...
|
|
380
|
-
|
|
427
|
+
...(ready
|
|
428
|
+
? { ready: true, healthChecked: ready.checked === true }
|
|
429
|
+
: {}),
|
|
430
|
+
});
|
|
381
431
|
},
|
|
382
432
|
};
|
|
383
433
|
}
|
|
@@ -408,7 +458,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
408
458
|
maxLines: 5000,
|
|
409
459
|
timeoutMs: 15 * 60_000,
|
|
410
460
|
});
|
|
411
|
-
return
|
|
461
|
+
return actionResult(r, 300);
|
|
412
462
|
},
|
|
413
463
|
},
|
|
414
464
|
run_tests: {
|
|
@@ -453,7 +503,7 @@ const ACTION_TOOLS: Record<McpAction, DenTool> = {
|
|
|
453
503
|
},
|
|
454
504
|
{ method: "GET", maxLines: 20_000, timeoutMs: 60 * 60_000 },
|
|
455
505
|
);
|
|
456
|
-
return { pillar, env
|
|
506
|
+
return actionResult(r, 300, { pillar, env });
|
|
457
507
|
},
|
|
458
508
|
},
|
|
459
509
|
};
|
|
@@ -532,6 +582,28 @@ async function notFoundMessage(
|
|
|
532
582
|
* that refused, how to start the web server, or (when it runs an older Den
|
|
533
583
|
* than the tool needs) how to restart it.
|
|
534
584
|
*/
|
|
585
|
+
/**
|
|
586
|
+
* Tell the web server a guard refused an action, so the activity trail shows
|
|
587
|
+
* the attempt (F2). Best effort: a report that fails changes nothing.
|
|
588
|
+
*/
|
|
589
|
+
async function reportRefusal(
|
|
590
|
+
ctx: ToolContext,
|
|
591
|
+
tool: string,
|
|
592
|
+
args: Args,
|
|
593
|
+
reason: string,
|
|
594
|
+
): Promise<void> {
|
|
595
|
+
try {
|
|
596
|
+
await ctx.api.get("/api/activity", {}, "POST", {
|
|
597
|
+
tool,
|
|
598
|
+
...(typeof args.repo === "string" ? { repo: args.repo } : {}),
|
|
599
|
+
...(typeof args.service === "string" ? { service: args.service } : {}),
|
|
600
|
+
reason,
|
|
601
|
+
});
|
|
602
|
+
} catch {
|
|
603
|
+
/* the refusal is still returned to the agent */
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
535
607
|
export async function callTool(
|
|
536
608
|
name: string,
|
|
537
609
|
args: Args,
|
|
@@ -548,12 +620,19 @@ export async function callTool(
|
|
|
548
620
|
const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
|
|
549
621
|
return { text: redactSecrets(text), isError: false };
|
|
550
622
|
} catch (e) {
|
|
623
|
+
if (e instanceof ActionFailed)
|
|
624
|
+
return {
|
|
625
|
+
text: redactSecrets(JSON.stringify(e.out, null, 2)),
|
|
626
|
+
isError: true,
|
|
627
|
+
};
|
|
551
628
|
if (e instanceof DenHttpError && e.status === 404) {
|
|
552
629
|
const text = await notFoundMessage(e, name, ctx).catch(
|
|
553
630
|
(inner: Error) => `Error: ${inner.message}`,
|
|
554
631
|
);
|
|
555
632
|
return { text: redactSecrets(text), isError: true };
|
|
556
633
|
}
|
|
634
|
+
if (e instanceof Refusal && tool.action)
|
|
635
|
+
await reportRefusal(ctx, name, args, e.message);
|
|
557
636
|
const msg =
|
|
558
637
|
e instanceof Refusal
|
|
559
638
|
? `Refused: ${e.message}`
|
package/src/ui/server/audit.ts
CHANGED
|
@@ -20,9 +20,13 @@ export interface AuditEntry {
|
|
|
20
20
|
clientVersion?: string;
|
|
21
21
|
mcpPid?: number | null;
|
|
22
22
|
session?: string;
|
|
23
|
-
/** exec | run | sync */
|
|
23
|
+
/** exec | run | sync, or the MCP tool name for a refused non-lifecycle call */
|
|
24
24
|
verb: string;
|
|
25
25
|
detail: string;
|
|
26
|
+
/** Set when the action never ran: a guard or the server refused it. */
|
|
27
|
+
outcome?: "refused";
|
|
28
|
+
/** Why it was refused (first line). */
|
|
29
|
+
reason?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
type Sink = (line: string, actor: AgentActor | null) => void;
|
|
@@ -43,7 +47,12 @@ export function actorSuffix(actor: Actor): string {
|
|
|
43
47
|
|
|
44
48
|
export function recordAction(
|
|
45
49
|
actor: Actor,
|
|
46
|
-
action: {
|
|
50
|
+
action: {
|
|
51
|
+
verb: string;
|
|
52
|
+
detail: string;
|
|
53
|
+
outcome?: "refused";
|
|
54
|
+
reason?: string;
|
|
55
|
+
},
|
|
47
56
|
): AuditEntry {
|
|
48
57
|
const entry: AuditEntry = {
|
|
49
58
|
ts: new Date().toISOString(),
|
|
@@ -58,13 +67,70 @@ export function recordAction(
|
|
|
58
67
|
: {}),
|
|
59
68
|
verb: action.verb,
|
|
60
69
|
detail: action.detail,
|
|
70
|
+
...(action.outcome ? { outcome: action.outcome } : {}),
|
|
71
|
+
...(action.reason ? { reason: clean(action.reason, 300) } : {}),
|
|
61
72
|
};
|
|
62
73
|
ring.push(entry);
|
|
63
74
|
if (ring.length > RING_MAX) ring.shift();
|
|
64
|
-
|
|
75
|
+
const mark = entry.outcome === "refused" ? "✗" : "▶";
|
|
76
|
+
const why = entry.reason ? ` — refused: ${entry.reason}` : "";
|
|
77
|
+
sink?.(
|
|
78
|
+
`${mark} ${action.verb} ${action.detail}${why}${actorSuffix(actor)}`,
|
|
79
|
+
actor,
|
|
80
|
+
);
|
|
65
81
|
return entry;
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
/** One line, printable characters only, at most `max` long. */
|
|
85
|
+
function clean(v: unknown, max: number): string {
|
|
86
|
+
const line = String(v ?? "").split(/[\r\n]/)[0];
|
|
87
|
+
return [...line]
|
|
88
|
+
.filter((c) => c.charCodeAt(0) >= 0x20 && c.charCodeAt(0) !== 0x7f)
|
|
89
|
+
.join("")
|
|
90
|
+
.slice(0, max);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const SAFE_PART = /^[A-Za-z0-9._-]{1,128}$/;
|
|
94
|
+
const LIFECYCLE_TOOL = /^service_(up|down|restart)$/;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* An MCP guard refused an agent's call before it reached an action route.
|
|
98
|
+
* The agent's own report, so every field is sanitised: a lifecycle tool with
|
|
99
|
+
* a valid repo becomes an "exec" entry for that service (what the panel's
|
|
100
|
+
* activity list shows); anything else is recorded under the tool's name.
|
|
101
|
+
*/
|
|
102
|
+
export function recordAgentRefusal(
|
|
103
|
+
actor: Actor,
|
|
104
|
+
report: {
|
|
105
|
+
tool?: unknown;
|
|
106
|
+
repo?: unknown;
|
|
107
|
+
service?: unknown;
|
|
108
|
+
reason?: unknown;
|
|
109
|
+
},
|
|
110
|
+
): AuditEntry {
|
|
111
|
+
const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
|
|
112
|
+
const repo = typeof report.repo === "string" ? report.repo : "";
|
|
113
|
+
const service = typeof report.service === "string" ? report.service : "";
|
|
114
|
+
const lifecycle = LIFECYCLE_TOOL.exec(tool);
|
|
115
|
+
const reason = clean(report.reason, 300) || "refused";
|
|
116
|
+
if (lifecycle && SAFE_PART.test(repo)) {
|
|
117
|
+
const address =
|
|
118
|
+
service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
|
|
119
|
+
return recordAction(actor, {
|
|
120
|
+
verb: "exec",
|
|
121
|
+
detail: `${address} ${lifecycle[1]} (local)`,
|
|
122
|
+
outcome: "refused",
|
|
123
|
+
reason,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return recordAction(actor, {
|
|
127
|
+
verb: tool,
|
|
128
|
+
detail: "",
|
|
129
|
+
outcome: "refused",
|
|
130
|
+
reason,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
68
134
|
/** The most recent actions, oldest first. */
|
|
69
135
|
export function recentActions(): AuditEntry[] {
|
|
70
136
|
return ring.slice();
|